diff --git a/.github/agents/fixlab-autofix.agent.md b/.github/agents/fixlab-autofix.agent.md index 204ee66..2dc0a1d 100644 --- a/.github/agents/fixlab-autofix.agent.md +++ b/.github/agents/fixlab-autofix.agent.md @@ -37,6 +37,15 @@ only for information that cannot be established safely from the repository. ## Bugfix loop +Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE +--from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. +The source profile declares non-sensitive configuration in `worktreeSetup.files`. +Never copy credentials or browser state. Missing configuration, authentication +or denied runtime access is a blocker; do not retry unchanged approvals or +grant blanket path access. Sign-in and MFA remain human-controlled. +In read-only modes, do not provision or alter configuration; report missing +prerequisites as blocked. + 1. **Classify scope.** Identify the smallest component in the profile that owns the behavior. Treat its source paths as the initial edit allowlist. 2. **Reproduce or measure.** Locate the affected code and establish a failing @@ -92,9 +101,24 @@ pass, remove transient validation artifacts, review the effective diff, commit and push that checkpoint, and create or update a draft pull request. Run remaining independent builds and browser journeys while the draft is open, 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 +until every required local gate passes or the developer explicitly accepts the recorded outcome. +After required local validation and approval gates pass or have an explicitly +accepted outcome, publish the authorized PR by marking it ready for review. +Do not wait for remote PR CI or build completion before publication; publication +can trigger those checks. Verify the PR is no longer draft before reporting the +PR stage passed. Report remote checks as pending, not passed, and leave merge +policies intact. Use `gh pr ready ` for GitHub or +`az repos pr update --id --draft false` for Azure DevOps, then read back +`isDraft` from the provider to confirm it is false. If publication or state +verification fails, report the PR stage blocked or failed, not passed. + +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/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98ad451..bccdd03 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ permissions: contents: read jobs: + windows-containment: + runs-on: windows-latest + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version-file: .nvmrc + - run: npm ci + - run: node --test test\windows-executor.test.js test\dashboard-executor-lease.test.js + test: runs-on: ubuntu-latest outputs: diff --git a/CHANGELOG.md b/CHANGELOG.md index 716508a..926995d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,21 @@ All notable changes to FixLab are documented in this file. ## Unreleased +- Keep managed frontend input open so React-style development servers do not + stop on EOF; preserve one-shot helper behavior and owned process cleanup. + +- Add approved, configuration-only worktree onboarding with `--from`, preserved + custom files, missing-browser-config preflight, and reviewed exact-directory + grants for linked dependencies; keep credentials and MFA human-controlled. +- Make chat detect incomplete profiles, open an explicit interactive setup + request, and verify Doctor before launching a new dashboard. + +- Give Windows tooling descendants one inherited hidden console while retaining + captured input/output, Job Object containment, and termination proofs. +- Publish authorized pull requests after required local validation and approval, + verify draft status is cleared, and leave publication-triggered remote builds + pending rather than waiting for them before publication. + ## 0.7.3 - 2026-10-05 - Wait for the native keep-awake inhibitor to become ready before reporting diff --git a/README.md b/README.md index 42455e4..4f6b9c4 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 @@ -81,6 +94,15 @@ For explicit plan-first onboarding without entering the interactive shell: fixlab onboard C:\path\to\application ``` +For a new worktree of an already configured repository, use +`fixlab onboard C:\path\to\worktree --from C:\path\to\application`. +Review the source profile and its `worktreeSetup.files` allowlist, then rerun +with `--yes` to provision only those non-sensitive files and run setup. +Onboarding also discovers external linked `node_modules` directories and +requires explicit approval before recording their exact runtime path grants. +Authentication and MFA remain interactive; credentials and browser state are +never copied. See [Repository onboarding](docs/repository-onboarding.md). + The first onboarding run creates the repository-owned profile and stops for review. After configuring that profile, rerun to inspect the setup plan, then approve dependency restoration, Playwright setup, browser authentication, and @@ -187,7 +209,8 @@ visible risk. FixLab never reports a skipped or timed-out gate as successful. authentication, application startup, and configurable doctor timeouts before expensive work begins. - **Early draft pull requests** that become visible after focused regression - checks while independent builds and browser validation continue. + checks while independent local builds and browser validation continue, then + publish after local validation and approval without waiting for remote CI. - **Playwright evidence** including measurable assertions, screenshots, videos, traces, failures, explicit data-source behavior, connect-and-retry authentication, and reuse of an already started React frontend. diff --git a/agents/fixlab.md b/agents/fixlab.md index d26a326..46bb6ce 100644 --- a/agents/fixlab.md +++ b/agents/fixlab.md @@ -38,6 +38,15 @@ instructions provide authoritative commands. ## Workflow +Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE +--from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. +The source profile declares non-sensitive configuration in `worktreeSetup.files`. +Never copy credentials or browser state. Missing configuration, authentication +or denied runtime access is a blocker; do not retry unchanged approvals or +grant blanket path access. Sign-in and MFA remain human-controlled. +In read-only modes, do not provision or alter configuration; report missing +prerequisites as blocked. + 1. Read repository instructions and inspect the effective source state. 2. Identify the smallest affected component and relevant existing tests. 3. For code changes, use an isolated worktree when the current workspace is not @@ -70,9 +79,24 @@ commit and push that checkpoint, and create or update a draft pull request. Run remaining independent builds, application checks, and browser journeys while the draft is open. Push later reviewed checkpoints to the same branch, 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 +review until every required local gate passes or repository policy records an explicitly accepted outcome. +After required local validation and approval gates pass or have an explicitly +accepted outcome, publish the authorized PR by marking it ready for review. +Do not wait for remote PR CI or build completion before publication; publication +can trigger those checks. Verify the PR is no longer draft before reporting the +PR stage passed. Report remote checks as pending, not passed, and leave merge +policies intact. Use `gh pr ready ` for GitHub or +`az repos pr update --id --draft false` for Azure DevOps, then read back +`isDraft` from the provider to confirm it is false. If publication or state +verification fails, report the PR stage blocked or failed, not passed. + +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 @@ -92,6 +116,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 9ea6402..2ec425d 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -7,7 +7,8 @@ import { existsSync, mkdirSync, readFileSync, - statSync + statSync, + writeFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { createInterface } from "node:readline/promises"; @@ -16,12 +17,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 @@ -35,6 +39,11 @@ import { stopKeepAwake } from "./keep-awake.js"; import { runStructuredCommand } from "./structured-command.js"; +import { + applyWorktreeSetup, + browserConfigurationErrors, + planWorktreeSetup +} from "../dashboard/repository-setup.js"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const profileRelativePath = join( @@ -77,18 +86,18 @@ function printUsage() { console.log(`FixLab CLI Usage: - fixlab [--runtime ] - fixlab onboard [repository] [--yes] [--authenticate] [--start-dashboard] [--runtime ] + fixlab [--runtime ] [--model ] + fixlab onboard [repository] [--from ] [--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 ] [--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 +125,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. @@ -136,7 +147,7 @@ function findExecutable(command) { const result = spawnSync( process.platform === "win32" ? "where.exe" : "which", [command], - { encoding: "utf8", shell: false } + { encoding: "utf8", shell: false, windowsHide: true } ); return result.status === 0; } @@ -153,7 +164,8 @@ function checkDotnetSdk(repository) { const result = spawnSync("dotnet", ["--version"], { cwd: repository, encoding: "utf8", - shell: false + shell: false, + windowsHide: true }); const version = result.stdout?.trim(); if (result.status === 0 && version) { @@ -330,7 +342,8 @@ function checkPlaywright(repository, profile) { const packageResult = spawnSync(process.execPath, ["-e", packageProbe], { cwd: workingDirectory, encoding: "utf8", - timeout: 15000 + timeout: 15000, + windowsHide: true }); const resolvedPackage = packageResult.stdout?.trim(); const packageCheck = { @@ -377,7 +390,8 @@ function checkPlaywright(repository, profile) { const browserResult = spawnSync(process.execPath, ["-e", browserProbe], { cwd: workingDirectory, encoding: "utf8", - timeout: 45000 + timeout: 45000, + windowsHide: true }); const launchedBrowser = browserResult.stdout?.trim(); const expectedBrowser = browserChannel @@ -475,6 +489,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 +524,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) { @@ -533,6 +568,16 @@ function doctor(repository, runtime) { ok: Boolean(profile), detail: error ?? profilePath }); + const grantedDirectories = new Set(linkedDependencyDirectories(repository)); + const unapprovedDirectories = linkedDependencyDirectories(repository, { discover: true }) + .filter((directory) => !grantedDirectories.has(directory)); + checks.push({ + name: "Runtime dependency access", + ok: unapprovedDirectories.length === 0, + detail: unapprovedDirectories.length + ? `unapproved linked dependencies: ${unapprovedDirectories.join(", ")}; review fixlab onboard "${repository}" --runtime ${runtime}, then approve with --yes` + : "no unapproved external dependency directories" + }); const frontendWorkingDirectory = resolve( repository, profile?.applications?.frontend?.workingDirectory ?? "." @@ -792,7 +837,8 @@ function setupPlaywright(repository, approved) { const result = spawnSync(command, args, { cwd: workingDirectory, stdio: "inherit", - shell: process.platform === "win32" + shell: process.platform === "win32", + windowsHide: true }); if (result.status !== 0) { console.error(`Playwright setup failed: ${formatCommand(command, args)}`); @@ -896,7 +942,7 @@ function authenticate(repository, approved) { return 0; } -function launch(repository, request, runtime, targetEnvironment = "") { +function launch(repository, request, runtime, targetEnvironment = "", model = "", readOnly = false, interactiveRequest = "") { const { profile, error } = loadProfile(repository); if (error) { console.error( @@ -924,6 +970,11 @@ function launch(repository, request, runtime, targetEnvironment = "") { } const guidance = [ + "Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE --from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. The source profile declares non-sensitive configuration in `worktreeSetup.files`. Never copy credentials or browser state. Missing configuration, authentication or denied runtime access is a blocker; do not retry unchanged approvals or grant blanket path access. Sign-in and MFA remain human-controlled. In read-only modes, do not provision or alter configuration; report missing prerequisites as blocked.", + "Prefix FixLab-created pull-request titles with `FLAB:` followed by a concise coding-change description. Use the repository's PR template with clear Markdown sections for the problem, changes, validation and risks, and accurate validation checkboxes.", + readOnly + ? "Do not publish a pull request or change its draft status in read-only mode." + : "After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the PR stage passed. Report remote checks as pending, not passed, and leave merge policies intact. Use `gh pr ready PR_NUMBER` for GitHub or `az repos pr update --id PR_ID --draft false` for Azure DevOps, then read back `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed.", "Treat runtime-synthesized Playwright scenarios as 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 generated authenticated tests such as *.auth.spec.ts unless the user explicitly requests permanent browser-test coverage or repository instructions require that exact persisted test.", ...(selectedEnvironment ? [ @@ -942,8 +993,8 @@ function launch(repository, request, runtime, targetEnvironment = "") { : []) ].join(" "); const prompt = - request || selectedEnvironment - ? `${guidance}${request ? ` Request: ${request}` : ""}` + request || selectedEnvironment || interactiveRequest + ? `${guidance}${request || interactiveRequest ? ` Request: ${request || interactiveRequest}` : ""}` : ""; const invocation = request ? runtime === "copilot" @@ -951,6 +1002,7 @@ function launch(repository, request, runtime, targetEnvironment = "") { packageRoot, prompt, sessionId: randomUUID(), + model, additionalDirs: linkedDependencyDirectories(repository) }) : buildAgencyInvocation({ @@ -966,6 +1018,8 @@ function launch(repository, request, runtime, targetEnvironment = "") { packageRoot, "--agent", "fixlab:fixlab", + ...linkedDependencyDirectories(repository).flatMap((directory) => ["--add-dir", directory]), + ...(runtime === "copilot" ? copilotModelArguments(model) : []), ...(prompt ? ["--interactive", prompt] : []) ] }; @@ -1037,10 +1091,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; @@ -1126,9 +1184,19 @@ function parseOnboardArguments(args) { let repositoryArgument; let approved = false; + let source; let authenticateBrowser = false; let startDashboard = false; - for (const argument of runtimeArguments.args) { + for (let index = 0; index < runtimeArguments.args.length; index += 1) { + const argument = runtimeArguments.args[index]; + if (argument === "--from") { + const value = runtimeArguments.args[++index]; + if (!value || value.startsWith("-")) { + return { error: "onboard requires a source checkout after --from" }; + } + source = resolveRepository(value); + continue; + } if (argument === "--yes") { approved = true; continue; @@ -1153,6 +1221,8 @@ function parseOnboardArguments(args) { return { repository: resolveRepository(repositoryArgument), runtime: runtimeArguments.runtime, + model: runtimeArguments.model, + source, approved, authenticateBrowser, startDashboard @@ -1162,16 +1232,39 @@ function parseOnboardArguments(args) { async function onboard({ repository, runtime, + model, + source, approved, authenticateBrowser, startDashboard }) { const profilePath = join(repository, profileRelativePath); - const profileExisted = existsSync(profilePath); + let profileExisted = existsSync(profilePath); console.log("FixLab onboarding"); console.log(` repository: ${repository}`); console.log(` runtime: ${runtime}`); + if (model) { + console.log(` model: ${model}`); + } + + if (source) { + const plan = planWorktreeSetup({ + source, + repository, + templatePath: join(packageRoot, "templates", "repository-profile.json") + }); + console.log(` source profile: ${join(plan.source, profileRelativePath)}`); + for (const file of plan.files) { + console.log(` ${file.replaceTemplate ? "Replace unchanged example" : file.previous ? "Preserve matching file" : "Provision"}: ${file.name}`); + } + if (!approved) { + console.log("No files or commands changed. Review the source profile, declared worktreeSetup.files and restore commands, then rerun with --yes."); + return 0; + } + applyWorktreeSetup(plan); + profileExisted = true; + } const initResult = init(repository); if (initResult !== 0) { @@ -1192,6 +1285,27 @@ async function onboard({ return 0; } + const { profile, error } = loadProfile(repository); + if (error) { + console.error(`Cannot onboard repository because ${error}.`); + return 1; + } + const configurationErrors = browserConfigurationErrors(profile, repository); + if (configurationErrors.length) { + console.error(configurationErrors.join("\n")); + return 1; + } + if (profile.trustedDependencyRoots !== undefined && !Array.isArray(profile.trustedDependencyRoots)) { + console.error("trustedDependencyRoots must be an array of approved absolute dependency-directory paths."); + return 1; + } + const grantedDirectories = new Set(linkedDependencyDirectories(repository)); + const dependencyDirectories = linkedDependencyDirectories(repository, { discover: true }) + .filter((directory) => !grantedDirectories.has(directory)); + for (const directory of dependencyDirectories) { + console.log(`Runtime path approval required for linked dependencies: ${directory}`); + } + const preparationPlan = prepare(repository, false); if (preparationPlan !== 0) { return preparationPlan; @@ -1214,6 +1328,15 @@ async function onboard({ return 0; } + if (dependencyDirectories.length) { + profile.trustedDependencyRoots = [ + ...(profile.trustedDependencyRoots ?? []), + ...dependencyDirectories + ]; + writeFileSync(profilePath, `${JSON.stringify(profile, null, 2)}\n`); + console.log("Approved only the listed linked dependency directories; runtime deny rules remain in force."); + } + const preparationResult = prepare(repository, true); if (preparationResult !== 0) { return preparationResult; @@ -1234,13 +1357,13 @@ async function onboard({ return doctorResult; } if (startDashboard) { - return dashboard(repository, DEFAULT_DASHBOARD_PORT, true, 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; } @@ -1260,7 +1383,8 @@ function openBrowser(url) { const child = spawn(command, args, { detached: true, stdio: "ignore", - shell: false + shell: false, + windowsHide: true }); child.on("error", (error) => { console.error(`Could not open the system browser: ${error.message}`); @@ -1273,7 +1397,8 @@ async function dashboard( port, shouldOpen, runtime, - keepAwakeMinutes = 0 + keepAwakeMinutes = 0, + model = "" ) { const readiness = inspectRepository(repository); if (!readiness.repositoryReady) { @@ -1284,6 +1409,7 @@ async function dashboard( repository, packageRoot, runtime, + model, preflight: runJobPreflight }); let address; @@ -1297,6 +1423,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 { @@ -1437,6 +1566,7 @@ async function startDashboardForChat({ repositoryArgument, port, runtime, + model, baseUrl }) { let repository; @@ -1453,7 +1583,7 @@ async function startDashboardForChat({ } let readiness = inspectRepository(repository); - if (!readiness.repositoryReady) { + if (!readiness.profileReady || doctor(repository, runtime) !== 0) { if (!existsSync(join(repository, profileRelativePath))) { const initResult = init(repository); if (initResult !== 0) { @@ -1463,14 +1593,17 @@ 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, false, + `Set up this repository for FixLab. Read its instructions and manifests, configure the repository-owned profile, and review the setup plan with the user. After scoped approval, run fixlab onboard "${repository}" --runtime ${runtime} --yes. Add --authenticate only when the profile requires browser sign-in; let the user complete sign-in and MFA. Do not start a dashboard from this agent. Return only after Doctor passes, or report the exact unresolved prerequisite.` + ); if (onboardingResult !== 0) { throw new Error( `FixLab onboarding exited with code ${onboardingResult}` ); } readiness = inspectRepository(repository); - if (!readiness.repositoryReady) { + if (!readiness.profileReady || doctor(repository, runtime) !== 0) { throw new Error( `onboarding finished but the repository is not dashboard-ready: ${readiness.error}` ); @@ -1488,7 +1621,8 @@ async function startDashboardForChat({ String(port), "--no-open", "--runtime", - runtime + runtime, + ...(model ? ["--model", model] : []) ], { detached: true, @@ -1574,7 +1708,8 @@ async function main(args) { runArguments.repository, runArguments.request, parsed.runtime, - runArguments.targetEnvironment + runArguments.targetEnvironment, + parsed.model ); } @@ -1592,7 +1727,10 @@ 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, + true ); } @@ -1612,7 +1750,8 @@ async function main(args) { parsed.port, parsed.open, runtimeArguments.runtime, - parsed.keepAwakeMinutes + parsed.keepAwakeMinutes, + runtimeArguments.model ); } @@ -1670,16 +1809,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 }) => { @@ -1721,14 +1863,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 d26a326..46bb6ce 100644 --- a/com.github.copilot/agents/fixlab.agent.md +++ b/com.github.copilot/agents/fixlab.agent.md @@ -38,6 +38,15 @@ instructions provide authoritative commands. ## Workflow +Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE +--from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. +The source profile declares non-sensitive configuration in `worktreeSetup.files`. +Never copy credentials or browser state. Missing configuration, authentication +or denied runtime access is a blocker; do not retry unchanged approvals or +grant blanket path access. Sign-in and MFA remain human-controlled. +In read-only modes, do not provision or alter configuration; report missing +prerequisites as blocked. + 1. Read repository instructions and inspect the effective source state. 2. Identify the smallest affected component and relevant existing tests. 3. For code changes, use an isolated worktree when the current workspace is not @@ -70,9 +79,24 @@ commit and push that checkpoint, and create or update a draft pull request. Run remaining independent builds, application checks, and browser journeys while the draft is open. Push later reviewed checkpoints to the same branch, 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 +review until every required local gate passes or repository policy records an explicitly accepted outcome. +After required local validation and approval gates pass or have an explicitly +accepted outcome, publish the authorized PR by marking it ready for review. +Do not wait for remote PR CI or build completion before publication; publication +can trigger those checks. Verify the PR is no longer draft before reporting the +PR stage passed. Report remote checks as pending, not passed, and leave merge +policies intact. Use `gh pr ready ` for GitHub or +`az repos pr update --id --draft false` for Azure DevOps, then read back +`isDraft` from the provider to confirm it is false. If publication or state +verification fails, report the PR stage blocked or failed, not passed. + +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 @@ -92,6 +116,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 209143f..7c5012e 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"); @@ -134,6 +137,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" @@ -175,6 +181,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; @@ -236,6 +243,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("-") @@ -298,7 +324,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 @@ -501,10 +527,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; @@ -556,7 +580,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" }`; @@ -575,10 +615,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) { @@ -635,7 +677,8 @@ async function startFrontend() { renderFrontendStatus( await fetchJson("/api/frontend/start", { method: "POST" }) ); - } catch { + } catch (error) { + formError.textContent = error.message; await refreshFrontendStatus(); } } @@ -646,9 +689,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" }) ); @@ -1210,6 +1332,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.held, body.history); @@ -1250,12 +1373,14 @@ 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" : "common", runAllUiScenarios: data.get("runAllUiScenarios") === "on", targetEnvironment: data.get("targetEnvironment"), + prestartFrontend: data.get("prestartFrontend") === "on", recordPlaywrightVideo: data.get("recordPlaywrightVideo") === "on", manualLocalhostTest: @@ -1729,4 +1854,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 d1171c3..1bbd0a1 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,17 @@

Repository onboarding

+ +
Intake diff --git a/dashboard/repository-setup.js b/dashboard/repository-setup.js new file mode 100644 index 0000000..1175567 --- /dev/null +++ b/dashboard/repository-setup.js @@ -0,0 +1,154 @@ +import { constants, copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; +import { canonicalExecutionRepository } from "./executor-lease.js"; + +export const SETUP_PROFILE_PATH = join(".github", "fixlab", "repository-profile.json"); + +function setupPath(root, value) { + if ( + typeof value !== "string" || !value || + isAbsolute(value) || /^[A-Za-z]:|^[/\\]/u.test(value) || + value.split(/[/\\]/u).includes("..") + ) { + throw new Error("Setup files must use repository-relative paths without traversal"); + } + const path = resolve(root, value); + const parts = relative(root, path).split(sep); + let current = root; + for (const part of parts) { + current = join(current, part); + try { + if (lstatSync(current).isSymbolicLink()) { + throw new Error(`Setup paths must not follow symbolic links: ${value}`); + } + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + } + return path; +} + +function existingFile(path) { + if (!existsSync(path)) { + return null; + } + const info = lstatSync(path); + if (!info.isFile() || info.isSymbolicLink() || info.size > 1024 * 1024) { + throw new Error("Setup requires regular files no larger than 1 MiB"); + } + return readFileSync(path); +} + +export function planWorktreeSetup({ source, repository, templatePath }) { + const sourceRoot = realpathSync.native(source); + const targetRoot = realpathSync.native(repository); + if ( + sourceRoot === targetRoot || + canonicalExecutionRepository(sourceRoot).key !== canonicalExecutionRepository(targetRoot).key + ) { + throw new Error("--from must identify another checkout of the same Git repository"); + } + const sourceProfilePath = setupPath(sourceRoot, SETUP_PROFILE_PATH); + const rawProfile = existingFile(sourceProfilePath); + if (!rawProfile) { + throw new Error("The --from checkout has no repository profile; configure it before worktree onboarding"); + } + let profile; + try { + profile = JSON.parse(rawProfile.toString("utf8")); + } catch { + throw new Error("The source repository profile is invalid JSON; repair it before worktree onboarding"); + } + if (!profile || typeof profile !== "object" || Array.isArray(profile)) { + throw new Error("The source repository profile must be a JSON object"); + } + if (profile.worktreeSetup !== undefined && ( + !profile.worktreeSetup || typeof profile.worktreeSetup !== "object" || Array.isArray(profile.worktreeSetup) + )) { + throw new Error("worktreeSetup must be an object containing a files array"); + } + const configuredFiles = profile.worktreeSetup?.files === undefined ? [] : profile.worktreeSetup.files; + if (!Array.isArray(configuredFiles) || configuredFiles.some((value) => typeof value !== "string")) { + throw new Error("worktreeSetup.files must be an array of repository-relative non-sensitive file paths"); + } + const files = [...new Set([SETUP_PROFILE_PATH, ...configuredFiles])].map((name) => { + if ( + /(?:^|[/\\])(?:\.git|node_modules|\.auth|\.edge-profile|\.npmrc|\.env(?:\.[^/\\]*)?)(?:[/\\]|$)|\.(?:pem|key|pfx|p12)$/iu.test(name) + ) { + throw new Error(`Worktree setup must not copy credentials, dependencies or browser state: ${name}`); + } + const sourcePath = setupPath(sourceRoot, name); + const targetPath = setupPath(targetRoot, name); + const content = existingFile(sourcePath); + if (!content) { + throw new Error(`Declared worktree setup file is missing from the source checkout: ${name}`); + } + const previous = existingFile(targetPath); + const template = name === SETUP_PROFILE_PATH && previous && templatePath + ? existingFile(templatePath) + : null; + const replaceTemplate = Boolean(template && previous.equals(template)); + if (previous && !previous.equals(content) && !replaceTemplate) { + throw new Error(`Existing worktree configuration differs; merge it manually before setup: ${name}`); + } + return { name, sourcePath, targetPath, content, previous, replaceTemplate }; + }); + const configurationErrors = browserConfigurationErrors(profile, targetRoot, new Set(files.map((file) => file.targetPath))); + if (configurationErrors.length) { + throw new Error(configurationErrors.join("\n")); + } + return { source: sourceRoot, repository: targetRoot, profile, files }; +} + +export function applyWorktreeSetup(plan) { + for (const file of plan.files) { + setupPath(plan.source, file.name); + setupPath(plan.repository, file.name); + const source = existingFile(file.sourcePath); + const target = existingFile(file.targetPath); + if ( + !source?.equals(file.content) || + (file.previous ? !target?.equals(file.previous) : target !== null) + ) { + throw new Error(`Setup files changed after planning; review a new plan: ${file.name}`); + } + } + for (const file of plan.files) { + if (file.previous?.equals(file.content)) { + continue; + } + mkdirSync(dirname(file.targetPath), { recursive: true }); + if (file.replaceTemplate) { + writeFileSync(file.targetPath, file.content); + } else { + copyFileSync(file.sourcePath, file.targetPath, constants.COPYFILE_EXCL); + } + } +} + +export function browserConfigurationErrors(profile, repository, plannedFiles = new Set()) { + const command = profile?.browserAutomation?.testCommand; + if (typeof command !== "string") { + return []; + } + const errors = []; + const root = realpathSync.native(repository); + const directory = profile.browserAutomation.workingDirectory ?? "."; + for (const match of command.matchAll(/(?:^|\s)--config(?:=|\s+)(?:"([^"]+)"|'([^']+)'|([^\s]+))/gu)) { + const name = match[1] ?? match[2] ?? match[3]; + try { + if (isAbsolute(name) || /^[A-Za-z]:|^[/\\]/u.test(name)) { + throw new Error("Use a repository-relative browser configuration"); + } + const path = setupPath(root, join(directory, name)); + if (!plannedFiles.has(path) && !existingFile(path)) { + errors.push(`browserAutomation.testCommand configuration is missing: ${name}. Provide it or declare it in worktreeSetup.files before onboarding`); + } + } catch (error) { + errors.push(`browserAutomation.testCommand configuration is invalid: ${error.message}`); + } + } + return errors; +} diff --git a/dashboard/server.js b/dashboard/server.js index dd2fc51..78f3468 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -35,6 +35,11 @@ import { storeScreenshots } from "./intake.js"; import { formatPreflightFailure } from "./preflight.js"; +import { createSessionRegistry } from "./sessions.js"; +import { createExecutorLease } from "./executor-lease.js"; +import { spawnWindowsExecutor } from "./windows-executor.js"; +import { browserConfigurationErrors } from "./repository-setup.js"; +const windowsOwnedTrees = new WeakMap(); export const DASHBOARD_HOST = "127.0.0.1"; export const DEFAULT_DASHBOARD_PORT = 4317; @@ -98,6 +103,31 @@ export const MAX_PLAYWRIGHT_VIDEO_BYTES = 50 * 1024 * 1024; export const DEFAULT_EXECUTION_IDLE_TIMEOUT_MS = 20 * 60 * 1000; export const DEFAULT_EXECUTION_HEARTBEAT_MS = 2 * 60 * 1000; export const FIXLAB_RUNTIMES = ["agency", "copilot"]; +export const HYDRAFUSION_MODEL = "hydrafusion"; + +export function validateCopilotModel(value, runtime = "copilot") { + if (value !== null && value !== undefined && typeof value !== "string") { + throw new Error("model must be a string"); + } + const model = String(value ?? "").trim(); + if (!model) { + return ""; + } + if (runtime !== "copilot") { + throw new Error("model selection requires the copilot runtime"); + } + if (model.length > 128 || !/^[A-Za-z0-9][A-Za-z0-9._/-]*$/u.test(model)) { + throw new Error("model must be 1-128 letters, numbers, dots, underscores, slashes, or hyphens"); + } + return model.toLowerCase() === HYDRAFUSION_MODEL ? HYDRAFUSION_MODEL : model; +} + +export function copilotModelArguments(model) { + const selectedModel = validateCopilotModel(model); + return selectedModel + ? [...(selectedModel === HYDRAFUSION_MODEL ? ["--experimental"] : []), "--model", selectedModel] + : []; +} export const FIXLAB_STAGES = [ "intake", "diagnosis", @@ -165,6 +195,9 @@ export function validatePort(value) { export function validateLiveTestProfile(profile, repository) { const missing = []; + if (repository) { + missing.push(...browserConfigurationErrors(profile, repository)); + } const frontend = profile?.applications?.frontend; const browserAutomation = profile?.browserAutomation; const authentication = browserAutomation?.authentication; @@ -361,9 +394,12 @@ function publicQueue(jobs) { position: index + 1, request: safeSummary(job.request), requestType: job.requestType, + model: job.model, + runtime: job.runtime, pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + prestartFrontend: job.prestartFrontend ?? false, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: job.manualLocalhostUrl, @@ -408,9 +444,12 @@ function dashboardSnapshot(job) { id: job.id, request: safePersistedSummary(job.request), requestType: job.requestType, + model: job.model, + runtime: job.runtime, pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + prestartFrontend: job.prestartFrontend ?? false, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: "", @@ -473,6 +512,8 @@ function restoreDashboardJob(snapshot, repository) { } const restored = { ...snapshot, + model: snapshot.model ?? "", + runtime: snapshot.runtime ?? (snapshot.model ? "copilot" : ""), manualLocalhostTest: snapshot.manualLocalhostTest ?? snapshot.holdForManualLiveTest ?? @@ -787,7 +828,7 @@ export function buildJobPrompt({ ? "- Pull-request strategy is informational in read-only mode; do not create or update pull requests." : pullRequestStrategy === "per-bug" ? `- Process each selected bug as an isolated delivery unit. Keep its code changes, focused validation, browser evidence, commit, and pull-request outcome separate from every other bug. -- After one bug's scoped fix and smallest focused regression pass, remove transient validation artifacts, self-review its diff, and create or update its authorized draft PR before running remaining independent validation. Keep that PR draft until every required gate for that bug is terminal. Never combine unrelated bug changes in one PR.` +- After one bug's scoped fix and smallest focused regression pass, remove transient validation artifacts, self-review its diff, and create or update its authorized draft PR before running remaining independent validation. Keep that PR draft until every required local gate for that bug reaches an accepted outcome, then publish it without waiting for remote CI. Never combine unrelated bug changes in one PR.` : `- Treat all selected bugs as one common-PR batch. Diagnose and implement every required fix, then run the smallest focused regression checks that protect the combined change. - After those focused checks pass, remove transient validation artifacts, self-review the combined diff, and create or update one authorized draft PR. Continue shared tests, builds, application startup, and browser scenarios while that draft is open.`; const uiScenarioGuidance = runAllUiScenarios @@ -815,11 +856,12 @@ export function buildJobPrompt({ : "- Playwright video recording was not requested. Preserve the required screenshot evidence and any repository-default traces."; const manualLocalhostGuidance = manualLocalhostTest ? `- Manual React localhost validation is enabled independently of Playwright. After required automated validation finishes, start or reuse only the profile-defined frontend and wait for its health check at ${manualLocalhostUrl || "the profile-defined local health URL"}. +${process.platform === "win32" ? "- Windows executor children are job-contained and terminated when the agent exits. For a manual gate, use a dashboard-managed frontend outside the executor; the user can start it with Start React. Verify that it serves the validated checkout. If no independently managed server serves that checkout, block with that exact prerequisite instead of retaining an executor child or validating a baseline checkout.\n" : ""}- Dashboard-managed frontends remain under dashboard session controls; do not terminate them from the agent. - Emit local-stack passed only after the React frontend is healthy. The dashboard will then open the localhost URL in the user's default browser. - After every required automated browser gate is terminal, emit FIXLAB_MANUAL|localhost|pending immediately before live-test blocked. Keep the FixLab-owned frontend running and request that the user confirm Passed or Failed. Do not emit this marker for a Playwright, authentication, test-data, or other automated blocker. - Do not run or rerun Playwright solely because this manual option is enabled. - If an authorized draft PR already exists, keep it draft with the manual gate pending; otherwise emit pr skipped because manual confirmation is pending. -- When the dashboard resumes this session with the user's manual result, mark live-test passed or failed accordingly, stop only the retained FixLab-owned frontend process, and continue to the gated PR outcome.` +- When the dashboard resumes this session with the user's manual result, mark live-test passed or failed accordingly, stop only frontend processes owned by the current job (not a dashboard-managed server), and continue to the gated PR outcome.` : "- Stop FixLab-owned applications after automated browser validation unless another explicit workflow requirement needs them."; const branchNamingGuidance = branchNaming ? `- Repository branch naming is configured as ${branchNaming.prefix}. Create or reuse only branches beneath this prefix. Do not substitute a runtime, bot, or agent name for the configured user ID.` @@ -838,7 +880,15 @@ export function buildJobPrompt({ : `- Generate a concise fix contract from the reported behavior and expected outcome. - Diagnose and reproduce with repository evidence. Do not claim a cause or reproduction without evidence. - Use the smallest focused reproduction that demonstrates the reported defect.`; - return `Run a FixLab ${mode} job. + return `FixLab output protocol (follow before any tool call): +- Your first assistant output must be this literal plain-text line: + FIXLAB_STAGE|intake|running|Accepted the request and starting intake. +- Emit that line before reading files, searching, planning with tools, delegating, or running commands. +- Before beginning each later stage, emit FIXLAB_STAGE|stage|running|concise next action as a literal assistant response line. +- Emit a terminal passed, skipped, blocked, or failed marker for the current stage before starting the next stage. +- Never defer or batch stage markers until the end of the job. + +Run a FixLab ${mode} job. Request type: ${requestType} Pull request strategy: ${pullRequestStrategy} Run all UI scenarios at end: ${runAllUiScenarios ? "yes" : "no"} @@ -865,6 +915,7 @@ Use this concise metadata only to avoid repeated discovery. Verify task-specific Repository requirements: - Read .github/fixlab/repository-profile.json and all repository-owned instructions before acting. +- Before validation in a new isolated worktree, plan \`fixlab onboard WORKTREE --from SOURCE --runtime RUNTIME\`. After scoped setup approval, add \`--yes\`. The source profile declares non-sensitive configuration in \`worktreeSetup.files\`. Never copy credentials or browser state. Missing configuration, authentication or denied runtime access is a blocker; do not retry unchanged approvals or grant blanket path access. Sign-in and MFA remain human-controlled. In read-only modes, do not provision or alter configuration; report missing prerequisites as blocked. - For repeat work, inspect the current git status and effective diff first, then search task-relevant symbols and files instead of rescanning the whole repository. - Do not perform a whole-repository rescan when the current diff, cached metadata, and focused symbol/path searches are sufficient. - Reuse this job/session context. Do not reread unchanged files, repeat completed diagnosis, reinstall available dependencies, or rerun broad checks without new evidence. @@ -892,7 +943,9 @@ ${testDataGuidance} - Once the scoped production change and its smallest focused regression checks pass, remove transient validation artifacts and self-review the effective diff. When pull-request publication is already authorized, commit and push that reviewed checkpoint and create or update a draft pull request before remaining independent validation completes. - 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. +- Keep the pull request draft and clearly marked validation-in-progress while any required local validation or approval 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 local gates pass or repository policy records an explicitly accepted outcome. +- ${readOnly ? "Do not publish a pull request or change its draft status in read-only mode." : "After required local validation and approval gates pass or have an explicitly accepted outcome, publish the authorized PR by marking it ready for review. Do not wait for remote PR CI or build completion before publication; publication can trigger those checks. Verify the PR is no longer draft before reporting the PR stage passed. Report remote checks as pending, not passed, and leave merge policies intact. Use `gh pr ready ` for GitHub or `az repos pr update --id --draft false` for Azure DevOps, then read back `isDraft` from the provider to confirm it is false. If publication or state verification fails, report the PR stage blocked or failed, not passed."} +- 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(", ")}. @@ -900,7 +953,7 @@ ${testDataGuidance} - Start only the applications defined by the repository profile, then execute the repository-defined live test against the allowed required system or environment from that profile. Do not invent or hardcode environment choices. - For every Playwright live test, save at least one non-sensitive screenshot under the test's repository-owned test-results directory so the dashboard can display the browser evidence. - ${playwrightOnly ? "Collect evidence for required setup, authentication readiness, application startup, the focused Playwright result, skipped gates, blockers, and remaining risks." : "Collect evidence for diagnosis or surface inspection, the effective diff, review, local validation, application startup, live testing, skipped gates, and remaining risks."} -- ${readOnly ? "Report the pull-request outcome without creating or updating a pull request." : "Create or update an authorized draft pull request after the reviewed focused checkpoint; finalize its evidence and readiness only after all required gates pass."} +- ${readOnly ? "Report the pull-request outcome without creating or updating a pull request." : "Create or update an authorized draft pull request after the reviewed focused checkpoint; finalize its evidence and publish it after required local validation and approval gates pass, without waiting for remote CI."} - Human interaction is limited to authentication, unsafe-data approval, deployment or pull-request approval, and genuine blockers that cannot be resolved from repository evidence. - When one of those human actions is required, emit FIXLAB_STAGE|stage|blocked|exact action needed, emit blocked outcomes for affected bugs when applicable, mark later stages skipped because of the blocker, and exit. The dashboard will collect user input and resume this same session. - Emit concise, user-visible analysis updates when evidence changes the diagnosis, validation status, blocker, or next action: @@ -1367,9 +1420,9 @@ function writeCacheSummary(context, job) { // Linked dependency folders (for example a node_modules junction) resolve outside // the repository, and non-interactive agents cannot answer the path prompt they raise. -// Only targets inside a profile-declared trustedDependencyRoots entry are granted, so a -// repository-controlled symlink alone never widens the agent's file access. -export function linkedDependencyDirectories(repository) { +// Discovery is only for a reviewed setup plan; runtime callers use the default, +// which grants only targets inside profile-declared trustedDependencyRoots. +export function linkedDependencyDirectories(repository, { discover = false } = {}) { let root; let profile; try { @@ -1393,7 +1446,7 @@ export function linkedDependencyDirectories(repository) { return []; } }); - if (trustedRoots.length === 0) { + if (trustedRoots.length === 0 && !discover) { return []; } @@ -1434,7 +1487,8 @@ export function linkedDependencyDirectories(repository) { const target = realpathSync(candidate); if ( !isInside(root, target) && - trustedRoots.some((trusted) => isInside(trusted, target)) + target !== parse(target).root && + (discover || trustedRoots.some((trusted) => isInside(trusted, target))) ) { found.add(target); } @@ -1481,7 +1535,8 @@ export function buildCopilotInvocation({ prompt, sessionId, resume = false, - additionalDirs = [] + additionalDirs = [], + model = "" }) { if (!sessionId) { throw new Error("Copilot invocation requires a session ID"); @@ -1492,6 +1547,7 @@ export function buildCopilotInvocation({ packageRoot, "--agent", "fixlab:fixlab", + ...copilotModelArguments(model), "--allow-all-tools", ...additionalDirectoryArguments(additionalDirs), "--no-ask-user", @@ -1508,7 +1564,7 @@ function findDirectExecutable(command) { const lookup = spawnSync( process.platform === "win32" ? "where.exe" : "which", [command], - { encoding: "utf8", shell: false } + { encoding: "utf8", shell: false, windowsHide: true } ); const candidates = lookup.stdout ?.split(/\r?\n/) @@ -1519,34 +1575,49 @@ function findDirectExecutable(command) { : candidates?.[0]; } -function createExecutor({ command, packageRoot, buildInvocation }) { +function createExecutor({ command, packageRoot, buildInvocation, defaultModel = "" }) { return ({ repository, artifactDirectories = [], prompt, sessionId, resume, + model, onOutput }) => { const executable = findDirectExecutable(command); if (!executable) { - throw new Error( + const error = new Error( `${command} executable is unavailable or is not a directly executable binary` ); + error.executorNotStarted = true; + throw error; } - const invocation = buildInvocation({ - packageRoot, - prompt, - sessionId, - resume, - additionalDirs: [ - ...linkedDependencyDirectories(repository), - ...artifactDirectories - ] - }); - const child = spawn(executable, invocation.args, { + let invocation; + try { + invocation = buildInvocation({ + packageRoot, + prompt, + sessionId, + resume, + model: model ?? defaultModel, + additionalDirs: [ + ...linkedDependencyDirectories(repository), + ...artifactDirectories + ] + }); + } catch (error) { + error.executorNotStarted = true; + throw error; + } + const containment = process.platform === "win32" + ? spawnWindowsExecutor({ executable, args: invocation.args, repository }) + : null; + const child = containment?.child ?? spawn(executable, invocation.args, { cwd: repository, + env: { ...process.env, BROWSER: "none" }, shell: false, + detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"] }); @@ -1557,14 +1628,57 @@ function createExecutor({ command, packageRoot, buildInvocation }) { ); child.stdin.end(invocation.input); - const completion = createProcessCompletion(child, onOutput); + const completion = createProcessCompletion(child, onOutput).then(async (result) => { + if (!containment && child.pid) { + stopProcessTree(child); + const started = Date.now(); + while (ownedTreeRunning(child)) { + if (Date.now() - started >= 5000) { + try { + process.kill(-child.pid, "SIGKILL"); + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } + } + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + } + return result; + }); return { + pid: child.pid, completion, - terminate() { + hasStopped() { + if (containment) { + return containment.hasStopped(); + } + if (!child.pid) { + return true; + } if (child.exitCode === null && child.signalCode === null) { - child.kill(); + return false; + } + if (process.platform !== "win32") { + try { + process.kill(-child.pid, 0); + return false; + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } + } } + return true; + }, + terminate() { + if (containment) { + containment.terminate(); + return; + } + stopProcessTree(child); } }; }; @@ -1611,22 +1725,25 @@ export function createAgencyExecutor({ packageRoot }) { }); } -export function createCopilotExecutor({ packageRoot }) { +export function createCopilotExecutor({ packageRoot, model = "" }) { + const selectedModel = validateCopilotModel(model); return createExecutor({ command: "copilot", packageRoot, - buildInvocation: buildCopilotInvocation + buildInvocation: buildCopilotInvocation, + defaultModel: selectedModel }); } -export function createRuntimeExecutor({ packageRoot, runtime = "agency" }) { +export function createRuntimeExecutor({ packageRoot, runtime = "agency", model = "" }) { if (!FIXLAB_RUNTIMES.includes(runtime)) { throw new Error( `FixLab runtime must be one of: ${FIXLAB_RUNTIMES.join(", ")}` ); } + const selectedModel = validateCopilotModel(model, runtime); return runtime === "copilot" - ? createCopilotExecutor({ packageRoot }) + ? createCopilotExecutor({ packageRoot, model: selectedModel }) : createAgencyExecutor({ packageRoot }); } @@ -1647,6 +1764,8 @@ function publicJob(job) { id: job.id, request: job.request, requestType: job.requestType, + model: job.model, + runtime: job.runtime, pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, @@ -1673,6 +1792,7 @@ function publicJob(job) { error: job.error, canResume: !job.resumeDisabled && + !job.executorStopping && ["blocked", "failed", "passed", "held"].includes(job.status), canComment: job.status === "running", inputCount: job.inputs.length, @@ -1886,10 +2006,21 @@ function appendOutput(job, stream, text) { const lines = job.partial[stream].split("\n"); job.partial[stream] = lines.pop(); for (const line of lines) { - appendLine(job, stream, line); + for (const segment of splitRenderedMarkers(line)) { + appendLine(job, stream, segment); + } } } +function splitRenderedMarkers(line) { + if (!/^FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST)(?:\||\s)/u.test(line)) { + return [line]; + } + return line + .split(/(?=FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST)(?:\||\s))/u) + .filter(Boolean); +} + function appendLine(job, stream, line) { pushLog(job, { index: job.nextLogIndex, @@ -2096,7 +2227,9 @@ function finishJob(job, result) { function flushPartialOutput(job) { for (const stream of ["stdout", "stderr"]) { if (job.partial[stream]) { - appendLine(job, stream, job.partial[stream]); + for (const segment of splitRenderedMarkers(job.partial[stream])) { + appendLine(job, stream, segment); + } job.partial[stream] = ""; } } @@ -2264,29 +2397,96 @@ async function frontendIsHealthy(healthUrl) { } } +function ownedTreeRunning(child) { + if (!child?.pid) { + return false; + } + const containment = windowsOwnedTrees.get(child); + if (containment) { + return !containment.hasStopped(); + } + if (child.exitCode === null && child.signalCode === null) { + return true; + } + if (process.platform === "win32") { + return false; + } + try { + process.kill(-child.pid, 0); + return true; + } catch (error) { + if (error.code !== "ESRCH") { + throw error; + } + return false; + } +} + +function spawnOwnedShell(configuration, retainDescendants = false) { + if (process.platform === "win32") { + const executable = process.env.ComSpec ?? "C:\\Windows\\System32\\cmd.exe"; + const containment = spawnWindowsExecutor({ + executable, + args: [], + commandLine: `"${executable}" /d /s /c "${configuration.command}"`, + repository: configuration.workingDirectory, + environment: configuration.environment, + retainDescendants + }); + windowsOwnedTrees.set(containment.child, containment); + // Dev servers can interpret EOF as shutdown; keep their input alive. + if (!retainDescendants) { + containment.child.stdin.end(); + } + containment.child.stdin.on("error", (error) => { + containment.child.emit("fixlab-input-error", error); + }); + containment.child.stdout.resume(); + containment.child.stderr.resume(); + return containment.child; + } + const child = spawn(configuration.command, { + cwd: configuration.workingDirectory, + env: { ...process.env, ...configuration.environment, BROWSER: "none" }, + shell: true, + detached: true, + stdio: retainDescendants ? ["pipe", "ignore", "ignore"] : "ignore" + }); + child.stdin?.on("error", (error) => { + child.emit("fixlab-input-error", error); + }); + return child; +} + function stopProcessTree(child) { - if (!child || child.exitCode !== null || child.signalCode !== null) { + if (!child?.pid) { return; } + const containment = windowsOwnedTrees.get(child); + if (containment) { + containment.terminate(); + return containment.waitForStopped(); + } if (process.platform === "win32" && child.pid) { - spawnSync("taskkill", ["/pid", String(child.pid), "/t", "/f"], { + if (child.exitCode !== null || child.signalCode !== null) { + return; + } + 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; } - if (child.pid) { - try { - process.kill(-child.pid, "SIGTERM"); - return; - } catch (error) { - if (error.code !== "ESRCH") { - child.kill(); - } - return; + try { + process.kill(-child.pid, "SIGTERM"); + } catch (error) { + if (error.code !== "ESRCH") { + throw error; } } - child.kill(); } async function frontendStatus(repository, connection) { @@ -2296,18 +2496,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 @@ -2488,8 +2688,9 @@ export function createDashboardServer({ repository, packageRoot, runtime = "agency", + model = "", publicDirectory = join(packageRoot, "dashboard", "public"), - executor = createRuntimeExecutor({ packageRoot, runtime }), + executor = createRuntimeExecutor({ packageRoot, runtime, model }), workItemLoader = createAzureDevOpsLoader(), browserOpener = openLocalUrl, preflight = null, @@ -2497,16 +2698,127 @@ export function createDashboardServer({ executionHeartbeatMs = DEFAULT_EXECUTION_HEARTBEAT_MS }) { const resolvedRepository = resolve(repository); + const defaultModel = validateCopilotModel(model, runtime); let currentJob = null; let activeHandle = null; + let activeTermination = null; const queuedJobs = []; const heldJobs = []; 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 + ); + const executorLease = createExecutorLease(resolvedRepository, sessions.id); + 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 = spawnOwnedShell(configuration, true); + frontendConnection.handle = child; + child.once("fixlab-input-error", (error) => { + frontendConnection.lastResult = { + ok: false, + message: `Frontend input failed: ${error.message}`, + finishedAt: new Date().toISOString() + }; + }); + 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) { + try { + if (ownedTreeRunning(child)) { + if (process.platform === "win32") { + frontendConnection.lastResult = { + ok: false, + message: "Frontend termination is unverified; ownership is retained.", + finishedAt: new Date().toISOString() + }; + } + return; + } + } catch (error) { + frontendConnection.lastResult = { + ok: false, + message: `Frontend termination is unverified: ${error.message}`, + finishedAt: new Date().toISOString() + }; + return; + } + 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; @@ -2526,13 +2838,17 @@ export function createDashboardServer({ } persistedJobs = persistedJobs.filter((job) => job.status !== "held"); const resumableSnapshot = persistedJobs.find((job) => - ["blocked", "failed", "passed"].includes(job.status) + ["blocked", "failed", "passed"].includes(job.status) && + (job.runtime ?? (job.model ? "copilot" : "")) === runtime ); if (resumableSnapshot) { currentJob = restoreDashboardJob( resumableSnapshot, resolvedRepository ); + if (currentJob.runtime !== runtime) { + currentJob.resumeDisabled = true; + } persistedJobs = persistedJobs.filter( (job) => job.id !== resumableSnapshot.id ); @@ -2678,6 +2994,13 @@ export function createDashboardServer({ } function startJob(job, prompt, resume = false) { + if (job.runtime !== runtime) { + throw new Error("Saved job runtime does not match this dashboard; reconnect using its originating runtime."); + } + validateCopilotModel(job.model, runtime); + if (closing) { + throw new Error("the dashboard is shutting down"); + } const profileIdleTimeoutMinutes = Number( loadRepositoryProfile(resolvedRepository).validation ?.agentIdleTimeoutMinutes @@ -2691,7 +3014,9 @@ export function createDashboardServer({ let idleTimer = null; let heartbeatTimer = null; let resolveIdleCompletion; + let leaseMetadataError = null; let handle; + const lease = executorLease.acquire(job.id); const idleCompletion = new Promise((resolveCompletion) => { resolveIdleCompletion = resolveCompletion; }); @@ -2701,7 +3026,7 @@ export function createDashboardServer({ const error = new Error( `FixLab agent produced no output for ${Math.round( idleTimeoutMs / 60000 - )} minute(s); the owned executor was stopped and the job can be resumed.` + )} minute(s); termination was requested and resume remains blocked until the owned executor exits.` ); pushLog(job, { index: job.nextLogIndex, @@ -2710,6 +3035,17 @@ export function createDashboardServer({ message: error.message }); resolveIdleCompletion({ code: null, error }); + job.executorStopping = true; + try { + lease.update("stopping"); + } catch (metadataError) { + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Could not update the idle executor lease: ${metadataError.message}. Still requesting owned termination.` + }); + } try { handle?.terminate?.(); } catch (terminationError) { @@ -2749,62 +3085,122 @@ export function createDashboardServer({ }; job.lastActivityAt = new Date().toISOString(); - handle = executor({ - repository: resolvedRepository, - artifactDirectories: job.artifactDirectory ? [job.artifactDirectory] : [], - prompt, - sessionId: job.id, - resume, - onOutput(stream, text) { - job.lastActivityAt = new Date().toISOString(); - armIdleWatchdog(); - const localStackWasReady = - job.stages["local-stack"].status === "passed"; - appendOutput(job, stream, text); - if ( - job.manualLocalhostTest && - !job.manualLocalhostOpenedAt && - !job.manualLocalhostOpening && - !localStackWasReady && - job.stages["local-stack"].status === "passed" - ) { - job.manualLocalhostOpening = true; - Promise.resolve() - .then(() => browserOpener(job.manualLocalhostUrl)) - .then(() => { - job.manualLocalhostOpenedAt = new Date().toISOString(); - pushLog(job, { - index: job.nextLogIndex, - timestamp: job.manualLocalhostOpenedAt, - stream: "dashboard", - message: "Opened the React localhost URL for manual validation." - }); - }) - .catch((error) => { - pushLog(job, { - index: job.nextLogIndex, - timestamp: new Date().toISOString(), - stream: "dashboard", - message: `Could not open React localhost automatically: ${error.message}` + try { + handle = executor({ + repository: resolvedRepository, + artifactDirectories: job.artifactDirectory ? [job.artifactDirectory] : [], + prompt: job.prestartFrontend && job.targetEnvironment.toLowerCase() === "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, + model: job.model, + onOutput(stream, text) { + job.lastActivityAt = new Date().toISOString(); + armIdleWatchdog(); + const localStackWasReady = + job.stages["local-stack"].status === "passed"; + appendOutput(job, stream, text); + if ( + job.manualLocalhostTest && + !job.manualLocalhostOpenedAt && + !job.manualLocalhostOpening && + !localStackWasReady && + job.stages["local-stack"].status === "passed" + ) { + job.manualLocalhostOpening = true; + Promise.resolve() + .then(() => browserOpener(job.manualLocalhostUrl)) + .then(() => { + job.manualLocalhostOpenedAt = new Date().toISOString(); + pushLog(job, { + index: job.nextLogIndex, + timestamp: job.manualLocalhostOpenedAt, + stream: "dashboard", + message: "Opened the React localhost URL for manual validation." + }); + }) + .catch((error) => { + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Could not open React localhost automatically: ${error.message}` + }); + }) + .finally(() => { + job.manualLocalhostOpening = false; }); - }) - .finally(() => { - job.manualLocalhostOpening = false; - }); + } } + }); + } catch (error) { + clearTimeout(idleTimer); + if (error.executorNotStarted === true) { + lease.release(); } - }); + throw error; + } activeHandle = handle; + job.executorStopping = false; + if (!handle?.completion || typeof handle.completion.then !== "function") { + throw new Error("Executor did not provide a termination promise; the repository lease remains reserved."); + } + const actualCompletion = Promise.resolve(handle.completion).then((result) => { + if (handle.hasStopped && !handle.hasStopped()) { + throw new Error("Executor exit was observed but owned descendants may still be running; the repository lease remains reserved."); + } + lease.release(); + return leaseMetadataError ? { code: null, error: leaseMetadataError } : result; + }); + activeTermination = actualCompletion; + try { + lease.update("running", handle.pid); + } catch (error) { + leaseMetadataError = error; + job.executorStopping = true; + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Executor lease metadata could not be updated: ${error.message}. The reservation is retained while the owned executor stops.` + }); + resolveIdleCompletion({ code: null, error }); + try { + handle.terminate?.(); + } catch (terminationError) { + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Could not stop the executor after its lease metadata failed: ${terminationError.message}. The reservation remains until termination is verified.` + }); + } + } + if (job.prestartFrontend && job.targetEnvironment.toLowerCase() === "local") { + void prewarmFrontend(job); + } armIdleWatchdog(); heartbeatTimer = setInterval(emitHeartbeat, executionHeartbeatMs); heartbeatTimer.unref?.(); - Promise.race([Promise.resolve(handle.completion), idleCompletion]) + Promise.race([actualCompletion, idleCompletion]) .then((result) => { if (job.holdRequested) { holdJob(job); + } else if (closing && job.status === "running" && !job.stopRequested) { + const recovered = recoverInterruptedDashboardJob(dashboardSnapshot(job)); + job.status = recovered.status; + job.stages = recovered.stages; + job.error = recovered.error; + job.finishedAt = recovered.finishedAt; } else { 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) => { @@ -2813,13 +3209,37 @@ export function createDashboardServer({ } else { 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(() => { + .finally(async () => { clearTimeout(idleTimer); clearInterval(heartbeatTimer); + try { + await actualCompletion; + } catch (error) { + job.executorStopping = true; + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Executor termination or lease release is unverified; retaining the repository lease: ${error.message}` + }); + recordJobMetric(job); + return; + } + job.executorStopping = false; if (activeHandle === handle) { activeHandle = null; + activeTermination = null; + } + recordJobMetric(job); + if (closing) { + return; } if (job.status === "held") { if (currentJob?.id === job.id) { @@ -2855,11 +3275,11 @@ export function createDashboardServer({ } return; } - startNextJob(); + return startNextJob(); }); } - function startNextJob() { + async function startNextJob() { if ( activeHandle || queuedJobs.length === 0 || @@ -2878,6 +3298,13 @@ export function createDashboardServer({ currentJob.lastActivityAt = new Date().toISOString(); 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, @@ -2898,10 +3325,171 @@ 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"); + } + const cleanupFailures = []; + if (body.action === "shutdown") { + if (body.resource !== "all") { + throw new Error("shutdown requires resource all"); + } + try { + await stopProcessTree(playwrightConnection.handle); + playwrightConnection.handle = null; + } catch (error) { + cleanupFailures.push(error.message); + } + } + 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) { + try { + 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) { + if (body.resource !== "all") { + sendJson(response, result.status, resultBody); + return; + } + cleanupFailures.push(resultBody.error ?? `Owned cleanup failed with HTTP ${result.status}`); + } + } catch (error) { + if (body.resource !== "all") { + throw error; + } + cleanupFailures.push(`Owned cleanup ${path} failed: ${error.message}`); + } + } + if (cleanupFailures.length) { + sendJson(response, 500, { error: cleanupFailures.join("; ") }); + 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, { readiness: inspectRepository(resolvedRepository), + runtime: { + name: runtime, + defaultModel, + supportsModelSelection: runtime === "copilot" + }, job: publicJob(currentJob), queue: publicQueue(queuedJobs), held: publicHeld(heldJobs), @@ -3117,7 +3705,7 @@ export function createDashboardServer({ } archiveJob(dismissedJob); currentJob = null; - startNextJob(); + await startNextJob(); persistDashboardState(); sendJson(response, 202, { job: publicJob(currentJob), @@ -3139,7 +3727,9 @@ export function createDashboardServer({ } if (currentJob.resumeDisabled) { sendJson(response, 409, { - error: "this FixLab job cannot be resumed safely" + error: currentJob.runtime !== runtime + ? "Saved job runtime is incompatible or unknown; reconnect using its originating runtime or start a new job." + : "this FixLab job cannot be resumed safely" }); return; } @@ -3193,6 +3783,18 @@ export function createDashboardServer({ return; } const manualResult = ["manual-pass", "manual-fail"].includes(action); + if ( + !running && + !manualResult && + action !== "skip" && + currentJob.stages["live-test"].status === "blocked" && + playwrightConnection.handle + ) { + sendJson(response, 409, { + error: "Authentication is still running and owns the repository browser profile. Finish sign-in and close its browser window before resuming the blocked live test." + }); + return; + } if (manualResult && !currentJob.manualLocalhostPending) { sendJson(response, 409, { error: "manual localhost confirmation is not pending" @@ -3217,7 +3819,7 @@ export function createDashboardServer({ action === "manual-pass" ? "passed" : "failed" }. Record the live-test stage as ${ action === "manual-pass" ? "passed" : "failed" - }, stop only the FixLab-owned frontend process, preserve this manual result in the job evidence, and continue the remaining gated outcome without rerunning Playwright.` + }, stop only frontend processes owned by the current job (not a dashboard-managed server), preserve this manual result in the job evidence, and continue the remaining gated outcome without rerunning Playwright.` : submittedDetails || defaultDetails; if (!details || details.length > 10000) { sendJson(response, 400, { @@ -3292,7 +3894,6 @@ export function createDashboardServer({ ) { if ( !currentJob || - currentJob.status !== "running" || !activeHandle?.terminate ) { sendJson(response, 409, { @@ -3315,10 +3916,13 @@ 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 { + currentJob.executorStopping = true; activeHandle.terminate(); + currentJob.pendingInputs = []; + currentJob.stopRequested = true; } catch (error) { sendJson(response, 500, { error: `could not stop the FixLab-owned executor: ${error.message}` @@ -3784,6 +4388,13 @@ export function createDashboardServer({ return; } const pullRequestStrategy = body.pullRequestStrategy ?? "common"; + let selectedModel; + try { + selectedModel = validateCopilotModel(body.model ?? defaultModel, runtime); + } catch (error) { + sendJson(response, 400, { error: error.message }); + return; + } if (!["common", "per-bug"].includes(pullRequestStrategy)) { sendJson(response, 400, { error: "pullRequestStrategy must be common or per-bug" @@ -3804,6 +4415,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 }) @@ -3959,9 +4575,12 @@ export function createDashboardServer({ createdAt: new Date().toISOString(), request: requestText, requestType, + model: selectedModel, + runtime, pullRequestStrategy, runAllUiScenarios, targetEnvironment, + prestartFrontend, recordPlaywrightVideo, manualLocalhostTest, manualLocalhostUrl, @@ -4200,14 +4819,15 @@ export function createDashboardServer({ return; } playwrightConnection.lastResult = null; - const child = spawn(configuration.command, { - cwd: configuration.workingDirectory, - env: { ...process.env, ...configuration.environment }, - shell: true, - stdio: "ignore", - windowsHide: false - }); + const child = spawnOwnedShell(configuration); playwrightConnection.handle = child; + child.once("fixlab-input-error", (error) => { + playwrightConnection.lastResult = { + ok: false, + message: `Authentication input failed: ${error.message}`, + finishedAt: new Date().toISOString() + }; + }); child.once("error", (error) => { playwrightConnection.lastResult = { ok: false, @@ -4217,6 +4837,23 @@ export function createDashboardServer({ playwrightConnection.handle = null; }); child.once("close", (code, signal) => { + try { + if (ownedTreeRunning(child)) { + playwrightConnection.lastResult = { + ok: false, + message: "Authentication descendants remain owned; retry must wait for confirmed termination.", + finishedAt: new Date().toISOString() + }; + return; + } + } catch (error) { + playwrightConnection.lastResult = { + ok: false, + message: `Authentication termination is unverified: ${error.message}`, + finishedAt: new Date().toISOString() + }; + return; + } playwrightConnection.lastResult = { ok: code === 0, message: @@ -4255,55 +4892,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, - detached: process.platform !== "win32", - 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; } @@ -4312,11 +4913,18 @@ export function createDashboardServer({ requestUrl.pathname === "/api/frontend/stop" ) { const child = frontendConnection.handle; + const wasStarting = frontendConnection.starting; + frontendStartGeneration += 1; + try { + await 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; } @@ -4354,6 +4962,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, @@ -4361,31 +4976,55 @@ export function createDashboardServer({ }; }, async close() { - persistDashboardState(); - if (activeHandle?.terminate) { - activeHandle.terminate(); - } - if ( - playwrightConnection.handle && - playwrightConnection.handle.exitCode === null && - playwrightConnection.handle.signalCode === null - ) { - playwrightConnection.handle.kill(); - } - stopProcessTree(frontendConnection.handle); - frontendConnection.handle = null; + closing = true; + frontendStartGeneration += 1; + const failures = []; + const attempt = async (action) => { + try { + await action(); + } catch (error) { + failures.push(error); + } + }; + await attempt(() => persistDashboardState()); + await attempt(async () => { + const handle = activeHandle; + const termination = activeTermination; + if (!handle) { + return; + } + handle.terminate?.(); + if (!termination) { + throw new Error("Executor termination cannot be verified; its reservation is retained."); + } + await termination; + }); + await attempt(async () => { + await stopProcessTree(playwrightConnection.handle); + playwrightConnection.handle = null; + }); + await attempt(async () => { + await stopProcessTree(frontendConnection.handle); + frontendConnection.handle = null; + }); for (const directory of artifactDirectories) { - removeArtifactDirectory(directory); + await attempt(() => removeArtifactDirectory(directory)); } artifactDirectories.clear(); - server.closeIdleConnections?.(); - await new Promise((resolveClose, reject) => { - if (!server.listening) { - resolveClose(); - return; - } - server.close((error) => (error ? reject(error) : resolveClose())); + await attempt(async () => { + server.closeIdleConnections?.(); + await new Promise((resolveClose, reject) => { + if (!server.listening) { + resolveClose(); + return; + } + server.close((error) => (error ? reject(error) : resolveClose())); + }); }); + await attempt(() => sessions.unregister()); + if (failures.length) { + throw new AggregateError(failures, "FixLab shutdown could not complete every owned cleanup step"); + } } }; } 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/dashboard/windows-executor.cs b/dashboard/windows-executor.cs new file mode 100644 index 0000000..b092b39 --- /dev/null +++ b/dashboard/windows-executor.cs @@ -0,0 +1,239 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; + +namespace FixLab +{ + public static class WindowsExecutor + { + const uint CreateSuspended = 0x00000004; + const uint CreateNewConsole = 0x00000010; + const uint UseShowWindow = 0x00000001; + const uint UseStandardHandles = 0x00000100; + const uint KillOnJobClose = 0x00002000; + const uint WaitTimeout = 258; + + [StructLayout(LayoutKind.Sequential)] + struct BasicLimits + { + public long ProcessTime, JobTime; + public uint Flags; + public UIntPtr MinimumWorkingSet, MaximumWorkingSet; + public uint ActiveProcessLimit; + public UIntPtr Affinity; + public uint PriorityClass, SchedulingClass; + } + + [StructLayout(LayoutKind.Sequential)] + struct IoCounters + { + public ulong ReadOperations, WriteOperations, OtherOperations; + public ulong ReadBytes, WriteBytes, OtherBytes; + } + + [StructLayout(LayoutKind.Sequential)] + struct ExtendedLimits + { + public BasicLimits Basic; + public IoCounters Io; + public UIntPtr ProcessMemory, JobMemory, PeakProcessMemory, PeakJobMemory; + } + + [StructLayout(LayoutKind.Sequential)] + struct Accounting + { + public long UserTime, KernelTime, PeriodUserTime, PeriodKernelTime; + public uint PageFaults, TotalProcesses, ActiveProcesses, TerminatedProcesses; + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)] + struct Startup + { + public uint Size; + public string Reserved, Desktop, Title; + public uint X, Y, Width, Height, XChars, YChars, Fill, Flags; + public ushort ShowWindow, ReservedSize; + public IntPtr ReservedBytes, Input, Output, Error; + } + + [StructLayout(LayoutKind.Sequential)] + struct ProcessInfo + { + public IntPtr Process, Thread; + public uint ProcessId, ThreadId; + } + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern IntPtr CreateJobObject(IntPtr attributes, string name); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetInformationJobObject(IntPtr job, int kind, ref ExtendedLimits info, uint size); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool QueryInformationJobObject(IntPtr job, int kind, out Accounting info, uint size, IntPtr length); + [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + static extern bool CreateProcess(string application, StringBuilder command, IntPtr processAttributes, + IntPtr threadAttributes, bool inheritHandles, uint flags, IntPtr environment, + string directory, ref Startup startup, out ProcessInfo process); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool AssignProcessToJobObject(IntPtr job, IntPtr process); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint ResumeThread(IntPtr thread); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateJobObject(IntPtr job, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool TerminateProcess(IntPtr process, uint code); + [DllImport("kernel32.dll", SetLastError = true)] + static extern uint WaitForSingleObject(IntPtr handle, uint milliseconds); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetExitCodeProcess(IntPtr process, out uint code); + [DllImport("kernel32.dll")] + static extern IntPtr GetStdHandle(int kind); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool SetHandleInformation(IntPtr handle, uint mask, uint flags); + [DllImport("kernel32.dll")] + static extern uint GetCurrentProcessId(); + [DllImport("kernel32.dll", SetLastError = true)] + static extern IntPtr OpenProcess(uint access, bool inherit, uint pid); + [DllImport("kernel32.dll", SetLastError = true)] + static extern bool GetProcessTimes(IntPtr process, out long creation, out long exit, + out long kernel, out long user); + [DllImport("kernel32.dll")] + static extern bool CloseHandle(IntPtr handle); + + static void Check(bool succeeded) + { + if (!succeeded) throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + static uint ActiveProcesses(IntPtr job) + { + Accounting accounting; + Check(QueryInformationJobObject(job, 1, out accounting, + (uint)Marshal.SizeOf(typeof(Accounting)), IntPtr.Zero)); + return accounting.ActiveProcesses; + } + + static void WaitForEmptyJob(IntPtr job) + { + while (ActiveProcesses(job) != 0) Thread.Sleep(20); + } + + public static int Run(string executable, string command, string directory, + string stopPath, string proofPath, string nonce, bool retainDescendants, uint ownerPid) + { + IntPtr job = IntPtr.Zero; + IntPtr owner = IntPtr.Zero; + ProcessInfo process = new ProcessInfo(); + bool assigned = false; + bool quiescent = false; + int result = 1; + try + { + owner = OpenProcess(0x00101000, false, ownerPid); + Check(owner != IntPtr.Zero); + long ownerCreation, supervisorCreation, exitTime, kernelTime, userTime; + Check(GetProcessTimes(owner, out ownerCreation, out exitTime, out kernelTime, out userTime)); + Check(GetProcessTimes(new IntPtr(-1), out supervisorCreation, out exitTime, out kernelTime, out userTime)); + if (ownerCreation > supervisorCreation) + throw new InvalidOperationException("Windows executor owner generation changed"); + job = CreateJobObject(IntPtr.Zero, null); + Check(job != IntPtr.Zero); + ExtendedLimits limits = new ExtendedLimits(); + limits.Basic.Flags = KillOnJobClose; + Check(SetInformationJobObject(job, 9, ref limits, + (uint)Marshal.SizeOf(typeof(ExtendedLimits)))); + if (File.Exists(stopPath)) + { + quiescent = true; + result = 130; + } + else + { + Startup startup = new Startup(); + startup.Size = (uint)Marshal.SizeOf(typeof(Startup)); + // Descendant tools inherit this hidden console instead of opening their own. + startup.Flags = UseStandardHandles | UseShowWindow; + startup.ShowWindow = 0; + startup.Input = GetStdHandle(-10); + startup.Output = GetStdHandle(-11); + startup.Error = GetStdHandle(-12); + Check(SetHandleInformation(startup.Input, 1, 1)); + Check(SetHandleInformation(startup.Output, 1, 1)); + Check(SetHandleInformation(startup.Error, 1, 1)); + Check(CreateProcess(executable, new StringBuilder(command), IntPtr.Zero, + IntPtr.Zero, true, CreateSuspended | CreateNewConsole, IntPtr.Zero, + directory, ref startup, out process)); + Check(AssignProcessToJobObject(job, process.Process)); + assigned = true; + Check(ResumeThread(process.Thread) != UInt32.MaxValue); + bool stopping = false; + while (ActiveProcesses(job) != 0) + { + uint wait = WaitForSingleObject(process.Process, 0); + Check(wait == 0 || wait == WaitTimeout); + uint ownerWait = WaitForSingleObject(owner, 0); + Check(ownerWait == 0 || ownerWait == WaitTimeout); + if (!stopping && (File.Exists(stopPath) || ownerWait == 0 || (wait == 0 && !retainDescendants))) + { + Check(TerminateJobObject(job, 130)); + stopping = true; + } + Thread.Sleep(20); + } + quiescent = true; + uint exitCode; + Check(GetExitCodeProcess(process.Process, out exitCode)); + result = File.Exists(stopPath) ? 130 : unchecked((int)exitCode); + } + } + catch (Exception error) + { + Console.Error.WriteLine("FixLab Windows containment failed: " + error.Message); + result = 1; + } + finally + { + try + { + if (process.Process == IntPtr.Zero) + { + quiescent = true; + } + else if (!quiescent) + { + if (assigned) + { + Check(TerminateJobObject(job, 1)); + WaitForEmptyJob(job); + } + else + { + Check(TerminateProcess(process.Process, 1)); + Check(WaitForSingleObject(process.Process, UInt32.MaxValue) == 0); + } + quiescent = true; + } + if (quiescent) + { + File.WriteAllText(proofPath + ".tmp", + "{\"nonce\":\"" + nonce + "\",\"pid\":" + GetCurrentProcessId() + + ",\"quiescent\":true}", new UTF8Encoding(false)); + File.Move(proofPath + ".tmp", proofPath); + } + } + catch (Exception error) + { + Console.Error.WriteLine("FixLab Windows termination could not be verified: " + error.Message); + result = 1; + } + if (process.Thread != IntPtr.Zero) CloseHandle(process.Thread); + if (process.Process != IntPtr.Zero) CloseHandle(process.Process); + if (job != IntPtr.Zero) CloseHandle(job); + if (owner != IntPtr.Zero) CloseHandle(owner); + } + return result; + } + } +} diff --git a/dashboard/windows-executor.js b/dashboard/windows-executor.js new file mode 100644 index 0000000..c7b848a --- /dev/null +++ b/dashboard/windows-executor.js @@ -0,0 +1,126 @@ +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { + lstatSync, + mkdtempSync, + readFileSync, + rmdirSync, + unlinkSync, + writeFileSync +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export function quoteWindowsArgument(value) { + return `"${String(value) + .replace(/(\\*)"/gu, (_match, slashes) => `${slashes}${slashes}\\"`) + .replace(/(\\+)$/u, "$1$1")}"`; +} + +export function spawnWindowsExecutor({ + executable, + args, + repository, + commandLine, + environment = {}, + retainDescendants = false +}) { + const directory = mkdtempSync(join(tmpdir(), "fixlab-windows-executor-")); + const stopPath = join(directory, "stop"); + const proofPath = join(directory, "quiescent.json"); + const nonce = randomUUID(); + const source = fileURLToPath(new URL("./windows-executor.cs", import.meta.url)); + const config = Buffer.from(JSON.stringify({ + executable, + commandLine: commandLine ?? [executable, ...args].map(quoteWindowsArgument).join(" "), + repository, + stopPath, + proofPath, + nonce, + ownerPid: process.pid, + retainDescendants + })).toString("base64"); + const script = [ + "$ErrorActionPreference = 'Stop'", + `Add-Type -Path '${source.replaceAll("'", "''")}'`, + `$config = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${config}')) | ConvertFrom-Json`, + "exit [FixLab.WindowsExecutor]::Run($config.executable, $config.commandLine, $config.repository, $config.stopPath, $config.proofPath, $config.nonce, $config.retainDescendants, $config.ownerPid)" + ].join("; "); + const powershell = join( + process.env.SystemRoot ?? "C:\\Windows", + "System32", "WindowsPowerShell", "v1.0", "powershell.exe" + ); + const child = spawn(powershell, [ + "-NoLogo", "-NoProfile", "-NonInteractive", + "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64") + ], { + cwd: repository, + env: { ...process.env, ...environment, BROWSER: "none" }, + shell: false, + windowsHide: true, + stdio: ["pipe", "pipe", "pipe"] + }); + let confirmed = false; + const cleanup = () => { + for (const path of [stopPath, proofPath, `${proofPath}.tmp`]) { + try { + unlinkSync(path); + } catch (error) { + if (error.code !== "ENOENT") { + throw error; + } + } + } + rmdirSync(directory); + }; + return { + child, + directory, + hasStopped() { + if (confirmed) { + return true; + } + if (child.pid && child.exitCode === null && child.signalCode === null) { + return false; + } + if (child.pid) { + let proof; + try { + const info = lstatSync(proofPath); + if (!info.isFile() || info.isSymbolicLink() || info.size > 1024) { + throw new Error("Invalid Windows executor termination proof"); + } + proof = JSON.parse(readFileSync(proofPath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") { + return false; + } + throw error; + } + if (proof.nonce !== nonce || proof.pid !== child.pid || proof.quiescent !== true) { + throw new Error("Windows executor termination proof does not match its owner"); + } + } + cleanup(); + confirmed = true; + return true; + }, + terminate() { + if (child.exitCode === null && child.signalCode === null) { + writeFileSync(stopPath, nonce, { mode: 0o600 }); + } + }, + async waitForStopped() { + if (child.exitCode === null && child.signalCode === null) { + await new Promise((resolve, reject) => { + child.once("exit", resolve); + child.once("error", reject); + }); + } + if (!this.hasStopped()) { + throw new Error("Windows process tree exited without verified termination proof"); + } + } + }; +} diff --git a/docs/architecture.md b/docs/architecture.md index 97d1bd5..8aa8620 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,6 +12,18 @@ Agency and `com.github.copilot/agents/fixlab.agent.md` for the Agent Plugins 1.0 layout used by direct GitHub Copilot CLI. Repository policy validation requires those definitions to remain identical. +Direct Copilot execution accepts `--model` or `FIXLAB_MODEL`; Agency rejects +nonempty model overrides. Dashboard jobs retain their selected model through +queueing, retries and saved-context recovery. An omitted model uses the +dashboard's configured default; an explicit empty selection uses Copilot's +own default. HydraFusion adds `--experimental --model hydrafusion` and remains +an account-dependent research preview, not a guaranteed cost or speed saving. +Stage markers are requested before tools and parsed even when adjacent in +rendered output, but model-dependent buffering can still delay visible updates. +Snapshots also retain their originating runtime. A dashboard restores only +compatible jobs; other-runtime or ambiguous legacy contexts remain in history +rather than being silently resumed through a different executor. + ```text Defect or pull request | @@ -64,6 +76,50 @@ held workflow state, 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. + +Executor admission also acquires one atomic filesystem lease per canonical +repository before invoking a runner. Git worktrees share the Git common +directory; standalone non-Git repositories use their canonical directory. +An acquisition records the owner, job, process generation and a unique nonce. +All new jobs, queue starts, retries and automatic continuations use this same +boundary. A logical timeout or stop request does not release the lease or +enable resume before the executor's termination promise settles. Unverified +termination, partial reservations and owner crashes remain fail-closed; neither +an HTTP timeout nor PID reuse authorizes automatic reclamation. + +Windows executors launch suspended, enter a non-breakaway Windows Job Object, +then resume. Each executor has one hidden console that ordinary tooling +descendants inherit, preventing transient PowerShell and command-prompt windows +without sharing shell state between jobs. Input, output and errors remain piped +to the supervisor and dashboard logs. Noninteractive CLI probes and setup +helpers also hide their Windows console windows; interactive agent sessions +continue using the user's terminal. +Managed long-running frontends keep their input pipe open until owned shutdown; +React-style servers can interpret EOF as a request to exit. One-shot setup and +authentication helpers still receive EOF, and process-tree containment and +termination proofs remain unchanged. +The supervisor terminates remaining descendants after launcher +exit or an owned stop request, and records a nonce-bound proof only after the +job's active process count reaches zero. Lease release requires both supervisor +exit and that proof; a supervisor crash remains fail-closed. This uses built-in +Windows PowerShell and its C# compiler, with no native package dependency. +Unavailable containment or missing proof is an explicit failure, never a +fallback to launcher-only checks. Dedicated Windows CI exercises real detached +descendants, explicit stop, wrapper crash and lease succession. + +Manual localhost validation must use a frontend managed outside the Windows +executor, such as the dashboard's React session, and verify it serves the +validated checkout. Dashboard-managed applications remain under session +controls rather than being terminated by an agent. Missing checkout or health +evidence remains a blocker. + 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 @@ -248,8 +304,14 @@ artifacts, reviews the effective diff, pushes the checkpoint, and creates or updates a draft pull request. Independent builds, application startup, and browser journeys continue while the draft is visible. Later reviewed checkpoints update the same branch and PR. The PR remains explicitly -validation-in-progress and cannot be marked ready until every required gate -passes or repository policy records an accepted outcome. +validation-in-progress until required local validation and approval gates +pass or repository policy records an accepted outcome. FixLab then publishes +the authorized PR by clearing its draft status and verifies the provider's +`isDraft` state is false before reporting the PR stage passed. Publication +does not wait for remote CI or build completion: some providers trigger PR +builds only after publication. Those checks remain reported as pending, not +passed, and normal review, build, and merge policies remain enforced. Failed +publication or state verification blocks or fails the PR stage. Stage summaries remain structured independently of raw output. The dashboard retains a bounded local log window and reports how many older entries were 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. diff --git a/docs/getting-started.md b/docs/getting-started.md index 93e0f9b..45ed4b4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -225,15 +225,71 @@ When a job is blocked at live-test, **Connect Playwright, then retry live test** runs the same configured command, waits for you to sign in, and retries the live test automatically only after the command exits successfully. Sign-in always stays with you; FixLab never handles credentials. +While the authentication helper is running, browser retry controls are disabled +and the API rejects attempts to resume a blocked live test. If the repository +keeps its sign-in window open after login, close that window to release the +persistent profile before retrying; do not start another sign-in helper. 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. +FixLab-owned agent and frontend processes inherit `BROWSER=none` to suppress +React development-server auto-opening. Explicit manual localhost validation +still opens the application once when its health gate passes. + +**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. + +Executor starts are also fenced by an atomic per-repository lease, shared by +linked Git worktrees. A timeout or stop request keeps that lease until actual +executor termination is confirmed; resume remains disabled while stopping. +An owner crash or unverifiable exit leaves an explicit reserved-lease blocker. +Do not clear it merely because the dashboard is unreachable or a PID appears +stale: recovery requires verifying that the owner and executor tree are +quiescent. There is no automatic stale-owner takeover. + +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..d3fe1b8 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -7,6 +7,26 @@ FixLab. For known CLI, package-manager, private-feed, Playwright, authentication, and runtime failures, see [Troubleshooting](troubleshooting.md). +## Recommended path: install, then run + +Once your supported runtime is installed and signed in, use just: + +```powershell +npm install --global github:MonaDevAI/FixLab +fixlab chat C:\source\application --runtime copilot +``` + +Replace the path with your clone. For Agency, replace `copilot` with `agency`. +If setup is incomplete, chat opens a guided interactive setup with an explicit +onboarding request. Review the real profile paths and commands, approve the +scoped restore/browser setup, and complete any required feed sign-in and +application MFA yourself. FixLab checks readiness before starting the dashboard. +If setup fails, it reports the prerequisite rather than starting a broken job. + +The commands below are manual setup and troubleshooting alternatives, not a +checklist that every user must run. A PR-only feature needs its approved commit +installed until the change reaches the default branch. + ## Prerequisites The machine running FixLab needs: @@ -45,6 +65,13 @@ local dashboard in the background after the profile is ready. ## Install directly from GitHub +The package installation is machine-wide; repository/worktree preparation is +separate. For another checkout of a configured repository, use plan-first +`fixlab onboard C:\path\to\worktree --from C:\path\to\application --runtime copilot`, +then add `--yes` after reviewing the source profile and declared files. +See [worktree preparation](repository-onboarding.md#preparing-another-worktree) +for scoped dependency grants and interactive authentication. + FixLab is not currently published to the public npm registry. Install the public GitHub repository with npm: @@ -492,15 +519,19 @@ 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 ``` -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: +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` and uses port `4317` by default. Browser opening +requires explicit opt-in. Select another repository or port: ```powershell fixlab dashboard C:\source\application --port 4318 --no-open diff --git a/docs/repository-onboarding.md b/docs/repository-onboarding.md index eb1a865..98584c3 100644 --- a/docs/repository-onboarding.md +++ b/docs/repository-onboarding.md @@ -103,6 +103,60 @@ FixLab replaces `{userId}` with the configured value and instructs the agent to create branches beneath the resolved prefix. It never substitutes `copilot`, `fixlab`, or another runtime name for the repository user's ID. +## Worktree setup + +### Preparing another worktree + +A Git worktree does not inherit ignored profiles, untracked browser +configuration, restored dependencies, or authenticated browser state. Declare +only the non-sensitive configuration files needed by another checkout: + +```json +{ + "worktreeSetup": { + "files": ["frontend/playwright.local.config.ts"] + } +} +``` + +Start with the configured source checkout and review the plan: + +```powershell +fixlab onboard C:\source\application-worktree --from C:\source\application --runtime copilot +fixlab onboard C:\source\application-worktree --from C:\source\application --runtime copilot --yes +``` + +`--from` requires another checkout of the same Git repository. Without `--yes`, +it changes no files and runs no commands. Approval copies the profile and only +the declared files, initializes missing FixLab prompts, runs the profile's +restore and Playwright setup, and checks readiness. Matching files are +preserved; differing configuration requires a manual merge. Only a profile +that exactly matches the packaged unchanged example can be replaced +automatically. All file paths must stay within their checkout, without +symlink/junction escapes. Dependency folders, credential files and browser +state are excluded. + +An explicit `--config` file in the profile's browser test command must exist +in the target checkout or be included in the copy plan. Missing configuration +blocks setup and dashboard readiness instead of silently skipping live testing. +After provisioning, rerun onboarding without `--from` if the target profile +has been customized. + +When `node_modules` links outside the checkout, onboarding lists the exact +resolved dependency directories. `--yes` approves adding only those paths +to `trustedDependencyRoots`; runtime adapters pass them through `--add-dir`. +Doctor reports unapproved links before a non-interactive job starts. This +does not grant all filesystem paths, approve URLs, or override runtime deny +rules. Explicit runtime denial remains a blocker, not a reason to repeat +unchanged approvals. + +For required browser sign-in, add `--authenticate` after reviewing the +repository-owned authentication command. Sign-in/MFA must be completed by the +user in this checkout's own browser profile. A profile directory existing is +not proof of a valid application session. Add `--start-dashboard` only when +all Doctor checks can pass; serve this worktree's actual code, not a healthy +server from another checkout. + ## Profile review checklist - Commands run non-interactively. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 0c63df3..339a059 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -155,6 +155,18 @@ guidance. ## Playwright package or browser is unavailable +For a new worktree, first provision its profile and declared non-sensitive +browser configuration with `fixlab onboard WORKTREE --from SOURCE`. +Review the plan and add `--yes` to execute it. Missing `--config` files are +reported explicitly; list private non-sensitive configuration in the source +profile's `worktreeSetup.files`, or provide it manually in the target checkout. + +If Doctor reports **Runtime dependency access**, a `node_modules` symlink or +junction points outside the checkout. Run `fixlab onboard WORKTREE` to inspect +the exact dependency paths, then add `--yes` to approve only those directories. +Do not use blanket path grants. Organizational runtime deny rules still apply; +a denied tool or URL needs its own scoped approval, not repeated job resumes. + Restore repository dependencies first: ```powershell diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js index 9a15057..a28b977 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", @@ -114,14 +123,10 @@ test.beforeAll(async () => { repository, packageRoot, executor, + browserOpener() {}, 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 +134,127 @@ test.afterAll(async () => { rmSync(repository, { recursive: true, force: true }); }); +test("Copilot model control honors configured defaults and explicit per-job selection", async ({ page }) => { + const modelRepository = mkdtempSync(join(tmpdir(), "fixlab-model-e2e-")); + const directory = join(modelRepository, ".github", "fixlab"); + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "repository-profile.json"), + readFileSync(join(repository, ".github", "fixlab", "repository-profile.json"))); + let selectedModel; + const modelDashboard = createDashboardServer({ + repository: modelRepository, + packageRoot, + runtime: "copilot", + model: "claude-sonnet-4.5", + executor(input) { + selectedModel = input.model; + return executor(input); + }, + browserOpener() {} + }); + try { + const address = await modelDashboard.listen({ port: await availablePort() }); + await page.goto(address.url); + await expect(page.locator("#model-control")).toBeVisible(); + await expect(page.locator("#model")).toHaveValue("claude-sonnet-4.5"); + await page.locator("#model").selectOption("hydrafusion"); + await page.locator("#request").fill("Verify the model is retained for this job."); + await page.locator('input[name="mode"][value="validate-only"]').check(); + await page.locator("#start-button").click(); + await expect.poll(() => selectedModel).toBe("hydrafusion"); + await expect(page.locator("#model")).toHaveValue("hydrafusion"); + await expect(page.locator("#job-status")).toContainText("passed"); + } finally { + await page.close(); + await modelDashboard.close(); + rmSync(modelRepository, { 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 test.step("Opening the session observer", () => 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 test.step("Cancelling the stop confirmation", () => + 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 test.step("Stopping the confirmed owner", async () => { + await row.getByRole("button", { name: "Stop agent", exact: true }).click(); + await expect(row).toContainText("(blocked)"); + }); + expect(stops).toBe(1); + await test.step("Resuming the stopped owner", async () => { + 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 test.step("Shutting down the confirmed sessions", async () => { + await row.getByRole("button", { name: "Shutdown sessions", exact: true }).click(); + await expect(row).toContainText("(blocked)"); + }); + expect(stops).toBe(2); + } finally { + await test.step("Closing the polling observer page", () => page.close()); + await test.step("Closing the owner dashboard", () => owner.close()); + await test.step("Closing the observer dashboard", () => observer.close()); + rmSync(sessionRepository, { recursive: true, force: true }); + } +}); + test("dashboard exposes repository readiness through the running server", async ({ page, request diff --git a/prompts/fixlab.pr.prompt.md b/prompts/fixlab.pr.prompt.md index bbcc330..cb592f5 100644 --- a/prompts/fixlab.pr.prompt.md +++ b/prompts/fixlab.pr.prompt.md @@ -10,4 +10,14 @@ independent validation may continue while that draft is open. Keep the PR description current with the symptom, confirmed root cause, focused change, completed and running checks, browser evidence, skipped gates, and remaining risks. Never mark the PR ready or complete the PR stage until every required -gate passes or repository policy records an explicitly accepted outcome. +local gate passes or repository policy records an explicitly accepted outcome. + +After required local validation and approval gates pass or have an explicitly +accepted outcome, publish the authorized PR by marking it ready for review. +Do not wait for remote PR CI or build completion before publication; publication +can trigger those checks. Verify the PR is no longer draft before reporting the +PR stage passed. Report remote checks as pending, not passed, and leave merge +policies intact. Use `gh pr ready ` for GitHub or +`az repos pr update --id --draft false` for Azure DevOps, then read back +`isDraft` from the provider to confirm it is false. If publication or state +verification fails, report the PR stage blocked or failed, not passed. diff --git a/scripts/ci/fixtures/executor-lease-contender.mjs b/scripts/ci/fixtures/executor-lease-contender.mjs new file mode 100644 index 0000000..9dad1cc --- /dev/null +++ b/scripts/ci/fixtures/executor-lease-contender.mjs @@ -0,0 +1,33 @@ +import { randomUUID } from "node:crypto"; +import { spawn } from "node:child_process"; +import { createExecutorLease } from "../../../dashboard/executor-lease.js"; + +const [repository, evidence] = process.argv.slice(2); +const owner = createExecutorLease(repository, randomUUID()); +process.send({ type: "ready" }); +process.once("message", () => { + try { + const acquisition = owner.acquire(randomUUID()); + const executor = spawn(process.execPath, [ + "-e", + "const fs = require('node:fs'); fs.appendFileSync(process.argv[1], process.pid + '\\n'); process.send('started'); setInterval(() => fs.appendFileSync(process.argv[1] + '.child', 'active\\n'), 20);", + evidence + ], { detached: true, stdio: ["ignore", "ignore", "inherit", "ipc"] }); + acquisition.update("running", executor.pid); + executor.once("message", () => process.send({ type: "acquired" })); + process.once("message", (message) => { + if (message && typeof message === "object" && "crash" in message && message.crash === true) { + process.send({ type: "orphan", pid: executor.pid }, () => process.exit(0)); + return; + } + executor.once("exit", () => { + acquisition.release(); + process.disconnect(); + }); + executor.kill(); + }); + } catch (error) { + process.send({ type: "reserved", message: error.message }); + process.disconnect(); + } +}); diff --git a/specs/v1/autofix-loop.md b/specs/v1/autofix-loop.md index fff2b68..4c3a18a 100644 --- a/specs/v1/autofix-loop.md +++ b/specs/v1/autofix-loop.md @@ -35,8 +35,15 @@ and pull-request policy. pull request after the scoped change and smallest focused regression checks pass, while independent required validation continues. It MUST reuse the same branch and draft, keep pending or failed gates visible, and MUST NOT - mark the pull request ready or the PR stage passed before the required gates - reach an accepted terminal outcome. + mark the pull request ready or the PR stage passed before required local + validation and approval gates reach an accepted terminal outcome. +10. It MUST then publish the authorized pull request by clearing draft status + and verify the provider reports `isDraft=false` before reporting the PR + stage passed. It MUST NOT wait for remote PR CI or build completion before + publication, because publication may trigger those checks. Pending remote + checks MUST remain reported as pending; review and merge policies still + apply. Failed publication or state verification MUST block or fail the PR + stage. ## Safety diff --git a/templates/fixlab-autofix.agent.md b/templates/fixlab-autofix.agent.md index 204ee66..2dc0a1d 100644 --- a/templates/fixlab-autofix.agent.md +++ b/templates/fixlab-autofix.agent.md @@ -37,6 +37,15 @@ only for information that cannot be established safely from the repository. ## Bugfix loop +Before validation in a new isolated worktree, plan `fixlab onboard WORKTREE +--from SOURCE --runtime RUNTIME`. After scoped setup approval, add `--yes`. +The source profile declares non-sensitive configuration in `worktreeSetup.files`. +Never copy credentials or browser state. Missing configuration, authentication +or denied runtime access is a blocker; do not retry unchanged approvals or +grant blanket path access. Sign-in and MFA remain human-controlled. +In read-only modes, do not provision or alter configuration; report missing +prerequisites as blocked. + 1. **Classify scope.** Identify the smallest component in the profile that owns the behavior. Treat its source paths as the initial edit allowlist. 2. **Reproduce or measure.** Locate the affected code and establish a failing @@ -92,9 +101,24 @@ pass, remove transient validation artifacts, review the effective diff, commit and push that checkpoint, and create or update a draft pull request. Run remaining independent builds and browser journeys while the draft is open, 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 +until every required local gate passes or the developer explicitly accepts the recorded outcome. +After required local validation and approval gates pass or have an explicitly +accepted outcome, publish the authorized PR by marking it ready for review. +Do not wait for remote PR CI or build completion before publication; publication +can trigger those checks. Verify the PR is no longer draft before reporting the +PR stage passed. Report remote checks as pending, not passed, and leave merge +policies intact. Use `gh pr ready ` for GitHub or +`az repos pr update --id --draft false` for Azure DevOps, then read back +`isDraft` from the provider to confirm it is false. If publication or state +verification fails, report the PR stage blocked or failed, not passed. + +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..7c29c56 100644 --- a/test/agent-rules.test.js +++ b/test/agent-rules.test.js @@ -104,6 +104,12 @@ test("cross-runtime validation guidance stays aligned", () => { ); } + for (const entryPoint of [...agentEntryPoints, "bin/fixlab.js"]) { + 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`); + } + for (const entryPoint of agentEntryPoints) { const content = readFileSync(join(root, entryPoint), "utf8"); assert.match( @@ -146,6 +152,57 @@ test("cross-runtime validation guidance stays aligned", () => { assert.match(browserRule, /event registration/); }); +test("every runtime prepares worktrees with scoped approval and human authentication", () => { + const root = join(testDirectory, ".."); + for (const entryPoint of [ + "dashboard/server.js", + "bin/fixlab.js", + "agents/fixlab.md", + "com.github.copilot/agents/fixlab.agent.md", + "templates/fixlab-autofix.agent.md", + ".github/agents/fixlab-autofix.agent.md" + ]) { + const content = readFileSync(join(root, entryPoint), "utf8").replace(/\s+/gu, " "); + assert.match(content, /--from SOURCE --runtime RUNTIME/, entryPoint); + assert.match(content, /scoped setup approval/, entryPoint); + assert.match(content, /worktreeSetup.files/, entryPoint); + assert.match(content, /Never copy credentials or browser state/, entryPoint); + assert.match(content, /do not retry unchanged approvals or grant blanket path access/, entryPoint); + assert.match(content, /Sign-in and MFA remain human-controlled/, entryPoint); + assert.match(content, /In read-only modes, do not provision or alter configuration/, entryPoint); + } +}); + +test("every PR entry point publishes before waiting for remote CI", () => { + const root = join(testDirectory, ".."); + const entryPoints = [ + "dashboard/server.js", + "bin/fixlab.js", + "agents/fixlab.md", + "com.github.copilot/agents/fixlab.agent.md", + "templates/fixlab-autofix.agent.md", + ".github/agents/fixlab-autofix.agent.md", + "prompts/fixlab.pr.prompt.md" + ]; + for (const entryPoint of entryPoints) { + const content = readFileSync(join(root, entryPoint), "utf8").replace(/\s+/gu, " "); + assert.match(content, /required local validation and approval gates/, + `${entryPoint} must preserve local validation and publication approval`); + assert.match(content, /publish the authorized PR by marking it ready for review/, + `${entryPoint} must explicitly publish instead of leaving the PR draft`); + assert.match(content, /Do not wait for remote PR CI or build completion before publication/, + `${entryPoint} must not wait for a build triggered by publication`); + assert.match(content, /Verify the PR is no longer draft before reporting the PR stage passed/, + `${entryPoint} must verify publication before reporting success`); + assert.match(content, /Report remote checks as pending, not passed, and leave merge policies intact/, + `${entryPoint} must not equate publication with successful CI or merge approval`); + assert.match(content, /gh pr ready/, + `${entryPoint} must identify the GitHub draft transition`); + assert.match(content, /az repos pr update --id (?:|PR_ID) --draft false/, + `${entryPoint} must identify the Azure DevOps draft transition`); + } +}); + test("repository policy matches enforced CI and rollback controls", () => { const root = join(testDirectory, ".."); diff --git a/test/dashboard-executor-lease.test.js b/test/dashboard-executor-lease.test.js new file mode 100644 index 0000000..6dbab94 --- /dev/null +++ b/test/dashboard-executor-lease.test.js @@ -0,0 +1,153 @@ +import assert from "node:assert/strict"; +import { fork, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { once } from "node:events"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { createExecutorLease } from "../dashboard/executor-lease.js"; + +function repository() { + const path = mkdtempSync(join(tmpdir(), "fixlab-lease-")); + const result = spawnSync("git", ["init", "--quiet", path]); + assert.equal(result.status, 0); + return path; +} + +test("executor lease admits exactly one barrier-synchronized cross-process contender", async () => { + const path = repository(); + const evidence = join(path, "starts.txt"); + const fixture = new URL("../scripts/ci/fixtures/executor-lease-contender.mjs", import.meta.url); + const workers = [ + fork(fixture, [path, evidence], { stdio: ["ignore", "ignore", "inherit", "ipc"] }), + fork(fixture, [path, evidence], { stdio: ["ignore", "ignore", "inherit", "ipc"] }) + ]; + try { + await Promise.all(workers.map((worker) => once(worker, "message"))); + const outcomes = workers.map((worker) => once(worker, "message")); + for (const worker of workers) { + worker.send({ start: true }); + } + const results = (await Promise.all(outcomes)).map(([message]) => message); + assert.equal(results.filter((result) => result.type === "acquired").length, 1); + assert.equal(results.filter((result) => result.type === "reserved").length, 1); + assert.equal(readFileSync(evidence, "utf8").trim().split("\n").length, 1); + const winner = workers[results.findIndex((result) => result.type === "acquired")]; + const exited = once(winner, "exit"); + winner.send({ finish: true }); + await exited; + const next = createExecutorLease(path, randomUUID()).acquire(randomUUID()); + next.release(); + } finally { + for (const worker of workers) { + if (worker.connected) { + const exited = once(worker, "exit"); + worker.send({ finish: true }); + await exited; + } + } + rmSync(path, { recursive: true, force: true }); + } +}); + +test("executor lease release is acquisition-scoped and stale callbacks cannot remove a successor", () => { + const path = repository(); + try { + const owner = createExecutorLease(path, randomUUID()); + const first = owner.acquire("first-job"); + first.update("stopping", 123); + assert.throws(() => owner.acquire("other-job"), /lease is reserved/); + first.release(); + const second = owner.acquire("second-job"); + first.release(); + assert.throws(() => owner.acquire("third-job"), /lease is reserved/); + second.release(); + } finally { + rmSync(path, { recursive: true, force: true }); + } +}); + +test("partial or crashed executor reservations fail closed without heartbeat or PID reclamation", () => { + const path = repository(); + try { + const owner = createExecutorLease(path, randomUUID()); + mkdirSync(owner.scope.directory, { recursive: true }); + assert.throws(() => owner.acquire("job"), /incomplete or unreadable/); + } finally { + rmSync(path, { recursive: true, force: true }); + } +}); + +test("linked worktrees share the canonical executor lease", () => { + const path = repository(); + const worktree = `${path}-worktree`; + try { + const commit = spawnSync("git", [ + "-C", path, "-c", "user.name=FixLab test", "-c", "user.email=fixlab@example.invalid", + "commit", "--allow-empty", "--quiet", "-m", "Fixture" + ]); + assert.equal(commit.status, 0); + assert.equal(spawnSync("git", ["-C", path, "worktree", "add", "--quiet", "-b", "fixture", worktree]).status, 0); + const first = createExecutorLease(path, randomUUID()); + const second = createExecutorLease(worktree, randomUUID()); + assert.equal(first.scope.key, second.scope.key); + const held = first.acquire("main-job"); + assert.throws(() => second.acquire("worktree-job"), /lease is reserved/); + held.release(); + } finally { + rmSync(worktree, { recursive: true, force: true }); + rmSync(path, { recursive: true, force: true }); + } +}); + +test("an owner crash with a surviving executor cannot free the repository lease", async () => { + const path = repository(); + const evidence = join(path, "crash-evidence"); + const fixture = new URL("../scripts/ci/fixtures/executor-lease-contender.mjs", import.meta.url); + const worker = fork(fixture, [path, evidence], { + stdio: ["ignore", "ignore", "inherit", "ipc"] + }); + let executorPid; + try { + await once(worker, "message"); + const acquired = once(worker, "message"); + worker.send({ start: true }); + assert.equal((await acquired)[0].type, "acquired"); + const orphaned = once(worker, "message"); + const exited = once(worker, "exit"); + worker.send({ crash: true }); + executorPid = (await orphaned)[0].pid; + await exited; + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + if (readFileSync(`${evidence}.child`, "utf8").includes("active")) { + break; + } + } catch (error) { + assert.equal(error.code, "ENOENT"); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.match(readFileSync(`${evidence}.child`, "utf8"), /active/); + const contender = createExecutorLease(path, randomUUID()); + assert.throws(() => contender.acquire("after-owner-crash"), /lease is reserved/); + } finally { + if (executorPid) { + if (process.platform === "win32") { + assert.equal(spawnSync("powershell.exe", [ + "-NoProfile", "-NonInteractive", "-Command", + `Stop-Process -Id ${executorPid} -Force -ErrorAction Stop` + ], { windowsHide: true }).status, 0); + } else { + process.kill(executorPid, "SIGTERM"); + } + } + if (worker.connected) { + const exited = once(worker, "exit"); + worker.send({ finish: true }); + await exited; + } + rmSync(path, { recursive: true, force: true }); + } +}); diff --git a/test/dashboard-repository-setup.test.js b/test/dashboard-repository-setup.test.js new file mode 100644 index 0000000..79c5992 --- /dev/null +++ b/test/dashboard-repository-setup.test.js @@ -0,0 +1,137 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { applyWorktreeSetup, browserConfigurationErrors, planWorktreeSetup, SETUP_PROFILE_PATH } from "../dashboard/repository-setup.js"; + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "fixlab-worktree-setup-")); + const source = join(root, "source"); + const repository = join(root, "worktree"); + mkdirSync(source); + const git = (...args) => { + const result = spawnSync("git", ["-C", source, ...args], { encoding: "utf8", windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + }; + git("init", "--quiet"); + writeFileSync(join(source, "tracked.txt"), "fixture"); + git("add", "tracked.txt"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "fixture"); + git("worktree", "add", "--quiet", "--detach", repository); + mkdirSync(join(source, ".github", "fixlab"), { recursive: true }); + mkdirSync(join(source, "frontend")); + const profile = { + browserAutomation: { workingDirectory: "frontend", testCommand: "playwright test --config=playwright.local.ts" }, + worktreeSetup: { files: ["frontend/playwright.local.ts"] } + }; + writeFileSync(join(source, SETUP_PROFILE_PATH), JSON.stringify(profile)); + writeFileSync(join(source, "frontend", "playwright.local.ts"), "export default {};"); + return { root, source, repository, profile }; +} + +test("worktree setup is plan-first, explicit, idempotent and configuration-only", () => { + const f = fixture(); + try { + writeFileSync(join(f.source, "frontend", "unrelated.txt"), "preserve"); + const plan = planWorktreeSetup(f); + assert.equal(existsSync(join(f.repository, SETUP_PROFILE_PATH)), false); + applyWorktreeSetup(plan); + assert.deepEqual(JSON.parse(readFileSync(join(f.repository, SETUP_PROFILE_PATH))), f.profile); + assert.equal(readFileSync(join(f.repository, "frontend", "playwright.local.ts"), "utf8"), "export default {};"); + assert.equal(existsSync(join(f.repository, "frontend", "unrelated.txt")), false); + assert.deepEqual(browserConfigurationErrors(f.profile, f.repository), []); + applyWorktreeSetup(planWorktreeSetup(f)); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("worktree setup preserves customized configuration and checks every file before writing", () => { + const f = fixture(); + try { + mkdirSync(join(f.repository, "frontend")); + writeFileSync(join(f.repository, "frontend", "playwright.local.ts"), "customized"); + assert.throws(() => planWorktreeSetup(f), /merge it manually/); + assert.equal(existsSync(join(f.repository, SETUP_PROFILE_PATH)), false); + assert.equal(readFileSync(join(f.repository, "frontend", "playwright.local.ts"), "utf8"), "customized"); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("worktree setup can replace only an unchanged generated template", () => { + const f = fixture(); + try { + const templatePath = join(f.root, "template.json"); + writeFileSync(templatePath, '{"example":true}'); + mkdirSync(join(f.repository, ".github", "fixlab"), { recursive: true }); + writeFileSync(join(f.repository, SETUP_PROFILE_PATH), '{"example":true}'); + applyWorktreeSetup(planWorktreeSetup({ ...f, templatePath })); + assert.deepEqual(JSON.parse(readFileSync(join(f.repository, SETUP_PROFILE_PATH))), f.profile); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("worktree setup refuses changed plans, unrelated repositories and sensitive paths", () => { + const f = fixture(); + try { + const plan = planWorktreeSetup(f); + writeFileSync(join(f.source, "frontend", "playwright.local.ts"), "changed"); + assert.throws(() => applyWorktreeSetup(plan), /changed after planning/); + assert.equal(existsSync(join(f.repository, SETUP_PROFILE_PATH)), false); + assert.throws(() => planWorktreeSetup({ ...f, repository: f.root }), /same Git repository/); + for (const name of ["../outside.ts", "C:\\outside.ts", ".env", ".npmrc", "frontend/.auth/user.json", "frontend/.edge-profile/Default/Cookies", "frontend/node_modules/tool.js", "certificate.pem"]) { + writeFileSync(join(f.source, SETUP_PROFILE_PATH), JSON.stringify({ worktreeSetup: { files: [name] } })); + assert.throws(() => planWorktreeSetup(f), /must not copy|repository-relative/); + } + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("worktree setup and browser configuration refuse symlink escapes", () => { + const f = fixture(); + try { + const outside = join(f.root, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "config.ts"), "outside"); + symlinkSync(outside, join(f.source, "escape"), process.platform === "win32" ? "junction" : "dir"); + writeFileSync(join(f.source, SETUP_PROFILE_PATH), JSON.stringify({ worktreeSetup: { files: ["escape/config.ts"] } })); + assert.throws(() => planWorktreeSetup(f), /symbolic links/); + assert.match(browserConfigurationErrors({ + browserAutomation: { workingDirectory: ".", testCommand: "playwright test --config=escape/config.ts" } + }, f.source)[0], /symbolic links/); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("browser configuration preflight names the missing prerequisite instead of skipping it", () => { + const root = mkdtempSync(join(tmpdir(), "fixlab-browser-config-")); + try { + const profile = { browserAutomation: { workingDirectory: ".", testCommand: 'playwright test --config "missing config.ts"' } }; + assert.match(browserConfigurationErrors(profile, root)[0], /missing config.ts.*worktreeSetup.files/); + assert.deepEqual(browserConfigurationErrors({ browserAutomation: { testCommand: "npm run test:e2e" } }, root), []); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("worktree planning blocks omitted browser configuration and invalid file declarations", () => { + const f = fixture(); + try { + delete f.profile.worktreeSetup; + writeFileSync(join(f.source, SETUP_PROFILE_PATH), JSON.stringify(f.profile)); + assert.throws(() => planWorktreeSetup(f), /configuration is missing.*worktreeSetup.files/); + assert.equal(existsSync(join(f.repository, SETUP_PROFILE_PATH)), false); + for (const worktreeSetup of [null, [], { files: null }, { files: "config.ts" }, { files: [1] }]) { + writeFileSync(join(f.source, SETUP_PROFILE_PATH), JSON.stringify({ worktreeSetup })); + assert.throws(() => planWorktreeSetup(f), /worktreeSetup/); + } + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index a242770..4fc3ddb 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, @@ -35,8 +36,10 @@ import { MAX_CACHE_BYTES, MAX_CACHE_ENTRIES, MAX_JOB_LOG_ENTRIES, - openLocalUrl + openLocalUrl, + validateCopilotModel } from "../dashboard/server.js"; +import { createExecutorLease } from "../dashboard/executor-lease.js"; const packageRoot = new URL("..", import.meta.url).pathname.replace( /^\/([A-Za-z]:)/, @@ -76,6 +79,11 @@ test("linked node_modules directories are granted only inside trusted dependency writeProfile(undefined); assert.deepEqual(linkedDependencyDirectories(repository), []); + assert.deepEqual(linkedDependencyDirectories(repository, { discover: true }), [ + realpathSync(external), + realpathSync(untrusted) + ]); + assert.deepEqual(linkedDependencyDirectories(repository), []); writeProfile([parse(base).root]); assert.deepEqual(linkedDependencyDirectories(repository), []); @@ -97,6 +105,35 @@ test("linked node_modules directories are granted only inside trusted dependency } }); +test("Copilot models are validated and retained in fresh and resumed invocations", () => { + assert.equal(validateCopilotModel(" HydraFusion "), "hydrafusion"); + assert.equal(validateCopilotModel("", "agency"), ""); + for (const model of [1, true, ["hydrafusion"], {}]) { + assert.throws(() => validateCopilotModel(model), /model must be a string/); + } + assert.throws(() => validateCopilotModel("hydrafusion", "agency"), /copilot runtime/); + for (const model of ["hydrafusion&whoami", "--model", "x".repeat(129)]) { + assert.throws(() => validateCopilotModel(model), /model must be/); + } + for (const resume of [false, true]) { + for (const model of ["", "hydrafusion", "claude-sonnet-4.5"]) { + const invocation = buildCopilotInvocation({ + packageRoot: "C:\\FixLab", + prompt: "Validate the selected model.", + sessionId: "11111111-1111-4111-8111-111111111111", + model, + resume + }); + assert.equal(invocation.args.includes("--experimental"), model === "hydrafusion"); + assert.equal(invocation.args.includes("--model"), Boolean(model)); + if (model) { + assert.equal(invocation.args[invocation.args.indexOf("--model") + 1], model); + } + assert.equal(invocation.input, "Validate the selected model."); + } + } +}); + test("Agency executor keeps long prompts out of process arguments", () => { const prompt = `Fix this batch:\n${"x".repeat(40000)}`; const invocation = buildAgencyInvocation({ @@ -309,6 +346,21 @@ function validProfile(overrides = {}) { }; } +test("dashboard readiness blocks a missing explicit Playwright configuration", () => { + const repository = createRepository(); + try { + const profile = validProfile(); + profile.browserAutomation.testCommand = "playwright test --config=playwright.local.ts"; + writeFileSync(join(repository, ".github", "fixlab", "repository-profile.json"), JSON.stringify(profile)); + assert.equal(inspectRepository(repository).profileReady, false); + assert.match(inspectRepository(repository).error, /configuration is missing.*worktreeSetup.files/); + writeFileSync(join(repository, "playwright.local.ts"), "export default {};"); + assert.equal(inspectRepository(repository).profileReady, true); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + test("dashboard readiness blocks an incomplete live-test profile", () => { const repository = createRepository(); const profilePath = join( @@ -480,10 +532,27 @@ async function startDashboard( } }); }; + const trackedExecutor = (input) => { + const handle = effectiveExecutor(input); + if (options.deferTermination) { + return handle; + } + let stopped; + const termination = new Promise((resolve) => { stopped = resolve; }); + return { + ...handle, + completion: Promise.race([handle.completion, termination]), + terminate() { + handle.terminate?.(); + stopped({ code: null, signal: "SIGTERM" }); + } + }; + }; const dashboard = createDashboardServer({ repository, packageRoot, - executor: effectiveExecutor, + executor: trackedExecutor, + browserOpener() {}, ...Object.fromEntries( Object.entries(options).filter(([name]) => name !== "autoTestEvidence") ), @@ -498,6 +567,134 @@ async function jsonRequest(url, path, options) { return { response, body: await response.json() }; } +test("Copilot dashboard retains model defaults, overrides and queue state through recovery", async () => { + const repository = createGitRepository(); + const calls = []; + let current = await startDashboard(repository, (input) => { + calls.push(input); + input.onOutput("stdout", "FIXLAB_STAGE|intake|blocked|Waiting for a prerequisite.\n"); + return { completion: Promise.resolve({ code: 1 }), terminate() {} }; + }, undefined, { runtime: "copilot", model: "hydrafusion" }); + const post = (path, body) => jsonRequest(current.url, path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + try { + const status = await jsonRequest(current.url, "/api/status"); + assert.deepEqual(status.body.runtime, { + name: "copilot", + defaultModel: "hydrafusion", + supportsModelSelection: true + }); + const started = await post("/api/jobs", { + request: "Retain the configured model.", + mode: "validate-only" + }); + assert.equal(started.response.status, 202); + assert.equal(started.body.job.model, "hydrafusion"); + await new Promise((resolve) => setTimeout(resolve, 20)); + const resumed = await post("/api/job/input", { + action: "continue", + details: "The prerequisite is ready." + }); + assert.equal(resumed.response.status, 202); + assert.equal(calls[1].model, "hydrafusion"); + assert.equal(calls[1].resume, true); + await new Promise((resolve) => setTimeout(resolve, 20)); + const queued = await post("/api/jobs", { + request: "Use an explicit stable model.", + mode: "validate-only", + model: "claude-sonnet-4.5" + }); + assert.equal(queued.response.status, 202); + assert.equal(queued.body.queue[0].model, "claude-sonnet-4.5"); + const unconfigured = await post("/api/jobs", { + request: "Use Copilot's own default.", + mode: "validate-only", + model: "" + }); + assert.equal(unconfigured.response.status, 202); + assert.equal(unconfigured.body.queue[1].model, ""); + await current.dashboard.close(); + current = await startDashboard(repository, (input) => { + calls.push(input); + return { completion: new Promise(() => {}), terminate() {} }; + }, undefined, { runtime: "copilot", model: "another-model" }); + const recovered = await jsonRequest(current.url, "/api/status"); + assert.equal(recovered.body.job.model, "hydrafusion"); + assert.equal(recovered.body.history.find((job) => job.id === queued.body.queue[0].id).model, "claude-sonnet-4.5"); + assert.equal(recovered.body.history.find((job) => job.id === unconfigured.body.queue[1].id).model, ""); + await post("/api/job/input", { action: "continue", details: "Resume the saved model." }); + assert.equal(calls.at(-1).model, "hydrafusion"); + await current.dashboard.close(); + current = await startDashboard(repository, () => { + throw new Error("Cross-runtime recovery must not start an executor."); + }); + const incompatible = await jsonRequest(current.url, "/api/status"); + assert.equal(incompatible.body.job, null); + assert.ok(incompatible.body.history.some((job) => job.runtime === "copilot")); + const rejected = await post("/api/job/input", { action: "continue", details: "Wrong runtime." }); + assert.equal(rejected.response.status, 404); + assert.match(rejected.body.error, /no FixLab job/); + } finally { + await current.dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("Agency dashboard rejects Copilot model overrides before starting an executor", async () => { + const repository = createRepository(); + const { dashboard, url } = await startDashboard(repository, () => { + throw new Error("Rejected jobs must not execute."); + }); + try { + const rejected = await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request: "Do not execute.", mode: "validate-only", model: "hydrafusion" }) + }); + assert.equal(rejected.response.status, 400); + assert.match(rejected.body.error, /copilot runtime/); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + +for (const trailingNewline of [false, true]) { + test(`adjacent markers survive chunk boundaries and ${trailingNewline ? "complete lines" : "final partial output"}`, async () => { + const repository = createRepository(); + const { dashboard, url } = await startDashboard(repository, ({ onOutput }) => { + const text = FIXLAB_STAGES.map((stage) => `FIXLAB_STAGE|${stage}|skipped|Read-only validation.`).join("") + + "FIXLAB_ACTIVITY|intake|Recorded the required evidence." + + (trailingNewline ? "\n" : ""); + const boundary = text.indexOf("FIXLAB_STAGE", 1) + 7; + onOutput("stdout", text.slice(0, boundary)); + onOutput("stdout", text.slice(boundary)); + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + }); + try { + const started = await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request: "Validate marker boundaries.", mode: "validate-only" }) + }); + assert.equal(started.response.status, 202); + await new Promise((resolve) => setTimeout(resolve, 20)); + const completed = await jsonRequest(url, "/api/job"); + assert.equal(completed.body.job.status, "passed"); + for (const stage of FIXLAB_STAGES) { + assert.equal(completed.body.job.stages[stage].status, "skipped"); + } + assert.equal(completed.body.job.activity.length, 1); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } + }); +} + test("dashboard reports readiness and serves only known static assets", async () => { const repository = createRepository(); const { dashboard, url } = await startDashboard(repository, () => { @@ -1161,8 +1358,13 @@ test("dashboard parses complete stage markers and passes a job", async () => { ); assert.match( receivedPrompt, - /Never mark it ready for review or report the pr stage passed until all required gates pass/i + /Never mark it ready for review or report the pr stage passed until all required local gates pass/i ); + assert.match(receivedPrompt, /publish the authorized PR by marking it ready for review/); + assert.match(receivedPrompt, /Do not wait for remote PR CI or build completion before publication/); + assert.match(receivedPrompt, /Verify the PR is no longer draft before reporting the PR stage passed/); + assert.match(receivedPrompt, /Report remote checks as pending, not passed, and leave merge policies intact/); + assert.match(receivedPrompt, /az repos pr update --id --draft false/); assert.match(receivedPrompt, /authentication, unsafe-data approval/i); assert.match(receivedPrompt, /git status and effective diff/i); assert.match(receivedPrompt, /task-relevant symbols and files/i); @@ -1554,14 +1756,15 @@ test("dashboard checks and starts repository-owned Playwright authentication", a assert.equal(started.body.started, true); let status; - for (let attempt = 0; attempt < 20; attempt += 1) { + const authenticationDeadline = Date.now() + 10000; + while (Date.now() < authenticationDeadline) { await new Promise((resolve) => setTimeout(resolve, 25)); status = await jsonRequest(url, "/api/playwright/status"); if (!status.body.running) { break; } } - assert.equal(status.body.ready, true); + assert.equal(status.body.ready, true, JSON.stringify(status.body)); assert.equal(status.body.lastResult.ok, true); assert.equal(status.body.paths[0].path, "e2e/.auth/user.json"); @@ -1645,7 +1848,87 @@ test("dashboard checks and starts repository-owned Playwright authentication", a } }); -test("dashboard starts, reuses and stops the profile-defined frontend", async () => { +test("blocked live-test resumes wait until authentication releases the browser profile", async () => { + const repository = createRepository(); + const profile = validProfile(); + profile.browserAutomation.authentication = { + required: true, + command: "node auth-holder.js", + statusPaths: ["auth-ready.json"] + }; + writeFileSync( + join(repository, ".github", "fixlab", "repository-profile.json"), + JSON.stringify(profile) + ); + writeFileSync(join(repository, "auth-ready.json"), "{}"); + writeFileSync( + join(repository, "auth-holder.js"), + 'const fs = require("node:fs"); setInterval(() => { if (fs.existsSync("auth-finished")) process.exit(0); }, 20);' + ); + let executions = 0; + const { dashboard, url } = await startDashboard(repository, ({ onOutput }) => { + executions += 1; + for (const stage of FIXLAB_STAGES) { + onOutput("stdout", `FIXLAB_STAGE|${stage}|${stage === "live-test" ? "blocked" : "passed"}|Fixture evidence\n`); + } + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + }); + const input = (action) => jsonRequest(url, "/api/job/input", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action, details: "Retry only the blocked browser gate." }) + }); + try { + await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Check browser ownership before a live-test retry.", + requestType: "bug-fix", + mode: "validate-only" + }) + }); + let job; + for (let attempt = 0; attempt < 80; attempt += 1) { + job = await jsonRequest(url, "/api/job"); + if (job.body.job.status === "blocked" && job.body.job.canResume) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(job.body.job.status, "blocked"); + assert.equal(executions, 1); + const connected = await jsonRequest(url, "/api/playwright/connect", { method: "POST" }); + assert.equal(connected.response.status, 202); + for (const action of ["retry", "continue", "comment"]) { + const rejected = await input(action); + assert.equal(rejected.response.status, 409); + assert.match(rejected.body.error, /Authentication is still running.*browser profile/); + } + assert.equal(executions, 1); + const unchanged = await jsonRequest(url, "/api/job"); + assert.equal(unchanged.body.job.inputCount, 0); + writeFileSync(join(repository, "auth-finished"), ""); + let authentication; + for (let attempt = 0; attempt < 80; attempt += 1) { + authentication = await jsonRequest(url, "/api/playwright/status"); + if (!authentication.body.running) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + assert.equal(authentication.body.running, false); + assert.equal(authentication.body.lastResult.ok, true); + const resumed = await input("retry"); + assert.equal(resumed.response.status, 202); + assert.equal(executions, 2); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + +async function checkFrontendCleanup(cleanup) { const repository = createRepository(); const port = await availablePort(); writeFileSync( @@ -1655,7 +1938,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}` } @@ -1665,12 +1948,20 @@ test("dashboard starts, reuses and stops the profile-defined frontend", async () ); writeFileSync( join(repository, "frontend.js"), - `require("node:http").createServer((q, s) => s.end("ok")).listen(${port}, "127.0.0.1");` + `require("node:http").createServer((q, s) => s.end(process.env.BROWSER ?? "unset")).listen(${port}, "127.0.0.1");` + ); + writeFileSync( + join(repository, "frontend-launcher.js"), + `require("node:child_process").spawn(process.execPath, ["frontend.js"], { stdio: "inherit", detached: process.platform === "win32" }); process.exit(0);` ); 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"); @@ -1693,24 +1984,105 @@ test("dashboard starts, reuses and stops the profile-defined frontend", async () } assert.equal(status.body.healthy, true); assert.equal(status.body.owned, true); + assert.equal(await (await fetch(`http://127.0.0.1:${port}`)).text(), "none"); const again = await jsonRequest(url, "/api/frontend/start", { 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) { + 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(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("managed frontend input stays open when EOF requests application shutdown", async () => { + const repository = createRepository(); + const port = await availablePort(); + const profile = validProfile(); + profile.applications.frontend = { + workingDirectory: ".", + command: "node frontend-eof.js", + port, + healthUrl: `http://127.0.0.1:${port}` + }; + writeFileSync(join(repository, ".github", "fixlab", "repository-profile.json"), JSON.stringify(profile)); + writeFileSync(join(repository, "frontend-eof.js"), [ + `const server = require("node:http").createServer((q, s) => s.end("ready")).listen(${port}, "127.0.0.1");`, + 'process.stdin.on("end", () => server.close(() => process.exit(0)));', + "process.stdin.resume();" + ].join("\n")); + const { dashboard, url } = await startDashboard(repository, () => ({ + completion: new Promise(() => {}), + terminate() {} + })); + try { + const started = await jsonRequest(url, "/api/frontend/start", { method: "POST" }); + assert.equal(started.response.status, 202); + let status; + for (let attempt = 0; attempt < 80; attempt += 1) { status = await jsonRequest(url, "/api/frontend/status"); - if (!status.body.healthy) { + if (status.body.healthy || !status.body.running) { break; } await new Promise((resolve) => setTimeout(resolve, 100)); } - assert.equal(status.body.healthy, false); + assert.equal(status.body.healthy, true, JSON.stringify(status.body)); + await new Promise((resolve) => setTimeout(resolve, 200)); + assert.equal((await jsonRequest(url, "/api/frontend/status")).body.healthy, true); + const stopped = await jsonRequest(url, "/api/frontend/stop", { method: "POST" }); + assert.equal(stopped.response.status, 200); + assert.equal(stopped.body.stopped, true); + assert.equal(stopped.body.running, false); + assert.equal(stopped.body.healthy, false); } finally { await dashboard.close(); rmSync(repository, { recursive: true, force: true }); @@ -1754,6 +2126,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 +2138,81 @@ 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: 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({ + environments: scenario.environment === "LOCAL" ? ["LOCAL", "dev", "sit"] : ["local", "dev", "sit"], + 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(process.env.BROWSER ?? "unset")).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); + assert.equal(await (await fetch(`http://127.0.0.1:${port}`)).text(), "none"); + 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 }) => { @@ -2070,6 +2522,10 @@ test("dashboard holds queued and active jobs and resumes the same session", asyn [secondId] ); + executions[0].options.onOutput( + "stdout", + "FIXLAB_STAGE|intake|passed|Intake retainedFIXLAB_ACTIVITY|diagnosis|Retain partial output" + ); const holding = await jsonRequest(url, "/api/jobs/hold", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -2092,6 +2548,11 @@ test("dashboard holds queued and active jobs and resumes the same session", asyn activeAfterHold.body.held.map((job) => job.id), [firstId] ); + assert.deepEqual(activeAfterHold.body.held[0].stages.intake, { + status: "passed", + message: "Intake retained" + }); + assert.equal(activeAfterHold.body.held[0].canResume, true); const resumedActive = await jsonRequest(url, "/api/jobs/resume", { method: "POST", @@ -2723,7 +3184,7 @@ test("dashboard recovery preserves a completed manual localhost gate", async () resumedPrompt, /Persisted manual React localhost result: passed/ ); - assert.match(resumedPrompt, /stop only the FixLab-owned frontend process/); + assert.match(resumedPrompt, /stop only frontend processes owned by the current job/); } finally { await second.dashboard.close(); } @@ -2903,6 +3364,58 @@ test("dashboard stops an executor that exceeds the output idle timeout", async ( } }); +test("idle finalization retains the atomic lease and blocks resume until executor termination", async () => { + const repository = createGitRepository(); + let finish; + const { dashboard, url } = await startDashboard(repository, () => ({ + completion: new Promise((resolve) => { finish = resolve; }), + terminate() {} + }), null, { executionIdleTimeoutMs: 25, deferTermination: true }); + const contender = createExecutorLease(repository, randomUUID()); + try { + await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Wait for confirmed termination before retrying.", + requestType: "bug-fix", + mode: "validate-only" + }) + }); + let status; + for (let attempt = 0; attempt < 40; attempt += 1) { + status = await jsonRequest(url, "/api/job"); + if (status.body.job.status === "failed") { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(status.body.job.status, "failed"); + assert.equal(status.body.job.canResume, false); + assert.throws(() => contender.acquire("racing-retry"), /lease is reserved/); + const resume = await jsonRequest(url, "/api/job/input", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ action: "retry", details: "Retry too soon." }) + }); + assert.equal(resume.response.status, 409); + finish({ code: null, signal: "SIGTERM" }); + for (let attempt = 0; attempt < 40; attempt += 1) { + status = await jsonRequest(url, "/api/job"); + if (status.body.job.canResume) { + break; + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(status.body.job.canResume, true); + contender.acquire("after-exit").release(); + } finally { + finish?.({ code: null }); + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("dashboard reports quiet long-running steps without resetting the idle timeout", async () => { const repository = createRepository(); let resolveCompletion; @@ -3232,6 +3745,99 @@ test("terminal chat redraws the prompt while job logs stream", async () => { assert.ok(output.match(/fixlab> /g)?.length >= 2); }); +test("terminal chat batches a poll into one redraw and removes runtime screen controls", async () => { + const input = new PassThrough(); + input.isTTY = true; + let output = ""; + const outputStream = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + } + }); + outputStream.isTTY = true; + let reads = 0; + const fetchImpl = async () => { + reads += 1; + return new Response(JSON.stringify({ + job: { + id: "batched-job", + status: "running", + request: "Batch output", + stages: {}, + logs: reads === 1 ? [] : [ + ...Array.from({ length: 50 }, (_, index) => ({ + index, + stream: "stdout", + message: `\u001b[2J\u001b[Hprogress-${index}` + })), + { index: 50, stream: "stdout", message: "\u001b[2J\r" } + ] + } + }), { headers: { "Content-Type": "application/json" } }); + }; + const chat = runChat({ + baseUrl: "http://127.0.0.1:4317", + input, + output: outputStream, + errorOutput: outputStream, + fetchImpl, + pollIntervalMs: 5 + }); + for (let attempt = 0; attempt < 100 && reads < 6; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const streamed = output; + input.end("/exit\n"); + assert.equal(await chat, 0); + assert.match(streamed, /progress-49/); + assert.doesNotMatch(streamed, /\u001b\[2J|\u001b\[H/); + assert.equal(streamed.match(/fixlab> /g)?.length, 2); +}); + +test("terminal chat reports an unchanged polling failure once and announces recovery", async () => { + const input = new PassThrough(); + input.isTTY = true; + let output = ""; + const outputStream = new Writable({ + write(chunk, _encoding, callback) { + output += chunk.toString(); + callback(); + } + }); + outputStream.isTTY = true; + let reads = 0; + let disconnected = true; + const fetchImpl = async () => { + reads += 1; + if (reads > 1 && disconnected) { + throw new Error("connection unavailable"); + } + return new Response(JSON.stringify({ job: null }), { + headers: { "Content-Type": "application/json" } + }); + }; + const chat = runChat({ + baseUrl: "http://127.0.0.1:4317", + input, + output: outputStream, + errorOutput: outputStream, + fetchImpl, + pollIntervalMs: 5 + }); + for (let attempt = 0; attempt < 100 && reads < 6; attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + assert.equal(output.match(/Dashboard polling failed/g)?.length, 1); + disconnected = false; + for (let attempt = 0; attempt < 100 && !output.includes("connection restored"); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + input.end("/exit\n"); + assert.equal(await chat, 0); + assert.equal(output.match(/Dashboard connection restored/g)?.length, 1); +}); + test("terminal chat buffers streamed output while the user is typing", async () => { let statusReads = 0; let submittedComments = 0; @@ -3436,7 +4042,8 @@ test("terminal chat opens the dashboard from natural language", async () => { assert.match(output, /Opened FixLab dashboard/); }); -test("terminal chat creates a separate bug job from natural language", async () => { +for (const model of ["", "hydrafusion"]) { +test(`terminal chat creates a separate bug job with ${model || "default model"} from natural language`, async () => { const requests = []; const input = Readable.from([ "new bug Product search returns duplicate rows\n", @@ -3492,6 +4099,7 @@ test("terminal chat creates a separate bug job from natural language", async () assert.equal(await runChat({ baseUrl: "http://127.0.0.1:4317", + model, input, output: outputStream, errorOutput: outputStream, @@ -3502,6 +4110,7 @@ test("terminal chat creates a separate bug job from natural language", async () const submitted = requests.find(({ url }) => url.endsWith("/api/jobs")); assert.deepEqual(JSON.parse(submitted.options.body), { request: "Product search returns duplicate rows", + ...(model ? { model } : {}), mode: "fix-and-validate", requestType: "bug-fix", pullRequestStrategy: "common", @@ -3511,6 +4120,7 @@ test("terminal chat creates a separate bug job from natural language", async () }); assert.match(output, /New bug queued as job new-bug-job/); }); +} test("terminal chat cancels a waiting job by queue position", async () => { const requests = []; @@ -3609,6 +4219,137 @@ test("dashboard shutdown terminates only its active executor handle", async () = } }); +test("shutdown completes frontend and HTTP cleanup even when executor termination throws", async () => { + const repository = createRepository(); + const profilePath = join(repository, ".github", "fixlab", "repository-profile.json"); + const profile = validProfile(); + const port = await availablePort(); + profile.applications.frontend = { + workingDirectory: ".", + command: 'node -e "setInterval(() => {}, 1000)"', + port, + healthUrl: `http://127.0.0.1:${port}` + }; + writeFileSync(profilePath, JSON.stringify(profile)); + let finish; + const { dashboard, url } = await startDashboard(repository, () => ({ + completion: new Promise((resolve) => { finish = resolve; }), + terminate() { throw new Error("Synthetic executor termination error"); } + })); + try { + const start = await jsonRequest(url, "/api/frontend/start", { method: "POST" }); + assert.equal(start.response.status, 202); + const session = await jsonRequest(url, "/api/session"); + const pid = session.body.frontend.pid; + await jsonRequest(url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request: "Exercise settled shutdown.", mode: "validate-only" }) + }); + await assert.rejects(dashboard.close(), (error) => + error instanceof AggregateError && + error.errors.some((failure) => /Synthetic executor termination error/.test(failure.message))); + await assert.rejects(fetch(`${url}/api/status`)); + assert.throws(() => process.kill(pid, 0), { code: "ESRCH" }); + finish({ code: null }); + await new Promise((resolve) => setTimeout(resolve, 0)); + } finally { + finish?.({ code: null }); + rmSync(repository, { recursive: true, force: true }); + } +}); + +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 }) => { @@ -3775,6 +4516,25 @@ test("dashboard cache hits and invalidates on profile or HEAD changes", async () } }); +test("dashboard PR strategies publish after local gates without waiting for remote CI", () => { + for (const pullRequestStrategy of ["per-bug", "common"]) { + const prompt = buildJobPrompt({ + request: "Fix the reported behavior.", + mode: "fix-and-validate", + requestType: "bug-fix", + pullRequestStrategy + }); + assert.match(prompt, /required local validation and approval gates/); + assert.match(prompt, /publish the authorized PR by marking it ready for review/); + assert.match(prompt, /Do not wait for remote PR CI or build completion before publication/); + assert.match(prompt, /Verify the PR is no longer draft before reporting the PR stage passed/); + assert.match(prompt, /Report remote checks as pending, not passed, and leave merge policies intact/); + assert.match(prompt, /If publication or state verification fails, report the PR stage blocked or failed/); + assert.match(prompt, /gh pr ready /); + assert.match(prompt, /az repos pr update --id --draft false/); + } +}); + test("validate-only prompt prohibits repository changes", () => { const prompt = buildJobPrompt({ request: "Check the current behavior.", @@ -3789,6 +4549,9 @@ test("validate-only prompt prohibits repository changes", () => { assert.match(prompt, /fix and pr stages must be explicitly skipped/); assert.match(prompt, /FIXLAB_ACTIVITY\|stage\|summary/); assert.match(prompt, /not hidden chain-of-thought/); + assert.match(prompt, /Do not publish a pull request or change its draft status in read-only mode/); + assert.doesNotMatch(prompt, /publish the authorized PR by marking it ready for review/); + assert.doesNotMatch(prompt, /gh pr ready|az repos pr update/); }); test("selected non-production data falls back to synthetic only when required", () => { @@ -3884,6 +4647,8 @@ test("playwright-only prompt skips review and runs only required browser steps", }); assert.match(prompt, /Run a FixLab playwright-only job/); + assert.match(prompt, /^FixLab output protocol/); + assert.match(prompt, /Never defer or batch stage markers/); assert.match(prompt, /minimum setup required by the repository profile/); assert.match(prompt, /verify browser authentication/); assert.match(prompt, /focused Playwright journey/); 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..b47021e 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -7,11 +7,14 @@ import { mkdtempSync, mkdirSync, readFileSync, + realpathSync, rmSync, + symlinkSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { createServer } from "node:net"; import test from "node:test"; import { fileURLToPath } from "node:url"; import { @@ -22,12 +25,13 @@ import { const cli = fileURLToPath(new URL("../bin/fixlab.js", import.meta.url)); -function run(args, cwd, environment = {}, input = undefined) { +function run(args, cwd, environment = {}, input = undefined, timeout = undefined) { return spawnSync(process.execPath, [cli, ...args], { cwd, encoding: "utf8", env: { ...process.env, ...environment }, - input + input, + timeout }); } @@ -77,6 +81,55 @@ test("help lists supported commands", () => { assert.match(result.stdout, /docs\/troubleshooting\.md/); }); +test("CLI preserves model selection without interpreting request text as flags", () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-model-")); + const executableDirectory = join(repository, "bin"); + try { + assert.equal(run(["init", repository], repository).status, 0); + mkdirSync(executableDirectory); + const executable = join(executableDirectory, process.platform === "win32" ? "copilot.cmd" : "copilot"); + writeFileSync(executable, process.platform === "win32" + ? "@echo off\r\necho ARGS:%*\r\nmore\r\n" + : "#!/bin/sh\nprintf 'ARGS:%s\\n' \"$*\"\ncat\n"); + if (process.platform !== "win32") { + chmodSync(executable, 0o755); + } + const environment = { + PATH: `${executableDirectory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}`, + FIXLAB_RUNTIME: "copilot", + FIXLAB_MODEL: "" + }; + for (const [options, model] of [ + [["--model", "HydraFusion"], "hydrafusion"], + [["--model=claude-sonnet-4.5"], "claude-sonnet-4.5"], + [[], "hydrafusion"] + ]) { + const result = run( + ["run", repository, ...options, "--", "Explain", "--model", "in", "request", "text"], + repository, + { ...environment, ...(options.length ? {} : { FIXLAB_MODEL: "hydrafusion" }) } + ); + assert.equal(result.status, 0, result.stderr); + assert.match(result.stdout, new RegExp(`--model ${model.replaceAll(".", "\\.")}`)); + assert.equal(result.stdout.includes("--experimental"), model === "hydrafusion"); + assert.match(result.stdout, /Explain --model in request text/); + } + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("CLI rejects unavailable runtime model selection and unsafe model identifiers", () => { + for (const [runtime, model, message] of [ + ["agency", "hydrafusion", /copilot runtime/], + ["copilot", "hydrafusion&whoami", /model must be/] + ]) { + const result = run(["run", "--runtime", runtime, "--model", model, "--", "Validate"], process.cwd()); + assert.equal(result.status, 1); + assert.match(result.stderr, message); + } +}); + test("onboard creates a new profile and stops before placeholder setup", () => { const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-")); @@ -159,6 +212,100 @@ test("onboard plans existing repository setup without making changes", () => { } }); +test("onboard provisions a worktree only with approval, grants exact dependency paths and stops on restore failure", () => { + const root = mkdtempSync(join(tmpdir(), "fixlab-cli-worktree-")); + const source = join(root, "source"); + const repository = join(root, "worktree"); + try { + mkdirSync(join(source, "frontend"), { recursive: true }); + const git = (...args) => { + const result = spawnSync("git", ["-C", source, ...args], { encoding: "utf8", windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + }; + git("init", "--quiet"); + writeFileSync(join(source, "frontend", "prepare.js"), + 'require("node:fs").writeFileSync("prepared.txt", "attempted"); process.exit(3);'); + git("add", "frontend/prepare.js"); + git("-c", "user.name=Fixture", "-c", "user.email=fixture@example.test", "commit", "--quiet", "-m", "fixture"); + git("worktree", "add", "--quiet", "--detach", repository); + assert.equal(run(["init", source], source).status, 0); + const sourceProfile = join(source, ".github", "fixlab", "repository-profile.json"); + const profile = JSON.parse(readFileSync(sourceProfile, "utf8")); + profile.validation.commands.frontendRestore = "node prepare.js"; + profile.validation.commands.backendRestore = ""; + profile.browserAutomation.testCommand = "playwright test --config=playwright.local.ts"; + profile.worktreeSetup = { files: ["frontend/playwright.local.ts"] }; + writeFileSync(sourceProfile, JSON.stringify(profile)); + writeFileSync(join(source, "frontend", "playwright.local.ts"), "export default {};"); + const dependencies = join(root, "dependencies", "node_modules"); + const playwright = join(dependencies, "@playwright", "test"); + mkdirSync(playwright, { recursive: true }); + writeFileSync(join(playwright, "index.js"), + "exports.chromium = { launch: async () => ({ close: async () => {} }) };"); + symlinkSync(dependencies, join(repository, "frontend", "node_modules"), "junction"); + const environment = createPackageManagerShims(source); + const args = ["onboard", repository, "--from", source, "--runtime", "copilot"]; + const plan = run(args, source, environment); + assert.equal(plan.status, 0, plan.stderr); + assert.match(plan.stdout, /No files or commands changed/); + const targetProfile = join(repository, ".github", "fixlab", "repository-profile.json"); + assert.equal(existsSync(targetProfile), false); + assert.equal(existsSync(join(repository, "frontend", "prepared.txt")), false); + const approved = run([...args, "--yes"], source, environment); + assert.notEqual(approved.status, 0); + assert.match(approved.stdout, /Approved only the listed linked dependency directories/); + assert.match(approved.stderr, /frontend restore failed/); + assert.doesNotMatch(approved.stdout, /FixLab onboarding is ready/); + assert.deepEqual(JSON.parse(readFileSync(targetProfile)).trustedDependencyRoots, [realpathSync(dependencies)]); + assert.equal(readFileSync(join(repository, "frontend", "playwright.local.ts"), "utf8"), "export default {};"); + assert.equal(readFileSync(join(repository, "frontend", "prepared.txt"), "utf8"), "attempted"); + assert.equal(readFileSync(sourceProfile, "utf8"), JSON.stringify(profile)); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("chat opens explicit interactive setup for an incomplete profile and does not start after setup failure", async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-chat-setup-")); + const probe = createServer(); + await new Promise((resolvePort, reject) => { + probe.once("error", reject); + probe.listen(0, "127.0.0.1", resolvePort); + }); + const port = probe.address().port; + await new Promise((resolvePort, reject) => probe.close((error) => error ? reject(error) : resolvePort())); + try { + assert.equal(run(["init", repository], repository).status, 0); + const profilePath = join(repository, ".github", "fixlab", "repository-profile.json"); + const profile = JSON.parse(readFileSync(profilePath, "utf8")); + delete profile.browserAutomation; + writeFileSync(profilePath, JSON.stringify(profile)); + const environment = createPackageManagerShims(repository); + const executable = join(repository, "bin", process.platform === "win32" ? "copilot.cmd" : "copilot"); + writeFileSync(executable, process.platform === "win32" + ? "@echo off\r\necho ARGS:%*\r\nexit /b 7\r\n" + : "#!/bin/sh\nprintf 'ARGS:%s\\n' \"$*\"\nexit 7\n"); + if (process.platform !== "win32") { + chmodSync(executable, 0o755); + } + const result = run(["chat", repository, "--runtime", "copilot", "--port", String(port)], repository, environment, undefined, 30000); + assert.equal(result.status, 1, result.stdout); + assert.match(result.stdout, /Opening the interactive FixLab onboarding session/); + assert.match(result.stdout, /--interactive/); + assert.match(result.stdout, /Set up this repository for FixLab/); + assert.match(result.stderr, /onboarding exited with code 7/); + assert.doesNotMatch(result.stdout, /FixLab dashboard started/); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("onboard validates source arguments before initialization", () => { + const result = run(["onboard", "--from", "--yes"], process.cwd()); + assert.equal(result.status, 1); + assert.match(result.stderr, /source checkout after --from/); +}); + test("Windows bootstrap keeps NVM and Node changes explicit", () => { const installer = readFileSync( fileURLToPath(new URL("../scripts/install-fixlab.ps1", import.meta.url)), @@ -790,6 +937,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, 0, model\);/ + ); + 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( @@ -952,8 +1117,19 @@ test("run launches the Agency-resolved FixLab agent", () => { assert.match(result.stdout, /FIXLAB_ACTIVITY/); assert.match(result.stdout, /FIXLAB_TEST/); assert.match(result.stdout, /transient validation artifacts/); + assert.match(result.stdout, /publish the authorized PR by marking it ready for review/); + assert.match(result.stdout, /Do not wait for remote PR CI or build completion before publication/); + assert.match(result.stdout, /Verify the PR is no longer draft before reporting the PR stage passed/); assert.match(result.stdout, /\*\.auth\.spec\.ts/); assert.match(result.stdout, /Request: repair the defect/); + const validation = run( + ["validate", repository, "--pr", "123", "--runtime", "agency"], + repository, + { PATH: `${executableDirectory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}` } + ); + assert.equal(validation.status, 0); + assert.match(validation.stdout, /Do not publish a pull request or change its draft status in read-only mode/); + assert.doesNotMatch(validation.stdout, /gh pr ready|az repos pr update/); } finally { rmSync(repository, { recursive: true, force: true }); } @@ -1077,9 +1253,20 @@ test("run launches the FixLab plugin directly through Copilot", () => { ); assert.match(result.stdout, /--autopilot/); assert.match(result.stdout, /transient validation artifacts/); + assert.match(result.stdout, /publish the authorized PR by marking it ready for review/); + assert.match(result.stdout, /Do not wait for remote PR CI or build completion before publication/); + assert.match(result.stdout, /Verify the PR is no longer draft before reporting the PR stage passed/); assert.match(result.stdout, /\*\.auth\.spec\.ts/); assert.match(result.stdout, /Request: repair the defect/); assert.doesNotMatch(result.stdout, /--interactive repair the defect/); + const validation = run( + ["validate", repository, "--pr", "123", "--runtime", "copilot"], + repository, + { PATH: `${executableDirectory}${process.platform === "win32" ? ";" : ":"}${process.env.PATH}` } + ); + assert.equal(validation.status, 0); + assert.match(validation.stdout, /Do not publish a pull request or change its draft status in read-only mode/); + assert.doesNotMatch(validation.stdout, /gh pr ready|az repos pr update/); } finally { rmSync(repository, { recursive: true, force: true }); } diff --git a/test/windows-executor.test.js b/test/windows-executor.test.js new file mode 100644 index 0000000..990714a --- /dev/null +++ b/test/windows-executor.test.js @@ -0,0 +1,433 @@ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createCopilotExecutor } from "../dashboard/server.js"; +import { createExecutorLease } from "../dashboard/executor-lease.js"; +import { quoteWindowsArgument, spawnWindowsExecutor } from "../dashboard/windows-executor.js"; + +const windowsOnly = { skip: process.platform !== "win32", timeout: 30000 }; + +async function observeTermination(pids) { + const script = ` + $ErrorActionPreference = 'Stop'; + $targets = @(${pids.map((pid) => `[Diagnostics.Process]::GetProcessById(${pid})`).join(";")}); + foreach ($target in $targets) { $null = $target.Handle }; + [Console]::WriteLine('OWNED_HANDLES_CAPTURED'); + foreach ($target in $targets) { + if (-not $target.WaitForExit(15000)) { throw 'Owned process did not terminate' } + $target.Dispose() + }; + [Console]::WriteLine('OWNED_HANDLES_EXITED') + `; + const observer = spawn(join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], + { stdio: ["ignore", "pipe", "pipe"], windowsHide: true }); + let output = ""; + let errors = ""; + let ready; + const captured = new Promise((resolve, reject) => { + ready = resolve; + observer.once("error", reject); + observer.once("exit", (code) => { + if (!output.includes("OWNED_HANDLES_CAPTURED")) { + reject(new Error(`Could not capture owned process handles (${code}): ${errors}`)); + } + }); + }); + observer.stdout.on("data", (chunk) => { + output += chunk.toString(); + if (output.includes("OWNED_HANDLES_CAPTURED")) { + ready(); + } + }); + observer.stderr.on("data", (chunk) => { errors += chunk.toString(); }); + const exited = new Promise((resolve) => { + observer.once("error", (error) => resolve({ code: null, error })); + observer.once("close", (code) => { + resolve({ code }); + }); + }); + await captured; + return { + get completion() { + return exited.then(({ code, error }) => { + if (error || code !== 0 || !output.includes("OWNED_HANDLES_EXITED")) { + throw error ?? new Error(`Owned process handles did not signal exit: ${errors}`); + } + }); + }, + stop() { observer.kill(); } + }; +} + +function start(args, repository = process.cwd(), input = "", options = {}) { + const handle = spawnWindowsExecutor({ executable: process.execPath, args, repository, ...options }); + let stdout = ""; + let stderr = ""; + handle.child.stdout.on("data", (chunk) => { stdout += chunk.toString(); }); + handle.child.stderr.on("data", (chunk) => { stderr += chunk.toString(); }); + handle.child.stdin.on("error", (error) => { stderr += `Could not send test input: ${error.message}\n`; }); + handle.child.stdin.end(input); + const completion = new Promise((resolve, reject) => { + handle.child.once("error", reject); + handle.child.once("close", (code) => resolve({ code, stdout, stderr })); + }); + return { ...handle, completion }; +} + +async function waitForRecord(path) { + const deadline = Date.now() + 15000; + while (Date.now() < deadline) { + try { + return JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + if (error.code !== "ENOENT" && !(error instanceof SyntaxError)) { + throw error; + } + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error("Owned Windows test processes did not become ready"); +} + +function treeScript(record, exitRoot) { + const childScript = ` + process.send({ pid: process.pid }); + setInterval(() => {}, 1000); + `; + return ` + const { spawn } = require("node:child_process"); + const { writeFileSync } = require("node:fs"); + const child = spawn(process.execPath, ["-e", ${JSON.stringify(childScript)}], { + detached: true, + windowsHide: true, + stdio: ["ignore", "ignore", "ignore", "ipc"] + }); + child.once("message", (message) => { + writeFileSync(${JSON.stringify(record)}, JSON.stringify({ root: process.pid, descendant: message.pid })); + ${exitRoot ? "process.exit(0);" : ""} + }); + setInterval(() => {}, 1000); + `; +} + +test("Windows argument quoting preserves quotes and trailing backslashes", () => { + assert.equal(quoteWindowsArgument(""), '""'); + assert.equal(quoteWindowsArgument('a"b'), '"a\\"b"'); + assert.equal(quoteWindowsArgument("C:\\path with space\\"), '"C:\\path with space\\\\"'); +}); + +test("Windows executor gives tooling descendants one inherited hidden console", windowsOnly, async () => { + const probe = ` + $ErrorActionPreference = 'Stop'; + $ProgressPreference = 'SilentlyContinue'; + Add-Type -TypeDefinition @' +using System; +using System.Runtime.InteropServices; +public static class ConsoleProbe { + [DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow(); + [DllImport("kernel32.dll")] public static extern uint GetConsoleProcessList(uint[] processes, uint count); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr window); +} +'@ + $window = [ConsoleProbe]::GetConsoleWindow(); + $processes = New-Object 'System.UInt32[]' 16; + $count = [ConsoleProbe]::GetConsoleProcessList($processes, $processes.Length); + if ($count -eq 0 -or $count -gt $processes.Length) { throw 'Could not inspect the inherited console' }; + [Console]::WriteLine((@{ + window = $window.ToInt64(); + visible = [ConsoleProbe]::IsWindowVisible($window); + processes = @($processes[0..($count - 1)]); + input = [Console]::In.ReadToEnd() + } | ConvertTo-Json -Compress)); + [Console]::Error.WriteLine('TOOLING_STDERR'); + `; + const source = ` + const { spawnSync } = require("node:child_process"); + const child = spawnSync(${JSON.stringify(join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"))}, + ["-NoLogo", "-NoProfile", "-NonInteractive", "-InputFormat", "Text", "-OutputFormat", "Text", "-EncodedCommand", + ${JSON.stringify(Buffer.from(probe, "utf16le").toString("base64"))}], { + encoding: "utf8", input: "TOOLING_STDIN", timeout: 15000 + }); + if (child.error) throw child.error; + if (child.status !== 0 || !child.stdout) { + throw new Error("Console probe failed (" + child.status + "): " + child.stderr); + } + const record = JSON.parse(child.stdout); + record.rootPid = process.pid; + process.stdout.write(JSON.stringify(record)); + process.stderr.write(child.stderr); + process.exitCode = child.status ?? 1; + `; + const handle = start(["-e", source]); + try { + const result = await handle.completion; + assert.equal(result.code, 0, result.stderr); + const record = JSON.parse(result.stdout); + assert.ok(record.window > 0, "Tooling must inherit a console rather than allocate its own"); + assert.equal(record.visible, false, "The inherited console must stay hidden"); + assert.ok(record.processes.includes(record.rootPid), "The executor and tooling must share their console"); + assert.equal(record.input, "TOOLING_STDIN"); + assert.match(result.stderr, /TOOLING_STDERR/); + assert.equal(handle.hasStopped(), true); + } finally { + handle.terminate(); + await handle.completion; + rmSync(handle.directory, { recursive: true, force: true }); + } +}); + +test("Windows suspended launch preserves argv, stdin, stdout and child exit code", windowsOnly, async () => { + const args = ["", 'a"b', "C:\\path with space\\", "literal & | < > % !"]; + const handle = start([ + "-e", + 'let input=""; process.stdin.on("data", data => input += data); process.stdin.on("end", () => { console.log(JSON.stringify({args:process.argv.slice(1),input})); process.exitCode=7; });', + "--", ...args + ], process.cwd(), "FIXLAB_STAGE|intake|running|A prompt with quotes \" and Unicode \u00e9.\n"); + try { + const result = await handle.completion; + assert.equal(result.code, 7, result.stderr); + assert.deepEqual(JSON.parse(result.stdout), { + args, + input: "FIXLAB_STAGE|intake|running|A prompt with quotes \" and Unicode \u00e9.\n" + }); + assert.equal(handle.hasStopped(), true); + assert.equal(handle.hasStopped(), true); + } finally { + handle.terminate(); + await handle.completion; + rmSync(handle.directory, { recursive: true, force: true }); + } +}); + +for (const exitRoot of [false, true]) { + test(`Windows job containment stops detached descendants after ${exitRoot ? "root exit" : "explicit stop"}`, windowsOnly, async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-windows-tree-test-")); + const record = join(repository, "owned-processes.json"); + const handle = start(["-e", treeScript(record, exitRoot)], repository); + let observer; + try { + const processes = await waitForRecord(record); + if (!exitRoot) { + observer = await observeTermination([processes.root, processes.descendant, handle.child.pid]); + assert.equal(handle.hasStopped(), false); + handle.terminate(); + } + const result = await handle.completion; + assert.equal(result.code, exitRoot ? 0 : 130, result.stderr); + assert.equal(handle.hasStopped(), true); + if (observer) { + await observer.completion; + } + } finally { + handle.terminate(); + await handle.completion; + observer?.stop(); + rmSync(handle.directory, { recursive: true, force: true }); + rmSync(repository, { recursive: true, force: true }); + } + }); +} + +test("Windows wrapper crash kills owned descendants but cannot supply release proof", windowsOnly, async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-windows-crash-test-")); + const handle = start(["-e", treeScript(join(repository, "owned-processes.json"), false)], repository); + let observer; + try { + const processes = await waitForRecord(join(repository, "owned-processes.json")); + observer = await observeTermination([processes.root, processes.descendant, handle.child.pid]); + handle.child.kill(); + await handle.completion; + assert.equal(handle.hasStopped(), false); + await observer.completion; + } finally { + handle.terminate(); + await handle.completion; + observer?.stop(); + rmSync(handle.directory, { recursive: true, force: true }); + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("Windows dashboard-owned frontend survives launcher exit until confirmed stop", windowsOnly, async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-windows-retained-test-")); + const record = join(repository, "owned-processes.json"); + const handle = start(["-e", treeScript(record, true)], repository, "", { retainDescendants: true }); + let observer; + try { + const processes = await waitForRecord(record); + observer = await observeTermination([processes.descendant, handle.child.pid]); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.equal(handle.hasStopped(), false); + handle.terminate(); + await handle.waitForStopped(); + assert.equal((await handle.completion).code, 130); + await observer.completion; + } finally { + handle.terminate(); + await handle.completion; + observer?.stop(); + rmSync(handle.directory, { recursive: true, force: true }); + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("Windows owner crash terminates its contained processes without reclaiming ownership", windowsOnly, async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-windows-owner-test-")); + const record = join(repository, "owned-processes.json"); + const source = ` + import { spawnWindowsExecutor } from ${JSON.stringify(new URL("../dashboard/windows-executor.js", import.meta.url).href)}; + import { createExecutorLease } from ${JSON.stringify(new URL("../dashboard/executor-lease.js", import.meta.url).href)}; + const lease = createExecutorLease(${JSON.stringify(repository)}, "crashed-owner").acquire("owned-job"); + const handle = spawnWindowsExecutor({ + executable: process.execPath, + args: ["-e", ${JSON.stringify(treeScript(record, false))}], + repository: ${JSON.stringify(repository)} + }); + handle.child.stdin.end(); + lease.update("running", handle.child.pid); + handle.child.stdout.resume(); + handle.child.stderr.resume(); + process.send({ pid: handle.child.pid, directory: handle.directory }); + setInterval(() => {}, 1000); + `; + const owner = spawn(process.execPath, ["--input-type=module", "-e", source], { + stdio: ["ignore", "ignore", "ignore", "ipc"], + windowsHide: true + }); + const ownerExit = new Promise((resolve) => owner.once("exit", resolve)); + let supervisor; + let observer; + try { + supervisor = await new Promise((resolve, reject) => { + owner.once("message", resolve); + owner.once("error", reject); + }); + const processes = await waitForRecord(record); + observer = await observeTermination([processes.root, processes.descendant, supervisor.pid, owner.pid]); + owner.kill(); + await ownerExit; + await observer.completion; + assert.throws(() => createExecutorLease(repository, "peer").acquire("unsafe-reclaim"), /reserved/); + } finally { + owner.kill(); + await ownerExit; + observer?.stop(); + if (supervisor) { + rmSync(supervisor.directory, { recursive: true, force: true }); + } + rmSync(createExecutorLease(repository, "cleanup").scope.directory, { recursive: true, force: true }); + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("Windows containment supports the repository-owned Microsoft Edge browser", { ...windowsOnly, timeout: 60000 }, async () => { + const profile = mkdtempSync(join(tmpdir(), "fixlab-contained-edge-test-")); + const handle = start(["-e", ` + const { chromium } = require("@playwright/test"); + const deadline = setTimeout(() => { + console.error("Contained Edge lifecycle exceeded its 30-second browser budget."); + process.exit(1); + }, 30000); + (async () => { + console.log("EDGE_LAUNCH_START"); + const context = await chromium.launchPersistentContext(${JSON.stringify(profile)}, { + channel: "msedge", headless: true, timeout: 20000 + }); + console.log("EDGE_LAUNCH_READY"); + const page = context.pages()[0] || await context.newPage(); + await page.setContent("

Owned browser evidence

"); + console.log(await page.locator("h1").textContent()); + console.log("EDGE_CLOSE_START"); + await context.close(); + console.log("EDGE_CLOSE_DONE"); + clearTimeout(deadline); + })().catch(error => { console.error(error); process.exit(1); }); + `], process.cwd(), "", { environment: { DEBUG: "pw:browser" } }); + try { + const result = await handle.completion; + assert.equal(result.code, 0, `${result.stdout}\n${result.stderr}`); + assert.match(result.stdout, /Owned browser evidence/); + assert.equal(handle.hasStopped(), true); + } finally { + handle.terminate(); + await handle.completion; + rmSync(handle.directory, { recursive: true, force: true }); + rmSync(profile, { recursive: true, force: true }); + } +}); + +test("real Windows Copilot executor retains its lease until the contained tree exits", windowsOnly, async () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-windows-lease-test-")); + const bin = join(repository, "bin"); + mkdirSync(bin); + const executable = join(bin, "copilot.exe"); + const pidPath = join(repository, "descendant.txt"); + const childArgs = ["-e", "setInterval(() => {}, 1000)"].map(quoteWindowsArgument).join(" "); + const source = ` + using System; + using System.Diagnostics; + using System.IO; + using System.Threading; + public class FakeCopilot { + public static void Main() { + Console.In.ReadToEnd(); + var start = new ProcessStartInfo(${JSON.stringify(process.execPath)}, ${JSON.stringify(childArgs)}); + start.UseShellExecute = false; + var child = Process.Start(start); + File.WriteAllText(${JSON.stringify(pidPath)}, child.Id.ToString()); + Console.WriteLine("FIXLAB_STAGE|intake|blocked|Synthetic prerequisite."); + Thread.Sleep(150); + } + } + `; + const encoded = Buffer.from(source).toString("base64"); + const script = `$source = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')); Add-Type -TypeDefinition $source -OutputAssembly '${executable.replaceAll("'", "''")}' -OutputType ConsoleApplication`; + const compiled = spawnSync(join(process.env.SystemRoot, "System32", "WindowsPowerShell", "v1.0", "powershell.exe"), + ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], + { encoding: "utf8", timeout: 15000, windowsHide: true }); + const originalPath = process.env.PATH; + let handle; + let lease; + try { + assert.equal(compiled.status, 0, compiled.stderr); + process.env.PATH = `${bin};${originalPath}`; + const peer = createExecutorLease(repository, "peer"); + lease = createExecutorLease(repository, "owner").acquire("native-job"); + const executor = createCopilotExecutor({ packageRoot: fileURLToPath(new URL("..", import.meta.url)) }); + let output = ""; + handle = executor({ + repository, + prompt: "Run the synthetic lease fixture.", + sessionId: "11111111-1111-4111-8111-111111111111", + model: "hydrafusion", + onOutput(_stream, text) { output += text; } + }); + lease.update("running", handle.pid); + assert.throws(() => peer.acquire("overlap"), /reserved/); + const result = await handle.completion; + assert.equal(result.code, 0, output); + assert.ok(Number(readFileSync(pidPath, "utf8")) > 0); + assert.equal(handle.hasStopped(), true); + lease.release(); + lease = null; + peer.acquire("successor").release(); + assert.match(output, /FIXLAB_STAGE\|intake\|blocked/); + } finally { + process.env.PATH = originalPath; + handle?.terminate(); + if (handle) { + await handle.completion; + if (handle.hasStopped()) { + lease?.release(); + } + } else { + lease?.release(); + } + rmSync(repository, { recursive: true, force: true }); + } +});