Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions dashboard/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,9 @@ function restoreDashboardJob(snapshot, repository) {
screenshotPaths: [],
cacheSummary: loadCacheSummary(restored.cacheContext)
});
if (restored.status === "queued") {
restored.pendingPrompt = restored.initialPrompt;
}
return restored;
}

Expand Down Expand Up @@ -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);
Comment on lines +2164 to +2166
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([
Expand Down Expand Up @@ -2374,6 +2387,8 @@ export function createDashboardServer({
}
}

startNextJob();

const server = createServer(async (request, response) => {
const requestUrl = new URL(
request.url,
Expand Down
9 changes: 7 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/repository-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 3 additions & 1 deletion templates/repository-profile.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,9 @@
"environments": [
"local",
"development",
"integration"
"test",
"integration",
"uat"
],
"pullRequests": {
"defaultTargetBranch": "develop",
Expand Down
96 changes: 96 additions & 0 deletions test/dashboard-server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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 }) => {
Expand Down
Loading