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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -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<raw> & 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;
}
});
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<DelegationReadback["artifacts"]>[number];
export const isMarkdownArtifact = (ref: string) => /\.(md|markdown)$/i.test(ref);
Expand All @@ -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 <article className="goal-team-artifact">
<header><h4>{heading}</h4>{isMarkdownArtifact(artifact.ref) ? <button type="button" aria-pressed={raw} onClick={() => setRaw(value => !value)}>
<header><h4>{heading}</h4><div className="goal-team-artifact-actions">{isMarkdownArtifact(artifact.ref) ? <button type="button" aria-pressed={raw} onClick={() => setRaw(value => !value)}>
{raw ? (zh ? "阅读报告" : "Read report") : (zh ? "查看原文" : "View source")}
</button> : null}</header>
</button> : null}<button type="button" onClick={download} title={zh ? "保存当前显示版本的 UTF-8 原文" : "Save the displayed version as UTF-8 source"}>
{zh ? "下载原文" : "Download source"}
</button></div></header>
{downloadError ? <p role="alert">{zh ? "无法开始下载,请重试。" : "Could not start the download. Please retry."}</p> : null}
<TeamArtifactContent artifact={artifact} raw={raw} label={`${zh ? "证据内容" : "Evidence content"}: ${artifact.ref}`}/>
<details><summary>{zh ? "产物版本" : "Artifact version"}</summary><code>{artifact.sha256}</code></details>
</article>;
Expand Down
16 changes: 16 additions & 0 deletions docs/guides/personal-workspace-user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
38 changes: 38 additions & 0 deletions examples/managed-research-team/synthetic_fixture.py
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions examples/personal-workspace-browser-smoke.mjs
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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);
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
103 changes: 103 additions & 0 deletions examples/personal-workspace-browser/report-download.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
},
};
Loading
Loading