diff --git a/README.md b/README.md index 4b61f73..8b8593b 100644 --- a/README.md +++ b/README.md @@ -185,6 +185,8 @@ visible risk. FixLab never reports a skipped or timed-out gate as successful. traces, failures, and explicit data-source behavior. - **Resumable workflows** for authentication, genuine blockers, manual validation, and focused follow-up instructions. +- **Restart-safe queues** that restore waiting jobs in their original order and + preserve their runtime session IDs. - **Pull-request readiness** based on completed gates, effective-diff review, remaining risks, and preserved evidence. diff --git a/dashboard/server.js b/dashboard/server.js index b1abff4..1b4c2a2 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -438,6 +438,9 @@ function restoreDashboardJob(snapshot, repository) { screenshotPaths: [], cacheSummary: loadCacheSummary(restored.cacheContext) }); + if (restored.status === "queued") { + restored.pendingPrompt = restored.initialPrompt; + } return restored; } @@ -2158,6 +2161,16 @@ export function createDashboardServer({ (job) => job.id !== resumableSnapshot.id ); } + const queuedSnapshots = persistedJobs + .filter((job) => job.status === "queued") + .slice(0, MAX_QUEUED_JOBS); + for (const snapshot of queuedSnapshots) { + queuedJobs.push(restoreDashboardJob(snapshot, resolvedRepository)); + } + if (queuedSnapshots.length > 0) { + const queuedIds = new Set(queuedSnapshots.map((job) => job.id)); + persistedJobs = persistedJobs.filter((job) => !queuedIds.has(job.id)); + } function dashboardHistory() { const liveIds = new Set([ @@ -2374,6 +2387,8 @@ export function createDashboardServer({ } } + startNextJob(); + const server = createServer(async (request, response) => { const requestUrl = new URL( request.url, diff --git a/docs/architecture.md b/docs/architecture.md index bc6d748..472dca6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -89,8 +89,9 @@ profile-defined startup and live testing, evidence collection, and the gated pull-request outcome. It asks for human interaction only for authentication, unsafe-data approval, deployment or pull-request approval, or genuine blockers. The job form also exposes the repository profile's allowed non-production -environments. A user can explicitly select environments such as DEV or SIT for -profile-defined application startup and Playwright live testing. The server +environments. A user can explicitly select profile-defined environments such +as DEV, SIT, Test, or UAT for application startup and Playwright live testing; +FixLab does not assume every user can access the same shared system. The server rejects values outside the profile allowlist and production aliases. The agent must use the selected environment exactly or block with the missing prerequisite; it cannot silently fall back to local or another environment. @@ -190,6 +191,10 @@ failed jobs pause queue advancement so the same session remains resumable. A user may explicitly dismiss a failed job without resuming it; FixLab preserves the failed outcome in dashboard history, performs no repository mutation for that job, and starts the next queued job. +Persisted waiting jobs are restored in their original order after a dashboard +restart, retain their UUID runtime session IDs, and remain paused behind a +restored blocked or failed active job. If no blocking job remains, queue +execution resumes automatically. Waiting jobs can be removed by queue position or job ID before execution. The dashboard records the job as cancelled, deletes only its FixLab-owned screenshot artifacts, renumbers the remaining queue, and never interrupts the diff --git a/docs/repository-onboarding.md b/docs/repository-onboarding.md index 2601b38..f98346b 100644 --- a/docs/repository-onboarding.md +++ b/docs/repository-onboarding.md @@ -70,9 +70,11 @@ instead declare `local-fixture`, `non-production-read-only`, or `non-production-approved`; any approved write still requires the normal human unsafe-data approval. -With `non-production-read-only`, an explicitly selected DEV or SIT environment -is tried first. If that backend is unavailable or cannot provide a safe record -required for the assertion, FixLab may fall back to +With `non-production-read-only`, the explicitly selected profile-approved +environment is tried first. Profiles can offer DEV, SIT, Test, UAT, or other +repository-specific non-production systems so users are not required to have +access to one shared environment. If that backend is unavailable or cannot +provide a safe record required for the assertion, FixLab may fall back to `synthetic-intercepted`/`intercepted` and must report that the backend was not validated. Expected empty-state scenarios must remain against the selected backend and must not be replaced by synthetic results. diff --git a/templates/repository-profile.json b/templates/repository-profile.json index 0482ae9..e74ff28 100644 --- a/templates/repository-profile.json +++ b/templates/repository-profile.json @@ -76,7 +76,9 @@ "environments": [ "local", "development", - "integration" + "test", + "integration", + "uat" ], "pullRequests": { "defaultTargetBranch": "develop", diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 905b7df..4b2f0eb 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -38,6 +38,16 @@ const packageRoot = new URL("..", import.meta.url).pathname.replace( "$1" ); +test("repository profile template offers configurable test and UAT environments", () => { + const profile = JSON.parse( + readFileSync(join(packageRoot, "templates", "repository-profile.json"), "utf8") + ); + + assert.equal(profile.environments.includes("test"), true); + assert.equal(profile.environments.includes("uat"), true); + assert.equal(profile.environments.includes("production"), false); +}); + test("Agency executor keeps long prompts out of process arguments", () => { const prompt = `Fix this batch:\n${"x".repeat(40000)}`; const invocation = buildAgencyInvocation({ @@ -1778,6 +1788,92 @@ test("dashboard recovers an orphaned running job as failed and resumable", async } }); +test("dashboard restores queued jobs in order after restart", async () => { + const repository = createGitRepository(); + const first = await startDashboard(repository, () => ({ + completion: Promise.resolve({ code: 1 }), + terminate() {} + })); + let currentJobId; + let firstQueuedJobId; + let secondQueuedJobId; + + try { + const started = await jsonRequest(first.url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Fail this active job before restart.", + requestType: "bug-fix", + mode: "fix-and-validate" + }) + }); + currentJobId = started.body.job.id; + await new Promise((resolve) => setTimeout(resolve, 0)); + + const firstQueued = await jsonRequest(first.url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Run this restored job first.", + requestType: "small-enhancement", + mode: "fix-and-validate" + }) + }); + firstQueuedJobId = firstQueued.body.queuedJob.id; + + const secondQueued = await jsonRequest(first.url, "/api/jobs", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + request: "Run this restored job second.", + requestType: "bug-fix", + mode: "validate-only" + }) + }); + secondQueuedJobId = secondQueued.body.queuedJob.id; + await jsonRequest(first.url, "/api/status"); + } finally { + await first.dashboard.close(); + } + + const executorCalls = []; + const second = await startDashboard(repository, (input) => { + executorCalls.push(input); + return { completion: new Promise(() => {}), terminate() {} }; + }); + try { + const restored = await jsonRequest(second.url, "/api/status"); + assert.equal(restored.body.job.id, currentJobId); + assert.equal(restored.body.job.status, "failed"); + assert.deepEqual( + restored.body.queue.map((job) => job.id), + [firstQueuedJobId, secondQueuedJobId] + ); + assert.deepEqual( + restored.body.queue.map((job) => job.position), + [1, 2] + ); + + const dismissed = await jsonRequest(second.url, "/api/job/dismiss", { + method: "POST" + }); + assert.equal(dismissed.response.status, 202); + assert.equal(dismissed.body.job.id, firstQueuedJobId); + assert.equal(dismissed.body.job.status, "running"); + assert.deepEqual( + dismissed.body.queue.map((job) => job.id), + [secondQueuedJobId] + ); + assert.equal(executorCalls.length, 1); + assert.match(executorCalls[0].prompt, /Run this restored job first/); + assert.equal(executorCalls[0].sessionId, firstQueuedJobId); + } finally { + await second.dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("dashboard recovery preserves a completed manual live-test hold", async () => { const repository = createGitRepository(); const executor = ({ onOutput }) => {