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..e9030d4 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"; @@ -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, @@ -646,6 +648,51 @@ 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 = {}; + 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 || + (["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] = sanitize(field, 2000); + } + 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.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) => sanitize(field, 500)); + } + 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 +782,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 +1396,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 +1486,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 +2381,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 +2671,65 @@ 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) { + if (!line.slice("FIXLAB_SUMMARY|".length).trimStart().startsWith("{")) { + return [line]; + } + 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) { + 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) : [])]; + } + } + } + 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 +3127,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. @@ -3844,6 +3950,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, @@ -4850,6 +4958,42 @@ 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 (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; + job.engineeringSummaryOrigin = "reviewed-note"; + persistDashboardState(); + 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; + } + 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..d771403 100644 --- a/test/dashboard-server.test.js +++ b/test/dashboard-server.test.js @@ -38,8 +38,10 @@ import { MAX_JOB_ACTIVITY_ENTRIES, MAX_CACHE_BYTES, MAX_CACHE_ENTRIES, + MAX_DASHBOARD_HISTORY_ENTRIES, MAX_JOB_LOG_ENTRIES, openLocalUrl, + normalizeEngineeringSummary, validateCopilotModel, validateLiveTestProfile } from "../dashboard/server.js"; @@ -56,6 +58,194 @@ 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 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/); + } + 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"); + 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`); + } + 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("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", + 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 { @@ -5320,6 +5510,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" @@ -5362,6 +5556,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( @@ -5447,6 +5645,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", @@ -5491,6 +5691,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." @@ -6277,6 +6480,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"); @@ -6286,6 +6498,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 });