From fcd9781770cef4a8e6e659968938ad5fe2214f39 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Thu, 5 Nov 2026 14:12:57 -0500 Subject: [PATCH 1/4] Retain detailed bug-to-code-fix dashboard summaries Capture bounded engineering explanations separately from stage messages, preserve them through retries and history, and display concrete fixes, changed files, regressions and risks. Allow explicitly reviewed notes for older stopped jobs without changing execution or validation evidence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46e778f9-d964-46b0-bebe-3b6f9f71eb74 --- dashboard/public/app.js | 26 ++++++- dashboard/server.js | 130 ++++++++++++++++++++++++++++++- docs/architecture.md | 16 ++++ docs/commands.md | 10 ++- e2e/dashboard-readiness.spec.js | 20 ++++- test/dashboard-server.test.js | 133 ++++++++++++++++++++++++++++++++ 6 files changed, 325 insertions(+), 10 deletions(-) diff --git a/dashboard/public/app.js b/dashboard/public/app.js index 11146b1..cfe1807 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -12,7 +12,7 @@ const stageNames = [ ]; function summaryText(value) { - const result = String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 1000); + const result = String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 2000); if (/(?:password|passwd|secret|token|authorization|bearer|cookie|connection.?string|private.?key)/iu.test(result)) { return "[Sensitive detail omitted]"; } @@ -23,6 +23,7 @@ function jobSummarySections(job) { const gates = job.validation?.gates ?? []; const unresolved = stageNames.filter((name) => job.stages?.[name]?.status !== "passed"); const evidence = job.testEvidence; + const detail = job.engineeringSummary; const interim = ["running", "queued", "holding"].includes(job.status); const stage = (name) => { const recorded = job.stages?.[name]; @@ -32,14 +33,27 @@ function jobSummarySections(job) { { heading: interim ? "Interim outcome" : "Recorded outcome", lines: [ `Job: ${summaryText(job.id)} | Status: ${summaryText(job.status)}`, `Request: ${summaryText(job.request)}`, + ...(detail ? [ + `Bug: ${summaryText(detail.problem)}`, + `Expected behavior: ${summaryText(detail.expectedBehavior)}`, + `Actual behavior: ${summaryText(detail.actualBehavior)}` + ] : []), `Environment: ${summaryText(job.targetEnvironment)} | Mode: ${summaryText(job.mode)}`, "Job completion is not a claim of complete regression coverage." ] }, - { heading: "Investigation and changes (agent-reported)", lines: [ + { heading: detail ? "Bug-to-code-fix explanation" : "Investigation and changes (agent-reported)", lines: [ + ...(detail ? [ + `Explanation source: ${summaryText(job.engineeringSummaryOrigin)}`, + `Root cause: ${summaryText(detail.rootCause)}`, + `Code fix: ${summaryText(detail.codeFix)}`, + ...(detail.changedFiles ?? []).map((file) => `Changed file: ${summaryText(file)}`), + `Commit reference: ${summaryText(detail.commit)}` + ] : ["Detailed code-change explanation was not retained for this job. Stage messages below are not a substitute for that evidence."]), stage("intake"), stage("diagnosis"), stage("reproduce"), stage("fix"), stage("review"), - "Exact changed-file and commit inventory: Not verified by this summary." + "Code references above are recorded explanations, not newly executed verification." ] }, { heading: "Tests and regression", lines: [ + ...(detail?.regression ?? []).map((item) => `Recorded regression detail: ${summaryText(item)}`), ...gates.map((gate) => `${summaryText(gate.id)}: ${summaryText(gate.status)}${gate.required ? " (required)" : " (optional or not applicable)"}${Number.isFinite(gate.durationMs) ? ` | ${gate.durationMs} ms` : ""}${gate.summary ? ` | ${summaryText(gate.summary)}` : ""}`), ...(gates.length ? [] : ["No executor-backed gate results recorded."]), `Gate scope: ${summaryText(job.validation?.scope)}. Only the listed configured gates are accounted for.`, @@ -56,7 +70,11 @@ function jobSummarySections(job) { "Deployed-system checks do not verify unpublished local code. Publication does not establish merge or deployment." ] }, { heading: "Workarounds and maintenance traps", lines: [ - "Validated workaround, tradeoffs and fix-induced side effects: Not verified unless explicitly recorded in the findings above.", + ...(detail?.workarounds ?? []).map((item) => `Workaround: ${summaryText(item)}`), + ...(detail?.risks ?? []).map((item) => `Risk / must-know: ${summaryText(item)}`), + ...((detail?.workarounds?.length || detail?.risks?.length) ? [] : [ + "Validated workaround, tradeoffs and fix-induced side effects: Not verified unless explicitly recorded in the findings above." + ]), "Do not infer that no risks exist merely because no finding was recorded." ] }, { heading: "Remaining gaps and next actions", lines: [ diff --git a/dashboard/server.js b/dashboard/server.js index 0e2b7df..829fea8 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -646,6 +646,47 @@ function safePersistedSummary(value) { return safeSummary(value).replace(/https?:\/\/\S+/giu, "[omitted URL]"); } +export function normalizeEngineeringSummary(value) { + const strings = ["problem", "expectedBehavior", "actualBehavior", "rootCause", "codeFix", "commit"]; + const lists = ["changedFiles", "regression", "risks", "workarounds"]; + if (!value || typeof value !== "object" || Array.isArray(value) || + Object.keys(value).some((key) => ![...strings, ...lists].includes(key))) { + throw new Error("Engineering summary must contain only the documented bug, fix and evidence fields."); + } + const result = {}; + for (const key of strings) { + const field = value[key] ?? ""; + if (typeof field !== "string" || field.length > 2000 || + (["problem", "rootCause", "codeFix"].includes(key) && !field.trim())) { + throw new Error(`Engineering summary ${key} must be a bounded string; problem, rootCause and codeFix are required.`); + } + if (key === "commit" && field && !/^[a-f0-9]{7,64}$/u.test(field)) { + throw new Error("Engineering summary commit must be a hexadecimal commit reference."); + } + result[key] = safeSummary(field, 2000).replace(/https?:\/\/\S+/giu, "[omitted URL]"); + } + for (const key of lists) { + const fields = value[key] ?? []; + if (!Array.isArray(fields) || fields.length > 20 || + fields.some((field) => typeof field !== "string" || !field.trim() || field.length > 500)) { + throw new Error(`Engineering summary ${key} must contain at most 20 bounded strings.`); + } + if (key === "changedFiles" && fields.some((field) => + /^[\\/]/u.test(field) || /[:*?"\0\r\n]/u.test(field) || + field.replace(/\\/gu, "/").split("/").some((part) => !part || part === "." || part === ".."))) { + throw new Error("Engineering summary files must be relative paths without traversal."); + } + result[key] = fields.map((field) => safeSummary(field, 500).replace(/https?:\/\/\S+/giu, "[omitted URL]")); + } + if (Buffer.byteLength(JSON.stringify(result)) > 16384) { + throw new Error("Engineering summary exceeds 16 KiB."); + } + return result; +} + +const ENGINEERING_SUMMARY_GUIDANCE = + '- At the reviewed fix checkpoint and before finishing, emit FIXLAB_SUMMARY| followed by one-line JSON: problem, expectedBehavior, actualBehavior, rootCause, codeFix (explain the actual change and why), changedFiles[], regression[], risks[], workarounds[], commit (hex or empty). Required: problem, rootCause, codeFix. Use relative file paths, bounded strings, no secrets/private URLs. Preserve the original bug-to-fix explanation across retries; update only for new evidence. Separate historical, skipped and unverified tests from actual passes; report no-change outcomes honestly.'; + function dashboardSnapshot(job) { return { id: job.id, @@ -735,6 +776,8 @@ function dashboardSnapshot(job) { owner: safePersistedSummary(bug.owner), summary: safePersistedSummary(bug.summary) })), + engineeringSummary: job.engineeringSummary ?? null, + engineeringSummaryOrigin: job.engineeringSummaryOrigin ?? null, canResume: false, canComment: false }; @@ -1347,6 +1390,7 @@ ${videoGuidance} ${manualLocalhostGuidance} ${readOnly ? "" : branchNamingGuidance} - ${readOnly ? "Do not edit files other than job-owned transient Playwright journeys and runner evidence. Do not create commits, push branches, create pull requests, or update pull requests." : "Make the smallest complete change that resolves the request. Make no code change when the evidence shows none is required."} +${readOnly ? "" : ENGINEERING_SUMMARY_GUIDANCE} ${readOnly ? TRANSIENT_PLAYWRIGHT_GUIDANCE : ""} - Autonomously complete the lifecycle without asking the user to direct routine engineering steps. ${readOnly ? "" : `- Saved delivery workflow: ${unverifiedDraft ? "explicit unverified draft; local and live checks are deferred, not passed" : efficient ? `efficient; focused checks before the ${workflow.publication === "ready" ? "ready-for-review" : "draft"} PR, broader checks only by saved preference or explicit selection` : "strict; all applicable configured gates remain required for full validation and ready publication"}. This recorded job policy takes precedence over the product default.`} @@ -1436,8 +1480,8 @@ function runGit(repository, args) { return result.status === 0 ? result.stdout.trim() : null; } -function safeSummary(value) { - const text = String(value ?? "").replace(/\s+/g, " ").trim().slice(0, 300); +function safeSummary(value, maximumLength = 300) { + const text = String(value ?? "").replace(/\s+/g, " ").trim().slice(0, maximumLength); if ( /(?:password|passwd|secret|token|authorization|bearer|cookie|connection.?string|private.?key|client.?secret|dashboard[-\\/]+artifacts)/i.test( text @@ -2331,6 +2375,8 @@ function publicJob(job) { id: job.id, request: job.request, requestType: job.requestType, + engineeringSummary: job.engineeringSummary ?? null, + engineeringSummaryOrigin: job.engineeringSummaryOrigin ?? null, deferValidation: job.deferValidation === true, skipLiveTests: job.skipLiveTests === true, useDeployedBackend: job.useDeployedBackend === true, @@ -2619,15 +2665,54 @@ function appendOutput(job, stream, text) { } function splitRenderedMarkers(line) { + const summaryIndex = line.indexOf("FIXLAB_SUMMARY|"); + if (summaryIndex > 0 && /^FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST)(?:\||\s)/u.test(line)) { + return [...splitRenderedMarkers(line.slice(0, summaryIndex)), ...splitRenderedMarkers(line.slice(summaryIndex))]; + } + if (summaryIndex === 0) { + let depth = 0; + let quoted = false; + let escaped = false; + for (let index = "FIXLAB_SUMMARY|".length; index < line.length; index += 1) { + const character = line[index]; + if (quoted) { + if (escaped) {escaped = false;} + else if (character === "\\") {escaped = true;} + else if (character === '"') {quoted = false;} + } else if (character === '"') {quoted = true;} + else if (character === "{") {depth += 1;} + else if (character === "}") { + depth -= 1; + if (depth === 0) { + const remainder = line.slice(index + 1).trim(); + return [line.slice(0, index + 1), ...(remainder ? splitRenderedMarkers(remainder) : [])]; + } + } + } + return [line]; + } if (!/^FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST)(?:\||\s)/u.test(line)) { return [line]; } return line - .split(/(?=FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST)(?:\||\s))/u) + .split(/(?=FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST|SUMMARY)(?:\||\s))/u) .filter(Boolean); } function appendLine(job, stream, line) { + if (line.startsWith("FIXLAB_SUMMARY|")) { + try { + if (job.permissionRequest || Buffer.byteLength(line) > 16384) { + throw new Error("Summary is blocked by pending permission review or exceeds 16 KiB."); + } + job.engineeringSummary = normalizeEngineeringSummary(JSON.parse(line.slice("FIXLAB_SUMMARY|".length))); + job.engineeringSummaryOrigin = "agent-reported"; + } catch (error) { + pushLog(job, { index: job.nextLogIndex, timestamp: new Date().toISOString(), + stream: "dashboard", message: `Engineering summary rejected: ${error instanceof SyntaxError ? "Invalid JSON." : error.message}` }); + } + return; + } const usage = parseUsageLine(line); if (usage) { if (job.runtime !== "copilot") { @@ -3025,8 +3110,12 @@ User input: ${details} ${manualResult} ${transientGuidance} +${job.mode === "fix-and-validate" && job.engineeringSummary + ? `Retained bug-to-code-fix explanation (recorded, not new verification):\n${JSON.stringify(job.engineeringSummary)}` + : ""} Resume requirements: +${job.mode === "fix-and-validate" ? ENGINEERING_SUMMARY_GUIDANCE : ""} - Reuse the completed diagnosis, current worktree, validation evidence, branch, and pull request from this session. - Re-read repository instructions and the effective diff only when the new input changes them. - Continue from the blocked or failed stage, or treat this as a focused addition to the completed job. @@ -4850,6 +4939,41 @@ export function createDashboardServer({ return; } + if (request.method === "POST" && requestUrl.pathname === "/api/job/engineering-summary") { + try { + const expectedHost = `${DASHBOARD_HOST}:${server.address().port}`; + if (request.headers.host !== expectedHost || request.headers.origin || + !request.headers["content-type"]?.startsWith("application/json")) { + throw new Error("Engineering notes require a local JSON client without a browser Origin."); + } + const body = await readJsonBody(request, 16384); + if (body.instanceId !== sessions.id || body.repository !== resolvedRepository || + body.confirmed !== true || typeof body.jobId !== "string") { + throw new Error("Engineering notes require the exact instance, repository, job and explicit confirmation."); + } + if (!dashboardHistoryPath) { + throw new Error("Engineering notes require persistable repository history."); + } + const job = [currentJob, ...heldJobs, ...completedJobs, ...persistedJobs] + .find((candidate) => candidate?.id === body.jobId); + if (!job || !["passed", "failed", "blocked", "held", "cancelled"].includes(job.status)) { + throw new Error("Engineering notes require an existing stopped job."); + } + const summary = normalizeEngineeringSummary(body.summary); + job.engineeringSummary = summary; + job.engineeringSummaryOrigin = "reviewed-note"; + persistDashboardState(); + if (dashboardHistoryWarning) { + sendJson(response, 503, { error: "Engineering notes are retained in memory but could not be persisted." }); + return; + } + sendJson(response, 200, { jobId: job.id, engineeringSummary: summary, origin: "reviewed-note" }); + } catch (error) { + sendJson(response, 400, { error: error.message }); + } + return; + } + if (request.method === "POST" && requestUrl.pathname === "/api/job/publication-context") { try { if (request.headers.host !== `${DASHBOARD_HOST}:${server.address().port}` || diff --git a/docs/architecture.md b/docs/architecture.md index 74738bc..4a271bd 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -71,6 +71,22 @@ Validation-only prompts retain authentication, permissions, data-safety and evidence requirements without instructions to prepare or publish code changes. Focused tests bound the generated prompt sizes and check those retained contracts. +Code jobs emit a bounded `FIXLAB_SUMMARY|` engineering explanation +at their reviewed fix checkpoint and completion. Required fields are `problem`, +`rootCause` and `codeFix`; optional fields capture expected/actual behavior, +relative changed files, a commit reference, regression details, risks and +workarounds. The dashboard sanitizes and preserves this record independently of +short stage messages through retries, history and restart. Invalid/oversized +records are explicitly rejected; neither records nor reviewed notes can mark +tests passed or grant permissions. + +For an older stopped job, an explicitly confirmed local JSON client can submit +reviewed notes to `/api/job/engineering-summary`, binding the exact instance, +repository and job. It rejects browser Origins, active/unknown jobs and unsafe +paths. Notes are labeled `reviewed-note`, not runner proof; agent records are +labeled `agent-reported`. Missing details are not invented from current source +or a different job. The summary view does not execute tests or fetch source. + FixLab is distributed and versioned as its own product. It supports Agency Copilot as the default compatibility runtime and GitHub Copilot CLI as an optional direct runtime. Both provide agent execution, authentication, plugin diff --git a/docs/commands.md b/docs/commands.md index 1178e69..266c0db 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -88,8 +88,14 @@ In the dashboard, select a current or saved job and click **View summary** in the header. This separate read-only view assembles recorded investigation, changes, gate results, scenario claims, findings and remaining gaps without an AI call or job mutation. It refreshes that same job's evidence while open. -Missing workarounds, commit inventory and side-effect findings stay unverified; -it does not infer them from a passed status or parse raw logs. For an AI-written +Detailed engineering records show the original bug, expected/actual behavior, +root cause, actual code correction, changed files, commit, regression details +and risks. They survive retries and restart instead of being replaced by a +"retained fix" stage message. Older jobs without a record explicitly identify +the missing explanation; reviewed notes can be imported by a confirmed local +client bound to that stopped job. Neither agent explanations nor reviewed notes +are new runner proof. Missing facts stay unverified; the view does not infer +them from a passed status or parse raw logs. For an AI-written handoff with richer context, use the optional prompt in its original conversation. ## Executor-backed dashboard checks diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js index 8b58621..665c8e0 100644 --- a/e2e/dashboard-readiness.spec.js +++ b/e2e/dashboard-readiness.spec.js @@ -2358,6 +2358,19 @@ test("job summary shows recorded findings and gaps without execution, and refres fix: { status: "pending", message: "Correction not applied" }, "live-test": { status: "blocked", message: "Authentication unavailable" } }, + engineeringSummary: { + problem: "A removal request returns 404", + expectedBehavior: "Remove the selected mapping", + actualBehavior: "Gateway returns 404", + rootCause: "An existing operation was omitted from the gateway specification", + codeFix: "Publish DELETE with both required path parameters and a boolean response", + changedFiles: ["deployment\\contract.json", "tests\\contract.test.js"], + regression: ["Assert the DELETE operation and its success response"], + risks: ["Deploy the contract before validating live removal"], + workarounds: ["No validated workaround recorded"], + commit: "a".repeat(40) + }, + engineeringSummaryOrigin: "agent-reported", validation: { scope: "all", gates: [{ id: "regression-tests", status: "failed", required: true, durationMs: 25 }] }, testEvidence: { reported: true, scenario: "Check role visibility", source: "synthetic-intercepted", mutationMode: "intercepted" }, bugs: [{ id: "bug-1", outcome: "unresolved", owner: "application", summary: "Role selection remains incorrect" }], @@ -2369,10 +2382,14 @@ test("job summary shows recorded findings and gaps without execution, and refres const summary = page.locator("#job-summary-content"); await expect(summary).toContainText("Interim outcome"); await expect(summary).toContainText("Confirmed route mismatch"); + await expect(summary).toContainText("Bug-to-code-fix explanation"); + await expect(summary).toContainText("Publish DELETE with both required path parameters"); + await expect(summary).toContainText("Changed file: deployment\\contract.json"); + await expect(summary).toContainText("Assert the DELETE operation and its success response"); await expect(summary).toContainText("regression-tests: failed (required)"); await expect(summary).toContainText("Role selection remains incorrect"); await expect(summary).toContainText("Synthetic or intercepted tests do not prove real backend writes"); - await expect(summary).toContainText("Validated workaround, tradeoffs and fix-induced side effects: Not verified"); + await expect(summary).toContainText("No validated workaround recorded"); await expect(summary).not.toContainText("Raw log sentinel"); job.status = "passed"; job.validation.gates[0].status = "stale"; @@ -2403,6 +2420,7 @@ test("job summary follows historical selection, handles missing evidence and ren await expect(summary).toContainText(saved.id); await expect(summary).not.toContainText(job.request); await expect(summary).toContainText("No executor-backed gate results recorded"); + await expect(summary).toContainText("Detailed code-change explanation was not retained"); await expect(summary).toContainText("[Sensitive detail omitted]"); await expect(summary).not.toContainText("private.example.test"); await expect(summary).not.toContainText("private-sentinel"); diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index fb4f631..23c77c6 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -40,6 +40,7 @@ import { MAX_CACHE_ENTRIES, MAX_JOB_LOG_ENTRIES, openLocalUrl, + normalizeEngineeringSummary, validateCopilotModel, validateLiveTestProfile } from "../dashboard/server.js"; @@ -56,6 +57,138 @@ const packageRoot = new URL("..", import.meta.url).pathname.replace( "$1" ); +test("engineering summary validates detail fields, redacts sensitive text and rejects unsafe paths", () => { + const detail = { + problem: "A mapping cannot be removed", + rootCause: "The gateway contract omitted an existing operation", + codeFix: "Add the operation and its path parameters to the contract", + changedFiles: ["deployment\\contract.json"], + regression: ["Assert the operation and response schema"], + risks: ["Deployment is required; live deletion is not verified"], + workarounds: [], + commit: "a".repeat(40) + }; + const normalized = normalizeEngineeringSummary(detail); + assert.equal(normalized.codeFix, detail.codeFix); + assert.deepEqual(normalized.changedFiles, detail.changedFiles); + assert.match(normalizeEngineeringSummary({ ...detail, problem: "password=private-sentinel" }).problem, /omitted/); + assert.doesNotMatch(JSON.stringify(normalizeEngineeringSummary({ + ...detail, rootCause: "See https://private.example.test/path" + })), /private\.example/); + for (const changedFiles of [["..\\outside.json"], ["C:\\outside.json"], ["/outside.json"]]) { + assert.throws(() => normalizeEngineeringSummary({ ...detail, changedFiles }), /relative paths/); + } + assert.throws(() => normalizeEngineeringSummary({ ...detail, codeFix: "" }), /required/); + assert.throws(() => normalizeEngineeringSummary({ ...detail, passed: true }), /documented/); + assert.throws(() => normalizeEngineeringSummary({ ...detail, regression: Array(21).fill("test") }), /20 bounded/); +}); + +test("engineering summaries survive history and restart, and reviewed notes cannot mutate another active job", async () => { + const repository = createGitRepository(); + const detail = { problem: "Removal fails", rootCause: "Missing contract operation", + codeFix: "Publish the existing operation", changedFiles: ["deployment\\contract.json"], + regression: ["Original failure reproduced; contract assertion passed", + 'Literal FIXLAB_STAGE|pr|passed|{"example"} is not runner evidence'] }; + const executions = []; + const executor = (input) => { + executions.push(input); + if (executions.length === 1) { + input.onOutput("stdout", `FIXLAB_SUMMARY|${JSON.stringify(detail)}FIXLAB_STAGE|intake|skipped|Fixture.\n`); + input.onOutput("stdout", "FIXLAB_SUMMARY|{private-sentinel-invalid-json\n"); + for (const stage of FIXLAB_STAGES.filter((stage) => stage !== "intake")) { + input.onOutput("stdout", `FIXLAB_STAGE|${stage}|skipped|Fixture.\n`); + } + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + } + return { completion: new Promise(() => {}), terminate() {} }; + }; + let current = await startDashboard(repository, executor); + const post = (path, body, headers = {}) => jsonRequest(current.url, path, { + method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify(body) + }); + try { + const started = await post("/api/jobs", { request: "Validate removal.", mode: "validate-only" }); + const firstId = started.body.job.id; + let state; + for (let attempt = 0; attempt < 100; attempt += 1) { + state = (await jsonRequest(current.url, "/api/status")).body; + if (state.job.status === "passed") {break;} + await new Promise((resolve) => setTimeout(resolve, 10)); + } + assert.equal(state.job.status, "passed"); + assert.equal(state.job.engineeringSummary.codeFix, detail.codeFix); + assert.equal(state.job.engineeringSummaryOrigin, "agent-reported"); + assert.doesNotMatch(JSON.stringify(state.job.logs), /private-sentinel/); + const second = await post("/api/jobs", { request: "Another job.", mode: "validate-only" }); + const secondId = second.body.job.id; + const notes = { instanceId: state.instanceId, repository, jobId: firstId, confirmed: true, + summary: { ...detail, codeFix: "Reviewed contract-only correction" } }; + for (const body of [ + { ...notes, instanceId: "wrong-instance" }, { ...notes, confirmed: false }, + { ...notes, jobId: secondId }, { ...notes, jobId: "missing-job" }, + { ...notes, summary: { ...detail, changedFiles: ["..\\outside.json"] } } + ]) { + const rejected = await post("/api/job/engineering-summary", body); + assert.equal(rejected.response.status, 400, rejected.body.error); + } + assert.equal((await post("/api/job/engineering-summary", notes, { Origin: current.url })).response.status, 400); + const reviewed = await post("/api/job/engineering-summary", notes); + assert.equal(reviewed.response.status, 200, reviewed.body.error); + const after = (await jsonRequest(current.url, "/api/status")).body; + assert.equal(after.job.id, secondId); + assert.equal(after.job.status, "running"); + assert.equal(executions.length, 2); + assert.equal(after.history.find((job) => job.id === firstId).engineeringSummary.codeFix, + "Reviewed contract-only correction"); + await current.dashboard.close(); + current = await startDashboard(repository, executor); + const restored = (await jsonRequest(current.url, "/api/status")).body; + const saved = [restored.job, ...restored.history, ...restored.held].find((job) => job?.id === firstId); + assert.equal(saved.engineeringSummary.codeFix, "Reviewed contract-only correction"); + assert.equal(saved.engineeringSummaryOrigin, "reviewed-note"); + assert.equal(executions.length, 2); + } finally { + await current.dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + +test("editing resumes retain the original detailed bug-to-code-fix explanation", async () => { + const repository = createRepository(); + const detail = { problem: "Removal fails", rootCause: "Missing contract operation", + codeFix: "Add the existing operation to the gateway contract" }; + const calls = []; + const executor = (input) => { + calls.push(input); + if (!input.resume) { + input.onOutput("stdout", `FIXLAB_SUMMARY|${JSON.stringify(detail)}\n`); + } + for (const stage of FIXLAB_STAGES) { + input.onOutput("stdout", `FIXLAB_STAGE|${stage}|${!input.resume && stage === "live-test" ? "blocked" : "skipped"}|Fixture.\n`); + } + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + }; + const { dashboard, url } = await startDashboard(repository, executor); + const post = (path, body) => jsonRequest(url, path, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) + }); + try { + await post("/api/jobs", { request: "Fix removal.", mode: "fix-and-validate" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal((await jsonRequest(url, "/api/job")).body.job.status, "blocked"); + const resumed = await post("/api/job/input", { action: "continue", details: "Continue validation." }); + assert.equal(resumed.response.status, 202, resumed.body.error); + assert.equal(calls.length, 2); + assert.ok(calls[1].prompt.includes(JSON.stringify(normalizeEngineeringSummary(detail)))); + assert.match(calls[1].prompt, /recorded, not new verification/); + assert.match(calls[1].prompt, /Preserve the original bug-to-fix explanation across retries/); + assert.equal((await jsonRequest(url, "/api/job")).body.job.engineeringSummary.codeFix, detail.codeFix); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("linked node_modules directories are granted only inside trusted dependency roots", () => { const base = mkdtempSync(join(tmpdir(), "fixlab-linked-")); try { From ab128e36b81d2ed6b66efdf410d68d8700f587a7 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Thu, 5 Nov 2026 14:36:07 -0500 Subject: [PATCH 2/4] Close engineering summary lifecycle and normalization gaps Validate trimmed path segments, clear explanation evidence for a fresh restarted job, retain same-job explanations in queue responses, and reject reviewed imports while executor shutdown is unsettled. Extend existing regression cases for all four review findings. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46e778f9-d964-46b0-bebe-3b6f9f71eb74 --- dashboard/server.js | 14 ++++++++++---- test/dashboard-server.test.js | 28 +++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/dashboard/server.js b/dashboard/server.js index 829fea8..849873d 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -585,6 +585,8 @@ function publicQueue(jobs) { position: index + 1, request: safeSummary(job.request), requestType: job.requestType, + engineeringSummary: job.engineeringSummary ?? null, + engineeringSummaryOrigin: job.engineeringSummaryOrigin ?? null, model: job.model, runtime: job.runtime, pullRequestStrategy: job.pullRequestStrategy, @@ -672,8 +674,9 @@ export function normalizeEngineeringSummary(value) { throw new Error(`Engineering summary ${key} must contain at most 20 bounded strings.`); } if (key === "changedFiles" && fields.some((field) => - /^[\\/]/u.test(field) || /[:*?"\0\r\n]/u.test(field) || - field.replace(/\\/gu, "/").split("/").some((part) => !part || part === "." || part === ".."))) { + /^[\\/]/u.test(field.trim()) || /[:*?"\0\r\n]/u.test(field) || + field.trim().replace(/\\/gu, "/").split("/").some((part) => + !part.trim() || part.trim() === "." || part.trim() === ".."))) { throw new Error("Engineering summary files must be relative paths without traversal."); } result[key] = fields.map((field) => safeSummary(field, 500).replace(/https?:\/\/\S+/giu, "[omitted URL]")); @@ -3933,6 +3936,8 @@ export function createDashboardServer({ workspaceStash: null, validationBaseline: null, validationHistory: { complete: true, gates: [] }, + engineeringSummary: null, + engineeringSummaryOrigin: null, finishedAt: null, lastActivityAt: status === "running" ? createdAt : null, error: null, @@ -4956,8 +4961,9 @@ export function createDashboardServer({ } const job = [currentJob, ...heldJobs, ...completedJobs, ...persistedJobs] .find((candidate) => candidate?.id === body.jobId); - if (!job || !["passed", "failed", "blocked", "held", "cancelled"].includes(job.status)) { - throw new Error("Engineering notes require an existing stopped job."); + if (closing || !job || job.executorStopping || (job === currentJob && activeHandle) || + !["passed", "failed", "blocked", "held", "cancelled"].includes(job.status)) { + throw new Error("Engineering notes require an existing stopped job with a settled executor."); } const summary = normalizeEngineeringSummary(body.summary); job.engineeringSummary = summary; diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 23c77c6..354f29e 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -75,7 +75,8 @@ test("engineering summary validates detail fields, redacts sensitive text and re assert.doesNotMatch(JSON.stringify(normalizeEngineeringSummary({ ...detail, rootCause: "See https://private.example.test/path" })), /private\.example/); - for (const changedFiles of [["..\\outside.json"], ["C:\\outside.json"], ["/outside.json"]]) { + for (const changedFiles of [["..\\outside.json"], ["C:\\outside.json"], ["/outside.json"], + [" ../outside.json"], [" /outside.json"], ["folder/.. "], ["folder/ .. /outside.json"]]) { assert.throws(() => normalizeEngineeringSummary({ ...detail, changedFiles }), /relative paths/); } assert.throws(() => normalizeEngineeringSummary({ ...detail, codeFix: "" }), /required/); @@ -5453,6 +5454,10 @@ test("dashboard holds queued and active jobs and resumes the same session", asyn [secondId] ); + executions[0].options.onOutput( + "stdout", + 'FIXLAB_SUMMARY|{"problem":"Removal fails","rootCause":"Missing operation","codeFix":"Retained correction"}\n' + ); executions[0].options.onOutput( "stdout", "FIXLAB_STAGE|intake|passed|Intake retainedFIXLAB_ACTIVITY|diagnosis|Retain partial output" @@ -5495,6 +5500,10 @@ test("dashboard holds queued and active jobs and resumes the same session", asyn resumedActive.body.queue.map((job) => job.id), [firstId] ); + assert.equal(resumedActive.body.queue[0].engineeringSummary.codeFix, "Retained correction"); + assert.equal(resumedActive.body.queue[0].engineeringSummaryOrigin, "agent-reported"); + const reloaded = await jsonRequest(url, "/api/status"); + assert.equal(reloaded.body.queue[0].engineeringSummary.codeFix, "Retained correction"); for (const stage of FIXLAB_STAGES) { executions[1].options.onOutput( @@ -5580,6 +5589,8 @@ test("dashboard restarts a completed job with a new id and session", async () => const executor = (options) => { executions.push(options); if (executions.length === 1) { + options.onOutput("stdout", + 'FIXLAB_SUMMARY|{"problem":"Original bug","rootCause":"Original cause","codeFix":"Original correction"}\n'); for (const stage of FIXLAB_STAGES) { options.onOutput( "stdout", @@ -5624,6 +5635,9 @@ test("dashboard restarts a completed job with a new id and session", async () => assert.notEqual(executions[1].sessionId, originalSessionId); assert.equal(restarted.body.job.status, "running"); assert.equal(restarted.body.history[0].id, originalId); + assert.equal(restarted.body.job.engineeringSummary, null); + assert.equal(restarted.body.job.engineeringSummaryOrigin, null); + assert.equal(restarted.body.history[0].engineeringSummary.codeFix, "Original correction"); assert.equal( restarted.body.job.request, "Restart this completed lifecycle job." @@ -6410,6 +6424,15 @@ test("idle finalization retains the atomic lease and blocks resume until executo body: JSON.stringify({ action: "retry", details: "Retry too soon." }) }); assert.equal(resume.response.status, 409); + const instance = (await jsonRequest(url, "/api/status")).body.instanceId; + const notes = { instanceId: instance, repository, jobId: status.body.job.id, confirmed: true, + summary: { problem: "Removal fails", rootCause: "Missing operation", codeFix: "Reviewed correction" } }; + const importNotes = () => jsonRequest(url, "/api/job/engineering-summary", { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(notes) + }); + const unsettled = await importNotes(); + assert.equal(unsettled.response.status, 400); + assert.match(unsettled.body.error, /settled executor/); finish({ code: null, signal: "SIGTERM" }); for (let attempt = 0; attempt < 40; attempt += 1) { status = await jsonRequest(url, "/api/job"); @@ -6419,6 +6442,9 @@ test("idle finalization retains the atomic lease and blocks resume until executo await new Promise((resolve) => setTimeout(resolve, 10)); } assert.equal(status.body.job.canResume, true); + const settled = await importNotes(); + assert.equal(settled.response.status, 200, settled.body.error); + assert.equal(settled.body.origin, "reviewed-note"); contender.acquire("after-exit").release(); } finally { finish?.({ code: null }); From 4b0cdf317175f04dfe0c4493db53016656af9288 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Thu, 5 Nov 2026 15:00:46 -0500 Subject: [PATCH 3/4] Screen engineering records before persistence and reject malformed frames intact Reuse runtime credential screening for every report field and list item. Only split a summary after valid top-level object JSON and a recognized following marker, keeping malformed arrays and trailing content out of ordinary output logs. Extend existing cases with synthetic credential and malformed-frame regressions. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46e778f9-d964-46b0-bebe-3b6f9f71eb74 --- dashboard/server.js | 20 +++++++++++++++++--- test/dashboard-server.test.js | 12 ++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/dashboard/server.js b/dashboard/server.js index 849873d..89c43d8 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -39,7 +39,7 @@ import { createSessionRegistry } from "./sessions.js"; import { inspectBrowserProfileOwnership } from "./browser-ownership.js"; import { canonicalExecutionRepository, createExecutorLease } from "./executor-lease.js"; import { spawnWindowsExecutor } from "./windows-executor.js"; -import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES, validateRuntimeUsage, validateToolApprovalMode } from "./runtime-requests.js"; +import { createRuntimeRequestBridge, MAX_RUNTIME_FRAME_BYTES, safeRuntimeText, validateRuntimeUsage, validateToolApprovalMode } from "./runtime-requests.js"; import { validateJobFileAccess } from "./runtime-permission-policy.js"; import { configuredWorkflow, efficientWorkflowGuidance, allowsDraftIntegrationDeferral, allowsUnverifiedDraft, UNVERIFIED_DRAFT_GUIDANCE, OPTIONAL_INTEGRATION_GUIDANCE, AUTONOMOUS_RECOVERY_GUIDANCE, DRAFT_INTEGRATION_GUIDANCE, COPILOT_EXECUTION_GUIDANCE, RUNTIME_RECOVERY_GUIDANCE, READ_ONLY_POST_GUIDANCE, EXISTING_APPLICATION_GUIDANCE, API_ONLY_PLAYWRIGHT_GUIDANCE, UNIT_TEST_ONLY_GUIDANCE, TRANSIENT_PLAYWRIGHT_GUIDANCE, CONCISE_PR_GUIDANCE, PR_LEARNING_GUIDANCE } from "./workflow-policy.js"; import { componentRetrievalGuidance } from "./affected-surface.js"; @@ -656,6 +656,9 @@ export function normalizeEngineeringSummary(value) { throw new Error("Engineering summary must contain only the documented bug, fix and evidence fields."); } const result = {}; + const sanitize = (field, limit) => safeRuntimeText(field, limit) + ? safeSummary(field, limit).replace(/https?:\/\/\S+/giu, "[omitted URL]") + : "[Sensitive detail omitted]"; for (const key of strings) { const field = value[key] ?? ""; if (typeof field !== "string" || field.length > 2000 || @@ -665,7 +668,7 @@ export function normalizeEngineeringSummary(value) { if (key === "commit" && field && !/^[a-f0-9]{7,64}$/u.test(field)) { throw new Error("Engineering summary commit must be a hexadecimal commit reference."); } - result[key] = safeSummary(field, 2000).replace(/https?:\/\/\S+/giu, "[omitted URL]"); + result[key] = sanitize(field, 2000); } for (const key of lists) { const fields = value[key] ?? []; @@ -679,7 +682,7 @@ export function normalizeEngineeringSummary(value) { !part.trim() || part.trim() === "." || part.trim() === ".."))) { throw new Error("Engineering summary files must be relative paths without traversal."); } - result[key] = fields.map((field) => safeSummary(field, 500).replace(/https?:\/\/\S+/giu, "[omitted URL]")); + result[key] = fields.map((field) => sanitize(field, 500)); } if (Buffer.byteLength(JSON.stringify(result)) > 16384) { throw new Error("Engineering summary exceeds 16 KiB."); @@ -2673,6 +2676,9 @@ function splitRenderedMarkers(line) { return [...splitRenderedMarkers(line.slice(0, summaryIndex)), ...splitRenderedMarkers(line.slice(summaryIndex))]; } if (summaryIndex === 0) { + if (!line.slice("FIXLAB_SUMMARY|".length).trimStart().startsWith("{")) { + return [line]; + } let depth = 0; let quoted = false; let escaped = false; @@ -2687,7 +2693,15 @@ function splitRenderedMarkers(line) { else if (character === "}") { depth -= 1; if (depth === 0) { + try { + JSON.parse(line.slice("FIXLAB_SUMMARY|".length, index + 1)); + } catch { + return [line]; + } const remainder = line.slice(index + 1).trim(); + if (remainder && !/^FIXLAB_(?:STAGE|ACTIVITY|BUG|TEST|SUMMARY)(?:\||\s)/u.test(remainder)) { + return [line]; + } return [line.slice(0, index + 1), ...(remainder ? splitRenderedMarkers(remainder) : [])]; } } diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 354f29e..949adbe 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -75,6 +75,15 @@ test("engineering summary validates detail fields, redacts sensitive text and re assert.doesNotMatch(JSON.stringify(normalizeEngineeringSummary({ ...detail, rootCause: "See https://private.example.test/path" })), /private\.example/); + for (const sensitive of ["api_key=syntheticCredentialValue123", `ghp_${"A".repeat(36)}`]) { + const sanitized = normalizeEngineeringSummary({ ...detail, + problem: sensitive, expectedBehavior: sensitive, actualBehavior: sensitive, + rootCause: sensitive, codeFix: sensitive, changedFiles: [sensitive], + regression: [sensitive], risks: [sensitive], workarounds: [sensitive] }); + assert.ok(!JSON.stringify(sanitized).includes(sensitive)); + assert.match(sanitized.codeFix, /omitted/); + assert.match(sanitized.regression[0], /omitted/); + } for (const changedFiles of [["..\\outside.json"], ["C:\\outside.json"], ["/outside.json"], [" ../outside.json"], [" /outside.json"], ["folder/.. "], ["folder/ .. /outside.json"]]) { assert.throws(() => normalizeEngineeringSummary({ ...detail, changedFiles }), /relative paths/); @@ -96,6 +105,9 @@ test("engineering summaries survive history and restart, and reviewed notes cann if (executions.length === 1) { input.onOutput("stdout", `FIXLAB_SUMMARY|${JSON.stringify(detail)}FIXLAB_STAGE|intake|skipped|Fixture.\n`); input.onOutput("stdout", "FIXLAB_SUMMARY|{private-sentinel-invalid-json\n"); + input.onOutput("stdout", `FIXLAB_SUMMARY|${JSON.stringify([detail, + { codeFix: "api_key=private-sentinel-array" }])}\n`); + input.onOutput("stdout", `FIXLAB_SUMMARY|${JSON.stringify(detail)} private-sentinel-trailing-text\n`); for (const stage of FIXLAB_STAGES.filter((stage) => stage !== "intake")) { input.onOutput("stdout", `FIXLAB_STAGE|${stage}|skipped|Fixture.\n`); } From d659544952c8a8914e9588920814226a8a000c02 Mon Sep 17 00:00:00 2001 From: Monalisa Biswal Date: Thu, 5 Nov 2026 15:10:22 -0500 Subject: [PATCH 4/4] Report engineering notes excluded by bounded history retention Return an explicit persistence failure when the selected stopped job falls outside the retained snapshot window, instead of falsely reporting durable success. Verify the live-completed-job edge case against the actual persisted history in a focused existing-suite regression. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 46e778f9-d964-46b0-bebe-3b6f9f71eb74 --- dashboard/server.js | 2 +- test/dashboard-server.test.js | 44 +++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/dashboard/server.js b/dashboard/server.js index 89c43d8..e9030d4 100644 --- a/dashboard/server.js +++ b/dashboard/server.js @@ -4983,7 +4983,7 @@ export function createDashboardServer({ job.engineeringSummary = summary; job.engineeringSummaryOrigin = "reviewed-note"; persistDashboardState(); - if (dashboardHistoryWarning) { + if (dashboardHistoryWarning || !persistedJobs.some((snapshot) => snapshot.id === job.id)) { sendJson(response, 503, { error: "Engineering notes are retained in memory but could not be persisted." }); return; } diff --git a/test/dashboard-server.test.js b/test/dashboard-server.test.js index 949adbe..d771403 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -38,6 +38,7 @@ import { MAX_JOB_ACTIVITY_ENTRIES, MAX_CACHE_BYTES, MAX_CACHE_ENTRIES, + MAX_DASHBOARD_HISTORY_ENTRIES, MAX_JOB_LOG_ENTRIES, openLocalUrl, normalizeEngineeringSummary, @@ -166,6 +167,49 @@ test("engineering summaries survive history and restart, and reviewed notes cann } }); +test("engineering notes report when the target falls outside the persisted history window", async () => { + const repository = createGitRepository(); + let executions = 0; + const executor = (input) => { + executions += 1; + if (executions === 1) { + for (const stage of FIXLAB_STAGES) { + input.onOutput("stdout", `FIXLAB_STAGE|${stage}|skipped|Fixture.\n`); + } + return { completion: Promise.resolve({ code: 0 }), terminate() {} }; + } + return { completion: new Promise(() => {}), terminate() {} }; + }; + const { dashboard, url } = await startDashboard(repository, executor); + const post = (path, body) => jsonRequest(url, path, { + method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) + }); + try { + const first = await post("/api/jobs", { request: "Older completed job.", mode: "validate-only" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + await post("/api/jobs", { request: "Active job.", mode: "validate-only" }); + for (let index = 0; index < MAX_DASHBOARD_HISTORY_ENTRIES - 1; index += 1) { + const queued = await post("/api/jobs", { request: `Queued job ${index}.`, mode: "validate-only" }); + assert.equal(queued.response.status, 202, queued.body.error); + const held = await post("/api/jobs/hold", { jobId: queued.body.queuedJob.id }); + assert.equal(held.response.status, 202, held.body.error); + } + const state = (await jsonRequest(url, "/api/status")).body; + const result = await post("/api/job/engineering-summary", { + instanceId: state.instanceId, repository, jobId: first.body.job.id, confirmed: true, + summary: { problem: "Older bug", rootCause: "Original cause", codeFix: "Reviewed correction" } + }); + assert.equal(result.response.status, 503, result.body.error); + assert.match(result.body.error, /could not be persisted/); + const saved = JSON.parse(readFileSync(join(dirname(createCacheContext(repository).cachePath), + "dashboard-jobs.json"), "utf8")); + assert.ok(!saved.jobs.some((job) => job.id === first.body.job.id)); + } finally { + await dashboard.close(); + rmSync(repository, { recursive: true, force: true }); + } +}); + test("editing resumes retain the original detailed bug-to-code-fix explanation", async () => { const repository = createRepository(); const detail = { problem: "Removal fails", rootCause: "Missing contract operation",