From 37187db6f75cce9e4033dc605efa1300748a546e Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Wed, 14 Oct 2026 17:28:40 -0400 Subject: [PATCH] Add safe sequential shared-worktree reuse Refresh the configured base for new jobs and persist saved branch ownership for resume. Gate acquisition on clean state and confirmed process ownership without resetting or copying browser state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 22c892d2-e1f2-436b-8bf5-5bbc49ddcf7d --- bin/fixlab.js | 6 + dashboard/public/app.js | 2 +- dashboard/server.js | 63 ++++++++- dashboard/shared-worktree.js | 148 ++++++++++++++++++++ docs/architecture.md | 10 ++ docs/repository-onboarding.md | 60 ++++++++ test/dashboard-server.test.js | 113 +++++++++++++++ test/dashboard-shared-worktree.test.js | 186 +++++++++++++++++++++++++ test/fixlab-cli.test.js | 16 +++ 9 files changed, 597 insertions(+), 7 deletions(-) create mode 100644 dashboard/shared-worktree.js create mode 100644 test/dashboard-shared-worktree.test.js diff --git a/bin/fixlab.js b/bin/fixlab.js index 2ec425d..ef6a53a 100644 --- a/bin/fixlab.js +++ b/bin/fixlab.js @@ -950,6 +950,12 @@ function launch(repository, request, runtime, targetEnvironment = "", model = "" ); return 1; } + if (profile.worktreeSetup?.mode === "shared" && !readOnly) { + console.error( + "Shared worktree jobs require dashboard-owned branch acquisition and resumption. Use fixlab dashboard or fixlab chat for this repository." + ); + return 1; + } let selectedEnvironment; try { diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 7c5012e..5ab9616 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -326,7 +326,7 @@ function renderJob(job, currentActiveJob = job) { jobStatusElement.textContent = job ? `${job.status} · ${job.requestType}${job.model ? ` · ${job.model}` : ""} · ${ job.targetEnvironment || "profile default" - } · ${formatDuration(job.durationMs)}${ + }${job.sharedWorkspace ? ` · shared: ${job.sharedWorkspace.branch}` : ""} · ${formatDuration(job.durationMs)}${ lastActivityAge === null ? "" : ` · last output ${formatDuration(lastActivityAge)} ago` diff --git a/dashboard/server.js b/dashboard/server.js index 78f3468..fcd9960 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -39,6 +39,7 @@ import { createSessionRegistry } from "./sessions.js"; import { createExecutorLease } from "./executor-lease.js"; import { spawnWindowsExecutor } from "./windows-executor.js"; import { browserConfigurationErrors } from "./repository-setup.js"; +import { prepareSharedWorktree, sharedWorktreeConfig, sharedWorktreeGuidance, verifySharedWorktreeBranch } from "./shared-worktree.js"; const windowsOwnedTrees = new WeakMap(); export const DASHBOARD_HOST = "127.0.0.1"; @@ -399,6 +400,7 @@ function publicQueue(jobs) { pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + sharedWorkspace: job.sharedWorkspace ?? null, prestartFrontend: job.prestartFrontend ?? false, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, @@ -465,6 +467,7 @@ function dashboardSnapshot(job) { scenario: safePersistedSummary(job.testEvidence.scenario) }, branchNaming: job.branchNaming ?? null, + sharedWorkspace: job.sharedWorkspace ?? null, intakeSource: job.intakeSource, workItem: persistedWorkItem(job.workItem), workItems: job.workItems.map(persistedWorkItem), @@ -733,6 +736,7 @@ export function inspectRepository(repository) { throw new Error("profile root must be a JSON object"); } const liveTestProfile = validateLiveTestProfile(profile, resolvedRepository); + sharedWorktreeConfig(profile, resolvedRepository); if (!liveTestProfile.ok) { return { repository: resolvedRepository, @@ -1769,6 +1773,7 @@ function publicJob(job) { pullRequestStrategy: job.pullRequestStrategy, runAllUiScenarios: job.runAllUiScenarios, targetEnvironment: job.targetEnvironment, + sharedWorkspace: job.sharedWorkspace ?? null, recordPlaywrightVideo: job.recordPlaywrightVideo, manualLocalhostTest: job.manualLocalhostTest, manualLocalhostUrl: job.manualLocalhostUrl, @@ -2948,6 +2953,7 @@ export function createDashboardServer({ createdAt, startedAt: status === "running" ? createdAt : null, heldAt: null, + sharedWorkspace: null, finishedAt: null, lastActivityAt: status === "running" ? createdAt : null, error: null, @@ -3017,6 +3023,35 @@ export function createDashboardServer({ let leaseMetadataError = null; let handle; const lease = executorLease.acquire(job.id); + try { + if (job.mode === "fix-and-validate") { + job.sharedWorkspace = prepareSharedWorktree({ + repository: resolvedRepository, + profile: loadRepositoryProfile(resolvedRepository), + jobId: job.id, + saved: job.sharedWorkspace, + resume, + resourcesRunning: Boolean(frontendConnection.handle) || + frontendConnection.starting || Boolean(playwrightConnection.handle) + }); + if (job.sharedWorkspace) { + persistDashboardState(); + const readiness = inspectRepository(resolvedRepository); + if (!readiness.profileReady) { + throw new Error(`Shared checkout configuration is not ready after branch acquisition: ${readiness.error}`); + } + pushLog(job, { + index: job.nextLogIndex, + timestamp: new Date().toISOString(), + stream: "dashboard", + message: `Acquired shared checkout on ${job.sharedWorkspace.branch}; base ${job.sharedWorkspace.remote}/${job.sharedWorkspace.baseBranch} at ${job.sharedWorkspace.baseCommit}.` + }); + } + } + } catch (error) { + lease.release(); + throw error; + } const idleCompletion = new Promise((resolveCompletion) => { resolveIdleCompletion = resolveCompletion; }); @@ -3089,9 +3124,9 @@ export function createDashboardServer({ handle = executor({ repository: resolvedRepository, artifactDirectories: job.artifactDirectory ? [job.artifactDirectory] : [], - prompt: job.prestartFrontend && job.targetEnvironment.toLowerCase() === "local" + 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, + : prompt) + sharedWorktreeGuidance(job.sharedWorkspace), sessionId: job.id, resume, model: job.model, @@ -3150,8 +3185,16 @@ export function createDashboardServer({ if (handle.hasStopped && !handle.hasStopped()) { throw new Error("Executor exit was observed but owned descendants may still be running; the repository lease remains reserved."); } + let branchError = null; + try { + verifySharedWorktreeBranch(resolvedRepository, job.sharedWorkspace); + } catch (error) { + branchError = error; + } lease.release(); - return leaseMetadataError ? { code: null, error: leaseMetadataError } : result; + return leaseMetadataError || branchError + ? { code: null, error: leaseMetadataError ?? branchError } + : result; }); activeTermination = actualCompletion; try { @@ -3300,7 +3343,11 @@ export function createDashboardServer({ try { const owners = await sessions.list(); const conflict = owners.find((owner) => - owner.id !== sessions.id && owner.available && owner.job?.running + owner.id !== sessions.id && owner.available && ( + owner.job?.running || + (loadRepositoryProfile(resolvedRepository).worktreeSetup?.mode === "shared" && + (owner.frontend?.running || owner.authentication?.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.`); @@ -3460,13 +3507,17 @@ export function createDashboardServer({ if ( request.method === "POST" && - ["/api/jobs", "/api/job/input", "/api/frontend/start"].includes(requestUrl.pathname) + ["/api/jobs", "/api/job/input", "/api/jobs/resume", "/api/jobs/restart", "/api/frontend/start"].includes(requestUrl.pathname) ) { try { const resource = requestUrl.pathname === "/api/frontend/start" ? "frontend" : "job"; const owners = await sessions.list(); + const shared = loadRepositoryProfile(resolvedRepository).worktreeSetup?.mode === "shared"; const conflict = owners.find((owner) => - owner.id !== sessions.id && owner.available && owner[resource]?.running + owner.id !== sessions.id && owner.available && ( + owner[resource]?.running || + (shared && resource === "job" && (owner.frontend?.running || owner.authentication?.running)) + ) ); if (conflict) { sendJson(response, 409, { diff --git a/dashboard/shared-worktree.js b/dashboard/shared-worktree.js new file mode 100644 index 0000000..bc4d06c --- /dev/null +++ b/dashboard/shared-worktree.js @@ -0,0 +1,148 @@ +import { spawnSync } from "node:child_process"; +import { existsSync, realpathSync } from "node:fs"; +import { isAbsolute, resolve } from "node:path"; + +function git(repository, args, description) { + const result = spawnSync("git", args, { + cwd: repository, + encoding: "utf8", + windowsHide: true, + timeout: 30000, + maxBuffer: 1024 * 1024, + env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GCM_INTERACTIVE: "never" } + }); + if (result.status !== 0 || result.error) { + throw new Error( + `${description} failed (${result.error?.code ?? `Git exit ${result.status}`}). ` + + "Resolve the Git prerequisite and retry; FixLab will not reset, stash, clean or force-switch this checkout." + ); + } + return result.stdout.trim(); +} + +function branchRef(repository, branch, description) { + if (typeof branch !== "string" || !branch || branch.startsWith("-")) { + throw new Error(`${description} must be a valid Git branch name`); + } + git(repository, ["check-ref-format", `refs/heads/${branch}`], description); + return branch; +} + +export function sharedWorktreeConfig(profile, repository) { + const mode = profile.worktreeSetup?.mode; + if (mode === undefined || mode === "isolated") { + return null; + } + if (mode !== "shared") { + throw new Error("worktreeSetup.mode must be shared or isolated"); + } + const root = realpathSync.native(repository); + const topLevel = realpathSync.native(git(root, ["rev-parse", "--show-toplevel"], "Shared worktree repository verification")); + if (root !== topLevel) { + throw new Error("Shared worktree mode requires the Git checkout root"); + } + const remote = profile.worktreeSetup.remote ?? "origin"; + if (typeof remote !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(remote)) { + throw new Error("worktreeSetup.remote must be a Git remote name"); + } + const baseBranch = branchRef( + root, + profile.worktreeSetup.baseBranch ?? profile.pullRequests?.defaultTargetBranch, + "worktreeSetup.baseBranch" + ); + const naming = profile.pullRequests?.branchNaming; + if ( + typeof naming?.userId !== "string" || + !/^[A-Za-z0-9._-]+$/u.test(naming.userId) || + typeof naming.prefixTemplate !== "string" || + !naming.prefixTemplate.includes("{userId}") + ) { + throw new Error("Shared worktree mode requires pullRequests.branchNaming.userId and prefixTemplate"); + } + const prefix = branchRef(root, naming.prefixTemplate.replaceAll("{userId}", naming.userId), "Shared branch prefix"); + return { remote, baseBranch, prefix }; +} + +export function prepareSharedWorktree({ repository, profile, jobId, saved = null, resume = false, resourcesRunning = false }) { + const config = sharedWorktreeConfig(profile, repository); + if (!config) { + if (saved) { + throw new Error("This job owns a shared worktree branch; restore shared mode before resuming it"); + } + return null; + } + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(jobId)) { + throw new Error("Shared worktree jobs require a valid job ID"); + } + const gitPath = (name) => { + const value = git(repository, ["rev-parse", "--git-path", name], "Git operation verification"); + return isAbsolute(value) ? value : resolve(repository, value); + }; + if (["MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply", "sequencer"].some((name) => existsSync(gitPath(name)))) { + throw new Error("Finish the in-progress Git operation before reusing the shared worktree"); + } + const currentBranch = git(repository, ["branch", "--show-current"], "Current branch verification"); + const dirty = git(repository, ["status", "--porcelain", "--untracked-files=all"], "Shared worktree cleanliness check"); + if (saved) { + const branch = branchRef(repository, saved.branch, "Saved job branch"); + if ( + saved.mode !== "shared" || + !branch.endsWith(`/fixlab-${jobId}`) || + !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/u.test(saved.baseCommit) || + typeof saved.remote !== "string" || + !/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(saved.remote) + ) { + throw new Error("Saved shared-worktree ownership is invalid; refusing to select a different branch"); + } + branchRef(repository, saved.baseBranch, "Saved base branch"); + git(repository, ["rev-parse", "--verify", `refs/heads/${branch}^{commit}`], "Saved branch verification"); + git(repository, ["merge-base", "--is-ancestor", saved.baseCommit, `refs/heads/${branch}`], "Saved branch ancestry verification"); + if (currentBranch !== branch) { + if (resourcesRunning) { + throw new Error("Stop the shared checkout's owned React and authentication sessions before reacquiring a different job branch"); + } + if (dirty) { + throw new Error("The shared worktree has uncommitted or untracked files; preserve them before reacquiring this job's branch"); + } + git(repository, ["switch", "--no-guess", "--no-overwrite-ignore", branch], "Saved branch reacquisition"); + } + return saved; + } + if (resume) { + throw new Error("This older job has no saved shared-worktree branch. Resume it with its original workspace policy; do not guess its branch"); + } + if (resourcesRunning) { + throw new Error("Stop the shared checkout's owned React and authentication sessions before starting another job branch"); + } + if (dirty) { + throw new Error("The shared worktree has uncommitted or untracked files; preserve them before starting another job"); + } + const branch = branchRef(repository, `${config.prefix}/fixlab-${jobId}`, "New job branch"); + git(repository, [ + "fetch", "--no-tags", config.remote, + `+refs/heads/${config.baseBranch}:refs/remotes/${config.remote}/${config.baseBranch}` + ], "Shared worktree base refresh"); + const baseCommit = git(repository, [ + "rev-parse", "--verify", `refs/remotes/${config.remote}/${config.baseBranch}^{commit}` + ], "Fetched base verification"); + git(repository, ["switch", "--no-guess", "--no-overwrite-ignore", "-c", branch, baseCommit], "New job branch creation"); + return { mode: "shared", branch, baseCommit, remote: config.remote, baseBranch: config.baseBranch }; +} + +export function verifySharedWorktreeBranch(repository, workspace) { + if (workspace && git(repository, ["branch", "--show-current"], "Completed job branch verification") !== workspace.branch) { + throw new Error("The agent changed the shared checkout's assigned branch. Reconcile the saved job branch before resuming; its result cannot be accepted"); + } +} + +export function sharedWorktreeGuidance(workspace) { + if (!workspace) { + return ""; + } + return `\nShared worktree contract: +- The dashboard has exclusively acquired this checkout on branch ${workspace.branch}, based on ${workspace.remote}/${workspace.baseBranch} at ${workspace.baseCommit}. +- Work in this checkout and on this exact branch. Do not create another worktree, switch branches, reset, stash, clean, or merge/rebase the base branch. +- Reuse this job's existing PR on resume. Another job can use the same folder only after owned execution stops and the checkout is clean. +- Check dependency and configuration readiness for the selected branch; cached dependencies are not proof that changed manifests were restored. +- Recheck frontend health and authenticated access after reacquisition. Browser/session state and build outputs are not evidence that a previous branch is still loaded.`; +} diff --git a/docs/architecture.md b/docs/architecture.md index 8aa8620..2c7c99d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -94,6 +94,16 @@ 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. +An opt-in `worktreeSetup.mode: "shared"` profile lets sequential dashboard jobs +reuse the selected checkout. Admission holds that same executor lease while +checking cleanliness, refreshing the configured remote base and acquiring a +job-specific branch. Its branch and base commit are persisted with the job. +Held/resumed jobs reacquire their saved branch without syncing it to the base; +restarted jobs acquire a new branch. PRs can remain pending while another job +uses the folder, but dirty files, in-progress Git operations and owned +frontend/authentication processes prevent branch changes. The executor must +leave its assigned branch selected. See [shared checkout setup](repository-onboarding.md#reusing-one-checkout-for-sequential-jobs). + 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 diff --git a/docs/repository-onboarding.md b/docs/repository-onboarding.md index 98584c3..3e0a43d 100644 --- a/docs/repository-onboarding.md +++ b/docs/repository-onboarding.md @@ -105,6 +105,66 @@ create branches beneath the resolved prefix. It never substitutes `copilot`, ## Worktree setup +### Reusing one checkout for sequential jobs + +Opt in to shared mode in a dedicated runner checkout's repository profile: + +```json +{ + "worktreeSetup": { + "mode": "shared", + "remote": "origin", + "baseBranch": "develop", + "files": ["frontend/playwright.local.config.ts"] + }, + "pullRequests": { + "defaultTargetBranch": "develop", + "branchNaming": { + "userId": "developer-alias", + "prefixTemplate": "users/{userId}" + } + } +} +``` + +Start `fixlab dashboard` against this checkout, or connect with `fixlab chat`. +Shared fix-and-validate jobs use that same folder, dependencies and local +browser profile; they do not create a folder per bug. Each new job refreshes +the configured remote base and creates a separate `users//fixlab-` +branch from its fetched commit, not from the previous fix branch. Omit +`baseBranch` to use `pullRequests.defaultTargetBranch`; the default remote is +`origin`. No network fetch is performed merely by installing or inspecting +the profile. + +The existing repository executor lease fences preparation and execution. +Another job can reuse the folder after execution has verifiably stopped, +even while its earlier PR is pending. Use **Hold** to release a blocked job +to the queue, and **Resume** to reacquire its saved branch and session. +Resumption never fetches, rebases or resets that branch. Restarting a job +creates a new session and branch from the refreshed base. Saved shared +ownership survives dashboard restarts; legacy jobs without a saved branch +must be resumed using their original workspace policy. + +Branch changes require a clean index and working tree, including untracked +files, and no merge/rebase/cherry-pick in progress. Stop owned frontend and +authentication processes before selecting a different branch. FixLab never +stashes, resets, deletes files, overwrites ignored files, or force-switches +another worktree's branch. A failed fetch blocks the new job instead of using +a stale base. The Git refresh is noninteractive and has a 30-second timeout; +authenticate to the remote separately if necessary. + +Keep this checkout's reusable profile/configuration available on the base +branch or locally ignored; uncommitted setup files must be committed or +explicitly ignored before starting. Branch-dependent dependencies must still +be checked after acquisition. Existing browser state can be reused only after +authenticated access is verified for the selected environment. Ignore browser +state and dependencies in Git; never copy credentials between checkouts. + +Shared mode is opt-in; omitted mode or `"isolated"` preserves existing +behavior. Direct interactive bug-fix and `fixlab run` execution are blocked in +shared mode because they do not have the dashboard's branch-resume contract. +Validation-only jobs do not switch branches or refresh the base. + ### Preparing another worktree A Git worktree does not inherit ignored profiles, untracked browser diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 4fc3ddb..5f310ca 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -488,8 +488,121 @@ function git(repository, args) { shell: false }); assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); } +test("shared dashboard jobs refresh develop, reuse one checkout and reacquire held branches after restart", async () => { + const source = createGitRepository(); + const repository = mkdtempSync(join(tmpdir(), "fixlab-shared-dashboard-")); + const executions = []; + const profile = validProfile({ worktreeSetup: { mode: "shared", baseBranch: "develop" } }); + writeFileSync(join(source, ".github", "fixlab", "repository-profile.json"), JSON.stringify(profile)); + git(source, ["branch", "-m", "develop"]); + git(source, ["add", "."]); + git(source, ["commit", "--quiet", "-m", "configure shared runner"]); + git(source, ["clone", "--quiet", source, repository]); + git(repository, ["config", "user.name", "Fixture"]); + git(repository, ["config", "user.email", "fixture@example.test"]); + const executor = (input) => { + let finish; + const completion = new Promise((resolve) => { finish = resolve; }); + executions.push({ input, finish, branch: git(repository, ["branch", "--show-current"]) }); + return { completion, terminate() { finish({ code: null, signal: "SIGTERM" }); } }; + }; + let current = await startDashboard(repository, executor); + const post = (path, body) => jsonRequest(current.url, path, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) + }); + const waitUntil = async (condition) => { + for (let attempt = 0; attempt < 100 && !condition(); attempt += 1) { + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.ok(condition()); + }; + const block = (execution) => { + execution.input.onOutput("stdout", "FIXLAB_STAGE|intake|blocked|Retain this job.\n"); + execution.finish({ code: 0 }); + }; + try { + const first = await post("/api/jobs", { request: "First shared fix.", mode: "fix-and-validate" }); + assert.equal(first.response.status, 202); + const firstId = first.body.job.id; + const firstBranch = first.body.job.sharedWorkspace.branch; + assert.equal(executions[0].branch, firstBranch); + assert.match(executions[0].input.prompt, /Shared worktree contract/); + writeFileSync(join(repository, "first-fix.txt"), "first fix"); + git(repository, ["add", "first-fix.txt"]); + git(repository, ["commit", "--quiet", "-m", "first fix"]); + const firstCommit = git(repository, ["rev-parse", "HEAD"]); + block(executions[0]); + await waitUntil(() => !existsSync(join(repository, ".git", "fixlab", "executor-lease"))); + await post("/api/jobs/hold", { jobId: firstId }); + writeFileSync(join(source, "upstream.txt"), "fresh develop"); + git(source, ["add", "upstream.txt"]); + git(source, ["commit", "--quiet", "-m", "advance develop"]); + const second = await post("/api/jobs", { request: "Second independent fix.", mode: "fix-and-validate" }); + assert.equal(second.response.status, 202); + assert.notEqual(second.body.job.sharedWorkspace.branch, firstBranch); + assert.equal(second.body.job.sharedWorkspace.baseCommit, git(source, ["rev-parse", "HEAD"])); + assert.equal(executions[1].input.repository, repository); + assert.equal(existsSync(join(repository, "first-fix.txt")), false); + assert.equal(readFileSync(join(repository, "upstream.txt"), "utf8"), "fresh develop"); + block(executions[1]); + await waitUntil(() => !existsSync(join(repository, ".git", "fixlab", "executor-lease"))); + await post("/api/jobs/hold", { jobId: second.body.job.id }); + await current.dashboard.close(); + current = await startDashboard(repository, executor); + const restored = await jsonRequest(current.url, "/api/status"); + assert.equal(restored.body.held.find((job) => job.id === firstId).sharedWorkspace.branch, firstBranch); + const resumed = await post("/api/jobs/resume", { jobId: firstId }); + assert.equal(resumed.response.status, 202); + assert.equal(executions[2].input.sessionId, firstId); + assert.equal(executions[2].input.resume, true); + assert.equal(git(repository, ["rev-parse", "HEAD"]), firstCommit); + assert.equal(existsSync(join(repository, "upstream.txt")), false); + assert.equal(readFileSync(join(repository, "first-fix.txt"), "utf8"), "first fix"); + block(executions[2]); + await waitUntil(() => !existsSync(join(repository, ".git", "fixlab", "executor-lease"))); + const restarted = await post("/api/jobs/restart", { jobId: firstId }); + assert.equal(restarted.response.status, 202); + assert.notEqual(restarted.body.job.id, firstId); + assert.notEqual(restarted.body.job.sharedWorkspace.branch, firstBranch); + assert.equal(restarted.body.job.sharedWorkspace.baseCommit, git(source, ["rev-parse", "HEAD"])); + assert.equal(executions[3].input.resume, false); + assert.equal(existsSync(join(repository, "first-fix.txt")), false); + } finally { + await current.dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + rmSync(source, { recursive: true, force: true }); + } +}); + +test("shared dashboard rejects dirty checkout acquisition without launching or retaining an executor lease", async () => { + const repository = createGitRepository(); + writeFileSync(join(repository, ".github", "fixlab", "repository-profile.json"), JSON.stringify(validProfile({ + worktreeSetup: { mode: "shared", baseBranch: "develop" } + }))); + git(repository, ["add", "."]); + git(repository, ["commit", "--quiet", "-m", "shared policy"]); + writeFileSync(join(repository, "uncommitted.txt"), "preserve"); + const { dashboard, url } = await startDashboard(repository, () => { throw new Error("Must not launch."); }); + try { + const result = await jsonRequest(url, "/api/jobs", { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ request: "Preserve dirty state.", mode: "fix-and-validate" }) + }); + assert.equal(result.response.status, 202); + assert.equal(result.body.job.status, "failed"); + assert.match(result.body.job.error, /uncommitted or untracked/); + assert.equal(readFileSync(join(repository, "uncommitted.txt"), "utf8"), "preserve"); + const reservation = createExecutorLease(repository, "test").acquire("test"); + reservation.release(); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + function createGitRepository() { const repository = createRepository(); git(repository, ["init", "--quiet"]); diff --git a/test/dashboard-shared-worktree.test.js b/test/dashboard-shared-worktree.test.js new file mode 100644 index 0000000..dc8f253 --- /dev/null +++ b/test/dashboard-shared-worktree.test.js @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, rmdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { prepareSharedWorktree, sharedWorktreeConfig, sharedWorktreeGuidance, verifySharedWorktreeBranch } from "../dashboard/shared-worktree.js"; + +function git(repository, ...args) { + const result = spawnSync("git", args, { cwd: repository, encoding: "utf8", windowsHide: true }); + assert.equal(result.status, 0, result.stderr); + return result.stdout.trim(); +} + +function fixture() { + const root = mkdtempSync(join(tmpdir(), "fixlab-shared-worktree-")); + const source = join(root, "source"); + const repository = join(root, "runner"); + mkdirSync(source); + git(source, "init", "--quiet", "-b", "develop"); + git(source, "config", "user.name", "Fixture"); + git(source, "config", "user.email", "fixture@example.test"); + writeFileSync(join(source, "tracked.txt"), "base"); + writeFileSync(join(source, ".gitignore"), ".edge-profile/\nignored.txt\n"); + git(source, "add", "."); + git(source, "commit", "--quiet", "-m", "base"); + git(root, "clone", "--quiet", source, repository); + git(repository, "config", "user.name", "Fixture"); + git(repository, "config", "user.email", "fixture@example.test"); + const profile = { + worktreeSetup: { mode: "shared", baseBranch: "develop" }, + pullRequests: { + defaultTargetBranch: "develop", + branchNaming: { userId: "developer", prefixTemplate: "users/{userId}" } + } + }; + return { root, source, repository, profile }; +} + +function prepare(f, options = {}) { + return prepareSharedWorktree({ ...f, jobId: randomUUID(), ...options }); +} + +test("shared jobs reuse one folder but refresh the base and retain separate resumable branches", () => { + const f = fixture(); + try { + const firstId = randomUUID(); + const first = prepare(f, { jobId: firstId }); + assert.equal(first.baseCommit, git(f.source, "rev-parse", "HEAD")); + writeFileSync(join(f.repository, "fix.txt"), "first job"); + git(f.repository, "add", "fix.txt"); + git(f.repository, "commit", "--quiet", "-m", "first fix"); + const firstCommit = git(f.repository, "rev-parse", "HEAD"); + mkdirSync(join(f.repository, ".edge-profile")); + writeFileSync(join(f.repository, ".edge-profile", "fixture-marker"), "local fixture"); + writeFileSync(join(f.source, "upstream.txt"), "updated base"); + git(f.source, "add", "upstream.txt"); + git(f.source, "commit", "--quiet", "-m", "advance base"); + const second = prepare(f); + assert.notEqual(second.branch, first.branch); + assert.equal(second.baseCommit, git(f.source, "rev-parse", "HEAD")); + assert.equal(existsSync(join(f.repository, "fix.txt")), false); + assert.equal(readFileSync(join(f.repository, "upstream.txt"), "utf8"), "updated base"); + assert.equal(readFileSync(join(f.repository, ".edge-profile", "fixture-marker"), "utf8"), "local fixture"); + git(f.repository, "remote", "set-url", "origin", join(f.root, "unavailable")); + assert.deepEqual(prepare(f, { jobId: firstId, saved: first, resume: true }), first); + assert.equal(git(f.repository, "rev-parse", "HEAD"), firstCommit); + assert.equal(readFileSync(join(f.repository, "fix.txt"), "utf8"), "first job"); + assert.equal(existsSync(join(f.repository, "upstream.txt")), false); + assert.match(sharedWorktreeGuidance(first), /Do not create another worktree/); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("shared jobs preserve dirty files, blocked branch acquisition and in-progress Git operations", () => { + const f = fixture(); + try { + const id = randomUUID(); + const saved = prepare(f, { jobId: id }); + const head = git(f.repository, "rev-parse", "HEAD"); + writeFileSync(join(f.repository, "untracked.txt"), "preserve"); + assert.throws(() => prepare(f), /uncommitted or untracked/); + assert.equal(git(f.repository, "rev-parse", "HEAD"), head); + assert.deepEqual(prepare(f, { jobId: id, saved, resume: true, resourcesRunning: true }), saved); + assert.equal(readFileSync(join(f.repository, "untracked.txt"), "utf8"), "preserve"); + rmSync(join(f.repository, "untracked.txt")); + writeFileSync(join(f.repository, "tracked.txt"), "uncommitted fix"); + git(f.repository, "add", "tracked.txt"); + assert.throws(() => prepare(f), /uncommitted or untracked/); + assert.equal(readFileSync(join(f.repository, "tracked.txt"), "utf8"), "uncommitted fix"); + git(f.repository, "commit", "--quiet", "-m", "preserve fix"); + assert.throws(() => prepare(f, { resourcesRunning: true }), /owned React and authentication/); + prepare(f); + assert.throws(() => prepare(f, { jobId: id, saved, resourcesRunning: true }), /reacquiring a different job branch/); + const mergeHead = join(f.repository, ".git", "MERGE_HEAD"); + writeFileSync(mergeHead, head); + assert.throws(() => prepare(f), /in-progress Git operation/); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("failed refresh never falls back to a stale base or changes the current branch", () => { + const f = fixture(); + try { + const branch = git(f.repository, "branch", "--show-current"); + const head = git(f.repository, "rev-parse", "HEAD"); + git(f.repository, "remote", "set-url", "origin", join(f.root, "missing")); + assert.throws(() => prepare(f), /base refresh failed/); + assert.equal(git(f.repository, "branch", "--show-current"), branch); + assert.equal(git(f.repository, "rev-parse", "HEAD"), head); + assert.equal(git(f.repository, "for-each-ref", "--format=%(refname)", "refs/heads/users/"), ""); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("resume refuses missing, rewritten and externally checked-out saved branches", () => { + const f = fixture(); + try { + const id = randomUUID(); + const saved = prepare(f, { jobId: id }); + const second = prepare(f); + const other = join(f.root, "other"); + git(f.repository, "worktree", "add", "--quiet", other, saved.branch); + assert.throws(() => prepare(f, { jobId: id, saved }), /branch reacquisition failed/); + assert.equal(git(f.repository, "branch", "--show-current"), second.branch); + assert.throws(() => verifySharedWorktreeBranch(f.repository, saved), /assigned branch/); + assert.throws(() => prepare(f, { jobId: id, saved: { ...saved, baseCommit: "0".repeat(40) } }), /ancestry verification failed/); + git(f.repository, "worktree", "remove", other); + git(f.repository, "branch", "-d", saved.branch); + assert.throws(() => prepare(f, { jobId: id, saved }), /Saved branch verification failed/); + assert.equal(git(f.repository, "branch", "--show-current"), second.branch); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("branch acquisition never overwrites ignored local files", () => { + const f = fixture(); + try { + writeFileSync(join(f.repository, "ignored.txt"), "local fixture"); + writeFileSync(join(f.source, "ignored.txt"), "remote fixture"); + git(f.source, "add", "-f", "ignored.txt"); + git(f.source, "commit", "--quiet", "-m", "track previously ignored file"); + assert.throws(() => prepare(f), /branch creation failed/); + assert.equal(git(f.repository, "branch", "--show-current"), "develop"); + assert.equal(readFileSync(join(f.repository, "ignored.txt"), "utf8"), "local fixture"); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); + +test("shared configuration is explicit and invalid or legacy ownership fails closed", () => { + const f = fixture(); + try { + assert.equal(sharedWorktreeConfig({}, f.repository), null); + assert.equal(sharedWorktreeConfig({ worktreeSetup: { mode: "isolated" } }, f.repository), null); + assert.equal(prepareSharedWorktree({ repository: f.repository, profile: {}, jobId: "unused" }), null); + const defaults = structuredClone(f.profile); + delete defaults.worktreeSetup.baseBranch; + assert.equal(sharedWorktreeConfig(defaults, f.repository).baseBranch, "develop"); + for (const worktreeSetup of [ + { mode: "unknown" }, { mode: "shared", remote: "--upload-pack=evil" }, + { mode: "shared", baseBranch: "develop^{commit}" }, { mode: "shared", baseBranch: "../develop" } + ]) { + assert.throws(() => sharedWorktreeConfig({ ...f.profile, worktreeSetup }, f.repository)); + } + assert.throws(() => sharedWorktreeConfig({ worktreeSetup: { mode: "shared" } }, f.repository), /baseBranch/); + assert.throws(() => sharedWorktreeConfig({ ...f.profile, pullRequests: {} }, f.repository), /branchNaming/); + assert.throws(() => sharedWorktreeConfig(f.profile, f.root), /repository verification failed/); + mkdirSync(join(f.repository, "nested")); + assert.throws(() => sharedWorktreeConfig(f.profile, join(f.repository, "nested")), /checkout root/); + rmdirSync(join(f.repository, "nested")); + assert.throws(() => prepare(f, { jobId: "--bad" }), /valid job ID/); + assert.throws(() => prepare(f, { resume: true }), /older job.*no saved/); + const id = randomUUID(); + const saved = prepare(f, { jobId: id }); + assert.throws(() => prepare(f, { jobId: randomUUID(), saved }), /ownership is invalid/); + assert.throws(() => prepare(f, { jobId: id, saved, profile: {} }), /restore shared mode/); + } finally { + rmSync(f.root, { recursive: true, force: true }); + } +}); diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index b47021e..0765bce 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -81,6 +81,22 @@ test("help lists supported commands", () => { assert.match(result.stdout, /docs\/troubleshooting\.md/); }); +test("direct shared-mode bug fixing requires dashboard-owned branch acquisition", () => { + const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-shared-")); + try { + mkdirSync(join(repository, ".github", "fixlab"), { recursive: true }); + writeFileSync(join(repository, ".github", "fixlab", "repository-profile.json"), JSON.stringify({ + worktreeSetup: { mode: "shared" } + })); + const result = run(["run", repository, "--", "Fix a bug."], repository); + assert.equal(result.status, 1); + assert.match(result.stderr, /Shared worktree jobs require dashboard-owned branch acquisition/); + assert.match(result.stderr, /fixlab dashboard or fixlab chat/); + } finally { + rmSync(repository, { recursive: true, force: true }); + } +}); + test("CLI preserves model selection without interpreting request text as flags", () => { const repository = mkdtempSync(join(tmpdir(), "fixlab-cli-model-")); const executableDirectory = join(repository, "bin");