diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css index 4ba2f67586..6852f7c918 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-loopx-mode.css @@ -122,7 +122,8 @@ li[data-check="blocked"] .goal-team-check-pill { border-color: transparent; back .goal-team-comparison select { min-height: 44px; padding: 8px; background: var(--pw-surface, #fff); color: inherit; border: 1px solid var(--pw-line, #ebebeb); border-radius: 6px; } @media (max-width: 640px) { .goal-team-comparison-grid { grid-template-columns: 1fr; } } -.goal-team-artifact > header { display: flex; justify-content: space-between; align-items: center; gap: 16px; margin-bottom: 16px; } +.goal-team-artifact > header { display: flex; flex-wrap: wrap; justify-content: space-between; align-items: center; gap: 12px; margin-bottom: 16px; } +.goal-team-artifact-actions { display: flex; flex-wrap: wrap; gap: 8px; } .goal-team-artifact > header h4 { font-size: var(--text-caption); line-height: var(--leading-caption); color: var(--pw-muted); font-weight: 500; } .goal-team-report { padding: 24px; background: var(--pw-surface, #fff); color: var(--pw-text, #171717); overflow-wrap: anywhere; } .goal-team-report .personal-md { font-size: var(--text-lead); line-height: var(--leading-prose); gap: 20px; } diff --git a/apps/presentation/dashboard/src/features/personal-workspace/report-download.test.mjs b/apps/presentation/dashboard/src/features/personal-workspace/report-download.test.mjs new file mode 100644 index 0000000000..01227ceb49 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/report-download.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import {test} from "node:test"; +import {downloadReport, reportDownloadName} from "./report-download.ts"; + +const hash = "abcdef0123456789".repeat(4); +test("download filenames are bounded versioned leaf names with inert extensions", () => { + assert.equal(reportDownloadName("reports/结论.md", hash), "report-结论-abcdef012345.md"); + assert.equal(reportDownloadName("C:\\private\\CON.exe", hash), "report-CON-abcdef012345.txt"); + for (const ref of ["../../.html", "x\u202Eexe.md", "a\r\n\"<>:|?.js", "".padEnd(1000, "x")]) { + const name = reportDownloadName(ref, hash); + assert.match(name, /^report-[\p{L}\p{N}_-]+-abcdef012345\.(txt|md)$/u); + assert.ok(name.length < 110); + } + assert.notEqual(reportDownloadName("report.json", hash), reportDownloadName("report.json", "b".repeat(64))); +}); + +test("repeated exports preserve exact Unicode/CRLF bytes and release object URLs even on interruption", async () => { + const original = {window: globalThis.window, document: globalThis.document, + create: URL.createObjectURL, revoke: URL.revokeObjectURL}; + const blobs = [], names = [], removed = [], released = [], timers = []; + let fail = false; + globalThis.window = {setTimeout: callback => timers.push(callback)}; + globalThis.document = {body: {append() {}}, createElement: () => ({ + click() {names.push(this.download); if (fail) throw new Error("interrupted");}, + remove() {removed.push(true);}, + })}; + URL.createObjectURL = blob => {blobs.push(blob); return `blob:${blobs.length}`;}; + URL.revokeObjectURL = url => released.push(url); + try { + const text = "# 结论 🧪\r\n\r\n & unmodified\n"; + downloadReport("a.md", hash, text); + fail = true; + assert.throws(() => downloadReport("a.md", hash, text), /interrupted/); + fail = false; + downloadReport("new.json", "b".repeat(64), '{"revision":2}\n'); + assert.deepEqual(new Uint8Array(await blobs[0].arrayBuffer()), new TextEncoder().encode(text)); + assert.equal(await blobs[1].text(), text); + assert.equal(await blobs[2].text(), '{"revision":2}\n'); + assert.equal(removed.length, 3); + assert.equal(released.length, 0, "Browser must consume URLs before cleanup"); + timers.forEach(callback => callback()); + assert.deepEqual(released, ["blob:1", "blob:2", "blob:3"]); + assert.notEqual(names[0], names[2]); + } finally { + globalThis.window = original.window; globalThis.document = original.document; + URL.createObjectURL = original.create; URL.revokeObjectURL = original.revoke; + } +}); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/report-download.ts b/apps/presentation/dashboard/src/features/personal-workspace/report-download.ts new file mode 100644 index 0000000000..78a8afebb1 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/report-download.ts @@ -0,0 +1,25 @@ +/** Keep a saved readback recognizable without treating its ref as a filesystem path. */ +export function reportDownloadName(ref: string, sha256: string): string { + const leaf = ref.split(/[\\/]/).pop() || "report"; + const extension = /\.(md|markdown|json|txt)$/i.exec(leaf)?.[1].toLowerCase() || "txt"; + const stem = leaf.replace(/\.[^.]*$/, "").normalize("NFC") + .replace(/[^\p{L}\p{N}_-]+/gu, "-").replace(/^-+|-+$/g, "").slice(0, 80) || "report"; + const version = /^[a-f0-9]{64}$/i.test(sha256) ? sha256.slice(0, 12).toLowerCase() : "readback"; + return `report-${stem}-${version}.${extension}`; +} + +/** Save exactly the displayed UTF-8 source; no network request or owner mutation. */ +export function downloadReport(ref: string, sha256: string, text: string): void { + const url = URL.createObjectURL(new Blob([text], {type: "text/plain;charset=utf-8"})); + const link = document.createElement("a"); + try { + link.href = url; + link.download = reportDownloadName(ref, sha256); + document.body.append(link); + link.click(); + } finally { + link.remove(); + // Allow the browser to consume the URL before releasing this bounded copy. + window.setTimeout(() => URL.revokeObjectURL(url), 1000); + } +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx index 27354047e8..f97d32e301 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/team-artifact-content.tsx @@ -1,6 +1,7 @@ import {useState} from "react"; import type {DelegationReadback} from "../../data/chat"; import {MarkdownText} from "./markdown.js"; +import {downloadReport} from "./report-download"; export type TeamArtifact = NonNullable[number]; export const isMarkdownArtifact = (ref: string) => /\.(md|markdown)$/i.test(ref); @@ -22,10 +23,19 @@ export function TeamArtifactContent({artifact, label, raw = false}: {artifact: T export function TeamArtifactReport({artifact, zh, heading = artifact.ref}: {artifact: TeamArtifact; zh: boolean; heading?: string}) { const [raw, setRaw] = useState(false); + const [downloadError, setDownloadError] = useState(false); + function download() { + setDownloadError(false); + try {downloadReport(artifact.ref, artifact.sha256, artifact.text);} + catch {setDownloadError(true);} + } return
-

{heading}

{isMarkdownArtifact(artifact.ref) ? : null}
+ : null} + {downloadError ?

{zh ? "无法开始下载,请重试。" : "Could not start the download. Please retry."}

: null}
{zh ? "产物版本" : "Artifact version"}{artifact.sha256}
; diff --git a/docs/guides/personal-workspace-user-guide.md b/docs/guides/personal-workspace-user-guide.md index 360e53797f..3f747de6fa 100644 --- a/docs/guides/personal-workspace-user-guide.md +++ b/docs/guides/personal-workspace-user-guide.md @@ -654,3 +654,19 @@ background. Opening Chat or explicitly sending a message still prepares it through the existing session owner. ![快速导航:中文窄屏](../assets/personal-workspace/command-palette-narrow.png) + +### 保存团队报告原文 / Save a team report source + +在成果、对话或证据视图打开团队报告后,点击 **下载原文 / Download source**, +保存当前显示版本的 UTF-8 原文。文件名包含版本摘要;Markdown、JSON 和文本保留 +相应扩展名,其他类型按文本保存。下载不会重新运行 Agent、核验或采用报告,也不会 +修改 Goal。它是所读版本的本地副本,不代表后续版本仍获验收;报告读取失败或失效时, +先通过原有刷新/重试恢复可读内容。 + +Open a team report in Results, conversation or evidence, then choose **Download source** +to save the displayed version's exact UTF-8 source. The filename includes a version +prefix. Markdown, JSON and text retain their extensions; other types download as text. +Saving reuses the existing readback without running an Agent, invoking validation, +recording adoption or changing the Goal. The file is a local copy of that version, +not a promise of continuing acceptance. If the reader becomes unavailable, use its +existing refresh/retry before downloading again. diff --git a/examples/managed-research-team/synthetic_fixture.py b/examples/managed-research-team/synthetic_fixture.py new file mode 100644 index 0000000000..35ebe702c6 --- /dev/null +++ b/examples/managed-research-team/synthetic_fixture.py @@ -0,0 +1,38 @@ +"""Shared synthetic research outputs for tests and dependency-free browser fixtures.""" +from hashlib import sha256 +import json +from pathlib import Path + +from scenario import assignments, encoded, evidence + + +def fixture(root: Path) -> dict: + dependencies = {} + for member in assignments(root): + worker, revision = member["worker"], member["revision"] + raw, normalized, stale, source = { + "initial": (90, 40, False, "filing-initial"), + "corrected": (75, 25, True, "filing-correction"), + }[revision] + work = root / worker / revision + work.mkdir(parents=True, exist_ok=True) + input_bytes = encoded(evidence(revision)) + (work / "input.json").write_bytes(input_bytes) + output = {"revision": revision, "input_sha256": sha256(input_bytes).hexdigest(), + "raw_fcf": raw, "normalized_fcf": normalized, "period_comparable": False, + "growth_supported": False, "independent_source_families": 1, "repost_stale": stale, + "source_refs": [source, "prior-filing", "repost"], "reason": "Different periods; one source family."} + if member.get("upstream"): + previous = json.loads((root / member["upstream"] / "output.json").read_text()) + output["adopted_dependencies"] = {member["upstream"]: sha256(encoded(previous)).hexdigest()} + (work / "output.json").write_bytes(encoded(output)) + (root / "accepted").mkdir(exist_ok=True) + (root / "accepted" / (worker + "-" + revision + ".json")).write_bytes(encoded({"turn_status": "committed", "evidence": output})) + dependencies[worker + "/" + revision] = sha256(encoded(output)).hexdigest() + report = {"initial_normalized_fcf": 40, "corrected_normalized_fcf": 25, "revision_delta": -15, + "growth_supported": False, "independent_source_families": 1, + "repost_stale_after_correction": True, "dependencies": dependencies, + "reason": "Correction reduces normalized cash; growth is unsupported."} + (root / "lead").mkdir(exist_ok=True) + (root / "lead" / "report.json").write_bytes(encoded(report)) + return report diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index b6dd207836..c81d392fd2 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -1,4 +1,5 @@ #!/usr/bin/env node +import {reportDownloadScenario} from "./personal-workspace-browser/report-download.mjs"; import { selectScenarioShard } from "./personal-workspace-browser/shard.mjs"; import {completedHistoryIsolationScenario} from "./personal-workspace-browser/completed-history-isolation.mjs"; import {researchWorkerReadbackScenario} from "./personal-workspace-browser/research-worker-readback.mjs"; @@ -97,6 +98,7 @@ import { conversationStartupScenario } from "./personal-workspace-browser/conver import { chatTodoProposalScenario } from "./personal-workspace-browser/chat-todo-proposal.mjs"; const scenarioCatalog = [conversationStartupScenario,goalDraftScenario, capabilityScopeScenario, stewardGroupTriggerScenario, conversationInputScenario, goalActivityScenario, conversationActivityScenario, navigationSortingScenario, automationCadenceScenario, chatRecoveryScenario, composerSessionAdmissionScenario, attachedHostFollowUpScenario, conversationReturnContinuityScenario, conversationHistoryRecoveryScenario, answerPresentationScenario, loopxModeScenario, teamEvidenceScenario, managedGoalResultsScenario, typedActionsScenario, teamPlanScenario, stewardJourneyScenario, executionChipScenario, stewardModelSettingsScenario, progressiveLoadingScenario, workspaceLocaleScenario, newestDraftScenario, larkCliMissingScenario, executionServiceOfflineScenario, chatTodoProposalScenario]; +scenarioCatalog.push(reportDownloadScenario); scenarioCatalog.push(confirmedOperationsScenario); scenarioCatalog.push(proposalReadbackScopeScenario); scenarioCatalog.push(goalDeletionScenario); diff --git a/examples/personal-workspace-browser/assets/report-download/desktop.png b/examples/personal-workspace-browser/assets/report-download/desktop.png new file mode 100644 index 0000000000..2786327830 Binary files /dev/null and b/examples/personal-workspace-browser/assets/report-download/desktop.png differ diff --git a/examples/personal-workspace-browser/assets/report-download/mobile.png b/examples/personal-workspace-browser/assets/report-download/mobile.png new file mode 100644 index 0000000000..78b2fbe742 Binary files /dev/null and b/examples/personal-workspace-browser/assets/report-download/mobile.png differ diff --git a/examples/personal-workspace-browser/managed-goal-results.mjs b/examples/personal-workspace-browser/managed-goal-results.mjs index e387e4c533..b678891426 100644 --- a/examples/personal-workspace-browser/managed-goal-results.mjs +++ b/examples/personal-workspace-browser/managed-goal-results.mjs @@ -53,6 +53,7 @@ export const managedGoalResultsScenario = { await page.screenshot({path: resolve(outputDir, "managed-goal-results-desktop.png"), animations: "disabled"}); await page.setViewportSize({width: 390, height: 844}); assert(await results.evaluate(el => el.scrollWidth <= el.clientWidth), "Files report must fit a phone"); + await results.getByRole("button", {name: "下载原文", exact: true}).scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "managed-goal-results-mobile.png"), animations: "disabled"}); stale = true; await results.getByRole("button", {name: "刷新", exact: true}).click(); diff --git a/examples/personal-workspace-browser/report-download.mjs b/examples/personal-workspace-browser/report-download.mjs new file mode 100644 index 0000000000..0419b8f767 --- /dev/null +++ b/examples/personal-workspace-browser/report-download.mjs @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {readFile} from "node:fs/promises"; +import {createHash} from "node:crypto"; +import {resolve} from "node:path"; +import {createInterface} from "node:readline"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; +import {outputDir, repoRoot} from "./fixture.mjs"; +import {fetchWorkspaceBackend, openWorkspacePage} from "./scenario-context.mjs"; + +export const reportDownloadScenario = { + id: "report-download", + async run({browser, collectCoverage, url}) { + const backend = spawn(resolveTestPython({repoRoot}), ["-u", resolve(repoRoot, "examples/personal-workspace-browser/report_download_fixture.py")], { + cwd: repoRoot, env: {...process.env, PYTHONPATH: "", NODE_OPTIONS: "--experimental-sqlite"}, stdio: ["pipe", "pipe", "pipe"], + }); + const closed = once(backend, "close"); + let diagnostics = "", context; + backend.stderr.on("data", chunk => {diagnostics = (diagnostics + chunk).slice(-4000);}); + const records = createInterface({input: backend.stdout})[Symbol.asyncIterator](); + async function receive(event) { + let timer; + try { + const row = await Promise.race([records.next(), new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Report backend timeout: ${diagnostics}`)), 60000); + })]); + assert.equal(row.done, false, diagnostics); + const result = JSON.parse(row.value); + assert.equal(result.event, event); + return result; + } finally {clearTimeout(timer);} + } + async function command(event) {backend.stdin.write(`${event}\n`); return receive(event);} + try { + const ready = await receive("ready"); + let reportRequests = 0; + context = await openWorkspacePage(browser, url, {collectCoverage, beforeGoto: async (api, page) => { + api.transformStatus = fixture => Object.assign(fixture, JSON.parse(JSON.stringify(fixture).replaceAll("product-release", ready.goal_id))); + await page.route("**/api/chat/goal-results**", async route => { + reportRequests++; + assert.equal(route.request().method(), "GET"); + await route.fulfill({response: await fetchWorkspaceBackend(route, ready.origin)}); + }); + }}); + const {page, api} = context; + await page.locator(".personal-goal-link", {hasText: "Product Release"}).click(); + const results = page.getByRole("region", {name: "已验收的团队报告"}); + const button = results.getByRole("button", {name: "下载原文", exact: true}); + await button.waitFor(); + const readsBefore = reportRequests; + async function download(keyboard = false) { + const pending = page.waitForEvent("download"); + if (keyboard) {await button.focus(); await button.press("Enter");} else await button.click(); + const saved = await pending; + assert.equal(saved.suggestedFilename(), `report-accepted-report-${ready.sha256.slice(0, 12)}.json`); + const bytes = await readFile(await saved.path()); + assert.equal(bytes.toString("utf8"), ready.text); + assert.equal(createHash("sha256").update(bytes).digest("hex"), ready.sha256); + } + await page.evaluate(() => {window.__reportCreateURL = URL.createObjectURL; URL.createObjectURL = () => {throw new Error("unavailable");};}); + await button.click(); + await results.getByRole("alert").filter({hasText: "无法开始下载"}).waitFor(); + await page.evaluate(() => {URL.createObjectURL = window.__reportCreateURL; delete window.__reportCreateURL;}); + await download(true); + assert.equal(await results.getByRole("alert").count(), 0); + await download(); + assert.equal(reportRequests, readsBefore, "Downloads must reuse the displayed read, not invoke validators again"); + await results.scrollIntoViewIfNeeded(); + await page.screenshot({path: resolve(outputDir, "report-download-desktop.png"), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + await button.scrollIntoViewIfNeeded(); + assert(await results.evaluate(el => el.scrollWidth <= el.clientWidth), "Report controls fit narrow screens"); + await download(); + await page.screenshot({path: resolve(outputDir, "report-download-mobile.png"), animations: "disabled"}); + await command("withdraw"); + await results.getByRole("button", {name: "刷新", exact: true}).click(); + await page.waitForFunction(() => !document.querySelector('.goal-managed-results[aria-busy="true"]')); + assert.equal(await button.count(), 0, "Withdrawn report must not remain downloadable"); + await command("restore"); + await results.getByRole("button", {name: "刷新", exact: true}).click(); + await button.waitFor(); + await download(); + // Switching Goals withdraws the reader and must not export the previous Goal's report. + await page.setViewportSize({width: 1512, height: 982}); + await page.locator(".personal-goal-link", {hasText: "Research Monitor"}).click(); + await page.waitForFunction(() => !document.querySelector('.goal-managed-results[aria-busy="true"]')); + assert.equal(await page.getByRole("button", {name: "下载原文", exact: true}).count(), 0); + const state = await command("inspect"); + assert.equal(state.authority_unchanged, true); + assert.equal(state.posts, 0); + assert.equal(state.sha256, ready.sha256); + assert.equal(api.turnRequests.length, 0); + assert.equal(api.actionApplies.length, 0); + return {note: "Exact canonical report download, repeated/keyboard/narrow, source withdrawal/recovery and Goal isolation", coverageEntries: await context.close()}; + } finally { + if (context && !context.page.isClosed()) await context.close(); + backend.stdin.end(); + const [code] = await closed; + assert.equal(code, 0, diagnostics); + } + }, +}; diff --git a/examples/personal-workspace-browser/report_download_fixture.py b/examples/personal-workspace-browser/report_download_fixture.py new file mode 100644 index 0000000000..cd7fab882f --- /dev/null +++ b/examples/personal-workspace-browser/report_download_fixture.py @@ -0,0 +1,62 @@ +"""Disposable accepted report owner for packaged download qualification; no model calls.""" +import hashlib +import json +from pathlib import Path +import sys +import tempfile +import threading + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "managed-research-team")) +import research_team as demo +from synthetic_fixture import fixture +from acceptance import canonical_tasks +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler + + +def main(): + with tempfile.TemporaryDirectory(prefix="loopx-report-download-") as directory: + root = Path(directory) / "team" + demo.prepare(root, "sqlite") + fixture(root) + for actor, revision in (("analyst", "initial"), ("analyst", "corrected"), + ("reviewer", "initial"), ("reviewer", "corrected"), ("lead", "report")): + demo.complete(root, actor, revision) + read = demo.cli(root, "todo", "result-read", "--goal-id", demo.GOAL, "--todo-id", "todo_lead-report") + digest = read["result"]["sha256"] + result_path = root / "runtime/goals" / demo.GOAL / "result-objects" / digest + original = result_path.read_bytes() + before = canonical_tasks(root) + + class Handler(ChatRequestHandler): + def do_POST(self): + self.server.posts += 1 + self._send_error("Read-only download fixture", status=405) + + server = ChatHTTPServer(("127.0.0.1", 0), Handler) + server.registry_path = root / "registry.json" + server.runtime_root_override = str(root / "runtime") + server.verbose, server.posts = False, 0 + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + print(json.dumps({"event": "ready", "origin": f"http://127.0.0.1:{server.server_port}", + "goal_id": demo.GOAL, "sha256": digest, "text": read["text"]}), flush=True) + try: + for line in sys.stdin: + action = line.strip() + if action == "withdraw": + result_path.write_text("changed source") + elif action == "restore": + result_path.write_bytes(original) + elif action != "inspect": + raise ValueError("Unknown fixture command") + print(json.dumps({"event": action, "posts": server.posts, + "authority_unchanged": canonical_tasks(root) == before, + "sha256": hashlib.sha256(result_path.read_bytes()).hexdigest()}), flush=True) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=5) + + +if __name__ == "__main__": + main() diff --git a/tests/test_managed_research_scenario.py b/tests/test_managed_research_scenario.py index f008ce89b7..19f1c9eca6 100644 --- a/tests/test_managed_research_scenario.py +++ b/tests/test_managed_research_scenario.py @@ -1,5 +1,4 @@ """The example oracle must reject false conclusions and unadopted dependencies.""" -from hashlib import sha256 import json from pathlib import Path import sys @@ -7,39 +6,10 @@ import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "examples" / "managed-research-team")) -from scenario import assignments, encoded, evidence, validate_worker, validate_report # noqa: E402 - - -def fixture(root: Path) -> dict: - dependencies = {} - for member in assignments(root): - worker, revision = member["worker"], member["revision"] - raw, normalized, stale, source = { - "initial": (90, 40, False, "filing-initial"), - "corrected": (75, 25, True, "filing-correction"), - }[revision] - work = root / worker / revision - work.mkdir(parents=True, exist_ok=True) - input_bytes = encoded(evidence(revision)) - (work / "input.json").write_bytes(input_bytes) - output = {"revision": revision, "input_sha256": sha256(input_bytes).hexdigest(), - "raw_fcf": raw, "normalized_fcf": normalized, "period_comparable": False, - "growth_supported": False, "independent_source_families": 1, "repost_stale": stale, - "source_refs": [source, "prior-filing", "repost"], "reason": "Different periods; one source family."} - if member.get("upstream"): - previous = json.loads((root / member["upstream"] / "output.json").read_text()) - output["adopted_dependencies"] = {member["upstream"]: sha256(encoded(previous)).hexdigest()} - (work / "output.json").write_bytes(encoded(output)) - (root / "accepted").mkdir(exist_ok=True) - (root / "accepted" / (worker + "-" + revision + ".json")).write_bytes(encoded({"turn_status": "committed", "evidence": output})) - dependencies[worker + "/" + revision] = sha256(encoded(output)).hexdigest() - report = {"initial_normalized_fcf": 40, "corrected_normalized_fcf": 25, "revision_delta": -15, - "growth_supported": False, "independent_source_families": 1, - "repost_stale_after_correction": True, "dependencies": dependencies, - "reason": "Correction reduces normalized cash; growth is unsupported."} - (root / "lead").mkdir(exist_ok=True) - (root / "lead" / "report.json").write_bytes(encoded(report)) - return report +from scenario import encoded, validate_worker, validate_report # noqa: E402 + + +from synthetic_fixture import fixture # noqa: E402 def test_valid_dependency_adoption(tmp_path):