diff --git a/dashboard/public/app.js b/dashboard/public/app.js index fcdb969..11146b1 100644 --- a/dashboard/public/app.js +++ b/dashboard/public/app.js @@ -11,6 +11,63 @@ const stageNames = [ "pr" ]; +function summaryText(value) { + const result = String(value ?? "").replace(/\s+/gu, " ").trim().slice(0, 1000); + if (/(?:password|passwd|secret|token|authorization|bearer|cookie|connection.?string|private.?key)/iu.test(result)) { + return "[Sensitive detail omitted]"; + } + return result.replace(/https?:\/\/\S+/giu, "[Link omitted]") || "Not verified"; +} + +function jobSummarySections(job) { + const gates = job.validation?.gates ?? []; + const unresolved = stageNames.filter((name) => job.stages?.[name]?.status !== "passed"); + const evidence = job.testEvidence; + const interim = ["running", "queued", "holding"].includes(job.status); + const stage = (name) => { + const recorded = job.stages?.[name]; + return `${name}: ${summaryText(recorded?.status)} - ${summaryText(recorded?.message)}`; + }; + return [ + { heading: interim ? "Interim outcome" : "Recorded outcome", lines: [ + `Job: ${summaryText(job.id)} | Status: ${summaryText(job.status)}`, + `Request: ${summaryText(job.request)}`, + `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: [ + stage("intake"), stage("diagnosis"), stage("reproduce"), stage("fix"), stage("review"), + "Exact changed-file and commit inventory: Not verified by this summary." + ] }, + { heading: "Tests and regression", lines: [ + ...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.`, + `Agent-reported scenario: ${evidence?.reported ? summaryText(evidence.scenario) : "Not verified"}`, + `Data source: ${summaryText(evidence?.source)} | Mutation mode: ${summaryText(evidence?.mutationMode)}`, + stage("local-stack"), stage("live-test"), + "Agent stage/scenario messages are not independent runner proof. Synthetic or intercepted tests do not prove real backend writes." + ] }, + { heading: "Findings and must-know", lines: [ + ...(job.bugs ?? []).map((bug) => `${summaryText(bug.id)}: ${summaryText(bug.outcome)} | Owner: ${summaryText(bug.owner)} | ${summaryText(bug.summary)}`), + ...((job.bugs ?? []).length ? [] : ["Per-bug findings: Not verified."]), + `Publication: ${stage("pr")}`, + `PR readiness: ${summaryText(job.pullRequestReadiness?.message)}`, + "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.", + "Do not infer that no risks exist merely because no finding was recorded." + ] }, + { heading: "Remaining gaps and next actions", lines: [ + ...unresolved.map(stage), + ...gates.filter((gate) => gate.status !== "passed").map((gate) => `Check ${summaryText(gate.id)}: ${summaryText(gate.status)}${gate.reason ? ` | ${summaryText(gate.reason)}` : ""}`), + ...(job.error ? [`Recorded error: ${summaryText(job.error)}`] : []), + "Review unverified scenarios, missing evidence and recorded blockers before claiming full coverage. No checks were run by opening this summary." + ] } + ]; +} + const form = document.querySelector("#job-form"); const startButton = document.querySelector("#start-button"); const formError = document.querySelector("#form-error"); @@ -930,6 +987,44 @@ let selectedJobId = null; let pendingRetryAfterConnectJobId = null; let playwrightAuthenticationRunning = false; const observedJobs = new Map(); +const summaryButton = document.querySelector("#job-summary-open"); +const summaryDialog = document.querySelector("#job-summary-dialog"); +const summaryContent = document.querySelector("#job-summary-content"); +let summarySelection = null; + +function renderSummary() { + summaryContent.replaceChildren(); + const job = summarySelection?.instanceId === dashboardInstanceId + ? observedJobs.get(summarySelection.jobId) : null; + if (!job) { + summaryContent.textContent = "This job's evidence is unavailable or the dashboard changed. Close and reopen the summary for the selected job."; + return; + } + for (const section of jobSummarySections(job)) { + const heading = document.createElement("h3"); + heading.textContent = section.heading; + const list = document.createElement("ul"); + for (const line of section.lines) { + const item = document.createElement("li"); + item.textContent = line; + list.append(item); + } + summaryContent.append(heading, list); + } +} + +summaryButton.addEventListener("click", () => { + if (draftingConversation || !observedJobs.has(selectedJobId)) {return;} + summarySelection = { instanceId: dashboardInstanceId, jobId: selectedJobId }; + renderSummary(); + summaryDialog.showModal(); +}); +document.querySelector("#job-summary-close").addEventListener("click", () => summaryDialog.close()); +summaryDialog.addEventListener("close", () => { + summarySelection = null; + summaryContent.replaceChildren(); + summaryButton.focus(); +}); const expandedJobIds = new Set(); let renderedEvidenceJobId = null; let playwrightEvidenceRequestId = 0; @@ -1364,6 +1459,8 @@ function inferActiveStageFromLogs(logs = []) { } function renderJob(job, currentActiveJob = job) { + summaryButton.disabled = !job; + if (summaryDialog.open) {renderSummary();} currentJobFullscreen.disabled = !currentActiveJob; currentJobFullscreen.hidden = !currentActiveJob; const currentJobTitle = document.querySelector("#current-job-dialog-title"); diff --git a/dashboard/public/index.html b/dashboard/public/index.html index c15a713..154129a 100644 --- a/dashboard/public/index.html +++ b/dashboard/public/index.html @@ -38,6 +38,7 @@

FixLab

Checking repository…
+
@@ -541,6 +542,14 @@

Raw logs

+ +
+

Job summary

+ +
+

Recorded evidence for the selected job, refreshed while open. No AI call, test execution, file creation or job changes. Missing findings remain unverified.

+
+

Current job details and evidence

diff --git a/dashboard/public/styles.css b/dashboard/public/styles.css index c2f36b4..70acf43 100644 --- a/dashboard/public/styles.css +++ b/dashboard/public/styles.css @@ -1367,6 +1367,23 @@ pre { animation: none; } } +#job-summary-dialog { + width: min(850px, calc(100vw - 2rem)); + max-height: calc(100dvh - 2rem); + box-sizing: border-box; + overflow: auto; + overflow-wrap: anywhere; + padding: 1.5rem; + border: 1px solid #d8d6e8; + border-radius: 12px; + color: #282638; + background: #f7f7fc; +} + +#job-summary-content li { + margin-bottom: 0.5rem; +} + #current-job-dialog { width: 100vw; max-width: 100vw; @@ -1380,7 +1397,8 @@ pre { background: #f7f7fc; } -#current-job-dialog::backdrop { +#current-job-dialog::backdrop, +#job-summary-dialog::backdrop { background: rgb(20 18 40 / 45%); } diff --git a/dashboard/repository-setup.js b/dashboard/repository-setup.js index 6280744..afe2f0d 100644 --- a/dashboard/repository-setup.js +++ b/dashboard/repository-setup.js @@ -3,7 +3,7 @@ import { dirname, isAbsolute, join, parse, relative, resolve, sep } from "node:p import { canonicalExecutionRepository } from "./executor-lease.js"; export const SETUP_PROFILE_PATH = join(".github", "fixlab", "repository-profile.json"); -export const REPOSITORY_PROMPT_NAMES = ["bugfix", "intake", "diagnose", "reproduce", "fix", "validate", "live-test", "pr"] +export const REPOSITORY_PROMPT_NAMES = ["bugfix", "intake", "diagnose", "reproduce", "fix", "validate", "live-test", "pr", "summary"] .map((name) => `fixlab.${name}.prompt.md`); export function localRepository(value) { diff --git a/docs/commands.md b/docs/commands.md index 6708690..1178e69 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -63,11 +63,35 @@ overwriting repository-owned versions. | `/fixlab.validate` | Run focused tests, lint, type-check, and builds | Every required check passes or is explicitly skipped with risk | | `/fixlab.live-test` | Start owned applications and run the browser journey | The running application proves the expected behavior | | `/fixlab.pr` | Prepare the evidence-backed pull-request outcome | The PR contains the exact change, evidence, and remaining risks | +| `/fixlab.summary` | Optionally summarize an existing job in the conversation | Actual outcomes, regression gaps, findings, workarounds and must-know items are explicit; no execution or file changes | The commands read `.github/fixlab/repository-profile.json` and repository instructions before acting. They never replace repository-owned commands, environment controls, or approval requirements. +For an optional engineering handoff, run `/fixlab.summary` in the VS Code +conversation containing the job evidence. It is never added to the automatic +bug-fix lifecycle. The prompt returns Markdown without rerunning checks, +changing job status, creating files or publishing changes. Active-job summaries +are interim; unavailable results are marked "Not verified". It distinguishes +runner-confirmed results from agent claims, local from deployed validation, and +known findings from suspected side effects. A folder or ZIP is a separate request. + +Existing repositories can rerun `fixlab init` after updating FixLab to add the +missing prompt without replacing their profile, agents or customized prompts. +Other conversation hosts can paste the packaged +[`fixlab.summary.prompt.md`](../prompts/fixlab.summary.prompt.md) into the +same evidence-bearing conversation; this is a prompt file, not a new CLI +subcommand or a dashboard export button. + +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 +handoff with richer context, use the optional prompt in its original conversation. + ## Executor-backed dashboard checks `fixlab gate --port --job --gate --repository ` diff --git a/e2e/dashboard-readiness.spec.js b/e2e/dashboard-readiness.spec.js index fbd53d3..8b58621 100644 --- a/e2e/dashboard-readiness.spec.js +++ b/e2e/dashboard-readiness.spec.js @@ -2347,6 +2347,75 @@ function pendingQuestionJob(question) { return job; } +test("job summary shows recorded findings and gaps without execution, and refreshes the same job", async ({ page }) => { + const posts = []; + page.on("request", (request) => { if (request.method() === "POST") { posts.push(request.url()); } }); + const job = { + id: "summary-current", request: "Investigate a synthetic regression", requestType: "bug-fix", + mode: "fix-and-validate", status: "running", targetEnvironment: "dev", + stages: { + diagnosis: { status: "passed", message: "Confirmed route mismatch" }, + fix: { status: "pending", message: "Correction not applied" }, + "live-test": { status: "blocked", message: "Authentication unavailable" } + }, + 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" }], + logs: [{ stream: "stdout", message: "Raw log sentinel must not enter summary" }], activity: [] + }; + await mockChatWorkspace(page, { job }); + await page.goto(baseUrl); + await page.getByRole("button", { name: "View summary", exact: true }).click(); + const summary = page.locator("#job-summary-content"); + await expect(summary).toContainText("Interim outcome"); + await expect(summary).toContainText("Confirmed route mismatch"); + 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).not.toContainText("Raw log sentinel"); + job.status = "passed"; + job.validation.gates[0].status = "stale"; + await expect(summary).toContainText("Recorded outcome"); + await expect(summary).toContainText("regression-tests: stale (required)"); + await expect(summary).toContainText("Job completion is not a claim of complete regression coverage"); + await page.getByRole("button", { name: "Close summary" }).click(); + await expect(page.locator("#job-summary-dialog")).toBeHidden(); + await expect(page.getByRole("button", { name: "View summary", exact: true })).toBeFocused(); + expect(posts).toEqual([]); +}); + +test("job summary follows historical selection, handles missing evidence and renders text safely", async ({ page }) => { + const posts = []; + page.on("request", (request) => { if (request.method() === "POST") { posts.push(request.url()); } }); + const job = { id: "summary-active", request: "Active summary sentinel", requestType: "bug-fix", + mode: "fix-and-validate", status: "running", stages: {}, bugs: [], logs: [], activity: [] }; + const saved = { ...job, id: "summary-history", status: "held", + request: 'Saved https://private.example.test/path', + stages: { review: { status: "blocked", message: "authorization=private-sentinel" } } }; + await mockChatWorkspace(page, { job, history: [saved] }); + await page.goto(baseUrl); + await page.locator('.workspace-navigation [data-open="memory-group"]').click(); + await page.locator("#repository-memory-list button").click(); + await page.setViewportSize({ width: 390, height: 844 }); + await page.getByRole("button", { name: "View summary", exact: true }).click(); + const summary = page.locator("#job-summary-content"); + 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("[Sensitive detail omitted]"); + await expect(summary).not.toContainText("private.example.test"); + await expect(summary).not.toContainText("private-sentinel"); + await expect(summary.locator("img")).toHaveCount(0); + expect(await page.evaluate(() => window.summaryInjection)).toBeUndefined(); + const overflow = await page.locator("#job-summary-dialog").evaluate((element) => + element.scrollWidth > element.clientWidth); + expect(overflow).toBe(false); + await page.keyboard.press("Escape"); + await expect(page.locator("#job-summary-dialog")).toBeHidden(); + expect(posts).toEqual([]); +}); + test("current-job full screen ignores historical selection, follows the live job and restores the prior view", async ({ page }) => { const posts = []; let historicalEvidence; diff --git a/prompts/fixlab.summary.prompt.md b/prompts/fixlab.summary.prompt.md new file mode 100644 index 0000000..15b25f5 --- /dev/null +++ b/prompts/fixlab.summary.prompt.md @@ -0,0 +1,47 @@ +--- +mode: agent +description: Optionally summarize an existing FixLab job without running checks or changing files. +--- + +Generate an engineering handoff for the FixLab job in this conversation using +existing session evidence only. This is an optional summary, not a new fix or +validation job. If the job is ambiguous or its evidence is unavailable, ask for +the job identifier and relevant sanitized results; do not infer them from a +different job. + +Do not rerun tests, install tools, start applications, access live systems, +change files, commit, publish, or alter job status. Do not invoke the bug-fix +workflow. Return Markdown in the conversation only; a folder or ZIP requires +a separate explicit request and normal permissions. + +Include these sections, marking missing evidence "Not verified": + +1. Outcome: original bug, expected versus actual behavior, job identifier, + repository/commit and PR references when safe, and whether the result is + fixed and verified, fixed but unverified, workaround-only, external, or + unresolved. For an active job, label the summary interim. +2. Diagnosis and changes: evidence-backed root cause, affected components, + changed files and the rationale for the correction. Explain no-change + outcomes honestly. +3. Test and regression report: a table of scenarios/assertions, exact safe + commands and working directories, recorded outcomes/durations, and evidence + references. Separate the original failing reproduction from subsequent + verification, focused tests from broad regression, and runner-confirmed + results from agent-reported claims. Preserve failed, skipped, timed-out, + blocked, pending, stale, not-run and deferred checks; never mark them passed. +4. Findings and must-know: verified tradeoffs, compatibility concerns, + maintenance traps and prerequisites. Separate pre-existing defects from + confirmed fix-induced regressions; label suspected side effects as + hypotheses, not findings. +5. Workarounds: only recorded workarounds, their validation, limitations, + conditions for removal, and known ownership. Do not invent one. +6. Remaining risks and next actions: untested scenarios, missing data/access, + evidence gaps and concrete follow-up. A complete summary does not imply + complete regression coverage. + +Distinguish local changed-code validation from deployed-system checks, +synthetic/intercepted tests from real integration, and draft publication from +ready-for-review or merge. Reference existing reviewed evidence instead of +pasting raw logs or transcripts. Exclude credentials, authorization headers, +cookies, browser state, private endpoints, personal/customer data, unreviewed +screenshots and model-cost/token metrics. Omit or redact sensitive references. diff --git a/test/dashboard-repository-settings.test.js b/test/dashboard-repository-settings.test.js index 4ab2c83..26c636f 100644 --- a/test/dashboard-repository-settings.test.js +++ b/test/dashboard-repository-settings.test.js @@ -32,7 +32,7 @@ test("repository onboarding previews without writes and creates only missing rev assert.equal(localRepository(repository), repository); const preview = plan(repository); assert.equal(existsSync(join(repository, ".github")), false); - assert.equal(preview.files.length, 10); + assert.equal(preview.files.length, 11); const applied = applyRepositorySettings(preview, "synthetic-settings-owner"); assert.equal(applied.saved, true); const profile = JSON.parse(readFileSync(join(repository, SETUP_PROFILE_PATH), "utf8")); @@ -46,6 +46,10 @@ test("repository onboarding previews without writes and creates only missing rev assert.equal(configuredTestSynthesis(profile, "", undefined, "sit").source, "synthetic-intercepted"); assert.equal(validateLiveTestProfile(profile).ok, true); assert.equal(existsSync(join(repository, ".github", "agents", "fixlab-autofix.agent.md")), true); + assert.equal( + readFileSync(join(repository, ".github", "prompts", "fixlab.summary.prompt.md"), "utf8"), + readFileSync(join(packageRoot, "prompts", "fixlab.summary.prompt.md"), "utf8") + ); assert.equal(existsSync(canonicalExecutionRepository(repository).directory), false); assert.equal(plan(repository).assets.length, 0); } finally { diff --git a/test/fixlab-cli.test.js b/test/fixlab-cli.test.js index d6b2d07..5425516 100644 --- a/test/fixlab-cli.test.js +++ b/test/fixlab-cli.test.js @@ -422,7 +422,8 @@ test("init creates a parseable repository profile", () => { "fixlab.fix.prompt.md", "fixlab.validate.prompt.md", "fixlab.live-test.prompt.md", - "fixlab.pr.prompt.md" + "fixlab.pr.prompt.md", + "fixlab.summary.prompt.md" ]; for (const promptName of expectedPrompts) { assert.equal(existsSync(join(promptDirectory, promptName)), true); @@ -446,6 +447,10 @@ test("init creates a parseable repository profile", () => { ); assert.match(bugfixPrompt, /agent: fixlab-autofix/); assert.match(bugfixPrompt, /smallest complete correction/); + assert.equal( + readFileSync(join(promptDirectory, "fixlab.summary.prompt.md"), "utf8"), + readFileSync(new URL("../prompts/fixlab.summary.prompt.md", import.meta.url), "utf8") + ); } finally { rmSync(repository, { recursive: true, force: true }); } @@ -466,7 +471,7 @@ test("init preserves existing profile and prompt files", () => { repository, ".github", "prompts", - "fixlab.validate.prompt.md" + "fixlab.summary.prompt.md" ); const agentPath = join( repository,