diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx index 0ef72ab82c..2dc89422f2 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-research-history.tsx @@ -101,7 +101,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re const copy = zh ? { title: "研究历程", loading: "正在读取完整记录", reading: "读取中", refresh: "刷新记录", all: "回到全图", scope: "Explore 已记录历史", coverage: "其他来源覆盖、尝试发生时序尚未核验", - partial: "历史尚未完整载入", failure: "无法继续读取历史。已读记录保留;请重试。", + partial: "历史尚未完整载入", failure: "无法读取此来源的研究记录", time: "记录时间(本地)", unknown: "时间未记录", record: "条记录", records: "条记录", evidence: "已记录依据", interpretation: "独立检查与实际采用尚未核验", versions: "来源与版本引用", updates: "历史记录", changes: "次记录", question: "个问题", questions: "个问题", links: "显式关联", missing: "部分关联的来源节点尚未记录", @@ -109,7 +109,7 @@ export function GoalResearchHistory({goalId, active, zh, refreshRevision = 0, re } : { title: "Research history", loading: "Reading the full recorded history", reading: "Reading", refresh: "Refresh history", all: "Whole graph", scope: "Recorded Explore history", coverage: "Other-source coverage and attempt-time coverage are unverified", - partial: "History is not fully loaded", failure: "Cannot continue reading history. Loaded records remain; retry.", + partial: "History is not fully loaded", failure: "Could not read this source's research history", time: "Recorded time (local)", unknown: "Time not recorded", record: "record", records: "records", evidence: "Recorded evidence", interpretation: "Independent checking and actual adoption are unverified", versions: "Source and version references", updates: "History", changes: "records", question: "question", questions: "questions", links: "Explicit relationships", missing: "Some linked source nodes are not recorded", diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 20f21e90be..d64d5303b3 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -4,6 +4,7 @@ import {reportDownloadScenario} from "./personal-workspace-browser/report-downlo 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"; +import {researchSourceRecoveryScenario} from "./personal-workspace-browser/research-source-recovery.ts"; import {researchTurnCostsScenario} from "./personal-workspace-browser/research-turn-costs.mjs"; import {researchToolContextScenario} from "./personal-workspace-browser/research-tool-context.mjs"; import { zcodeGoalScenario } from "./personal-workspace-browser/zcode-goal.mjs"; @@ -134,6 +135,7 @@ scenarioCatalog.push(exploreCompositionOwnerScenario); scenarioCatalog.push(attentionUserActionScenario); scenarioCatalog.push(researchResultsScenario); scenarioCatalog.push(researchRealBenchmarkScenario); +scenarioCatalog.push(researchSourceRecoveryScenario); scenarioCatalog.push(researchHistoryScenario); scenarioCatalog.push(researchMeasurementsScenario); scenarioCatalog.push(researchToolContextScenario, researchTurnCostsScenario); diff --git a/examples/personal-workspace-browser/research-source-recovery.ts b/examples/personal-workspace-browser/research-source-recovery.ts new file mode 100644 index 0000000000..95e680ab3c --- /dev/null +++ b/examples/personal-workspace-browser/research-source-recovery.ts @@ -0,0 +1,152 @@ +// Packaged UI + real read-only HTTP owner. Original R7 records stay unchanged; +// only transport availability and the catalog shell are controlled by this smoke. +import assert from "node:assert/strict"; +import {spawn} from "node:child_process"; +import {once} from "node:events"; +import {mkdtemp, mkdir, readFile, rm, writeFile} from "node:fs/promises"; +import {tmpdir} from "node:os"; +import {resolve} from "node:path"; +import {pathToFileURL} from "node:url"; +import type {Browser} from "playwright"; +import {launchBrowser, loadPlaywright} from "../dashboard-browser-smoke-support.mjs"; +import {resolveTestPython} from "../../scripts/test-python.mjs"; + +const root = resolve(import.meta.dirname, "../.."); +const output = resolve(root, "output/playwright/research-source-recovery"); +type CapturedHistory = {goal_id:string; display_name:string; events:(Record & {event_id:string})[]}; +export const researchSourceRecoveryScenario = { + id: "research-source-recovery", + async run({browser}: {browser: Pick}) { + const temporary = await mkdtemp(resolve(tmpdir(), "loopx-research-recovery-")); + const fault = resolve(temporary, "unavailable"); + const fixturePaths = ["on", "off"].map(arm => resolve(root, + `apps/presentation/dashboard/src/data/fixtures/edgebench-grid-context-${arm}-20261103-history.json`)); + const originals = await Promise.all(fixturePaths.map(path => readFile(path, "utf8"))); + const captures: CapturedHistory[] = originals.map(value => JSON.parse(value)); + assert.equal(captures[0].goal_id, captures[1].goal_id, "Physical sources can share one logical Goal id"); + const backend = spawn(resolveTestPython(), ["-u", "-c", ` +import json, pathlib, sys, threading +from loopx.presentation.runtime_workspace import RuntimeWorkspaceServer +from loopx.presentation.chat_bundle import validate_bundle +from loopx.control_plane.status.goal_source import GoalSource +validate_bundle(pathlib.Path(sys.argv[2]), source_root=pathlib.Path(sys.argv[2]).parents[2]) +class Source(GoalSource): + def __init__(self, name, capture): + self.capture = capture + super().__init__(name.replace(' ','-'),name,'/registry','/runtime','/cli','/python','/bin','local','host',capture['goal_id']) + def read_status(self, goal_id=None): + self.check_access(goal_id) + return dict(ok=True, registry='', runtime_root='', goal_count=1, run_count=0, + contract=dict(ok=True,summary=dict(errors=0,warnings=0,checks=0),errors=[],warnings=[]), + attention_queue=dict(available=True,item_count=0,needs_user_or_controller=0,needs_codex=0,watching_external_evidence=0,items=[]), + run_history=dict(available=True,goal_count=1,run_count=0,recent_runs=[],goals=[dict( + id=self.goal_id,display_name=self.capture['display_name'],registry_member=True,activation_state='active')])) + def read_events(self, goal_id): + self.check_access(goal_id) + marker = pathlib.Path(sys.argv[1]) + if marker.exists() and marker.read_text() == self.label: raise OSError('Controlled history transport failure') + return self.capture['events'] +servers=[RuntimeWorkspaceServer(Source(name,json.loads(pathlib.Path(path).read_text())),pathlib.Path(sys.argv[2]), + analysis_runtime_root=pathlib.Path(sys.argv[1]).parent / 'analysis') + for name,path in zip(('R7 context on','R7 context off'),sys.argv[3:])] +catalog=[dict(id=s.source.id,label=s.source.label,status_url=s.status_url) for s in servers] +for s in servers: + s.catalog=catalog + threading.Thread(target=s.serve_forever,daemon=True).start() +print(json.dumps(catalog),flush=True) +threading.Event().wait() +`, fault, resolve(root, "loopx/web/chat"), ...fixturePaths], {cwd:root, stdio:["ignore", "pipe", "pipe"]}); + try { + const catalog = await new Promise<{label:string;status_url:string}[]>((accept, reject) => { + const timer = setTimeout(() => reject(new Error("History owner startup timed out")), 15000); + backend.once("exit", code => {clearTimeout(timer); reject(new Error(`History owner exited ${code}`));}); + backend.stdout.once("data", bytes => {clearTimeout(timer); accept(JSON.parse(bytes.toString()));}); + backend.stderr.on("data", bytes => process.stderr.write(bytes)); + }); + await mkdir(output, {recursive:true}); + for (const language of ["zh", "en"]) { + const zh = language === "zh"; + const page = await browser.newPage({locale:zh ? "zh-CN" : "en-US", reducedMotion:"reduce", viewport:{width:1440,height:900}}); + const errors: string[] = [], writes: string[] = []; + page.on("pageerror", error => errors.push(String(error))); + page.on("request", request => {if (request.method() !== "GET") writes.push(request.url());}); + const url = new URL("/chat/", catalog[0].status_url); + url.search = new URLSearchParams({goalId:captures[0].goal_id,view:"overview",statusUrl:"/status.json"}).toString(); + await page.goto(url.toString()); + await page.getByRole("heading", {name:captures[0].display_name,exact:true}).waitFor(); + await page.locator(".personal-goal-tabs").getByRole("button", {name:zh ? "研究" : "Research",exact:true}).click(); + const coverage = page.locator(".research-history-coverage"); + const readError = page.locator(".research-history-read-error"); + await coverage.filter({hasText:"33 / 33"}).waitFor(); + await writeFile(fault, catalog[0].label); + await page.locator(".research-history-controls").getByRole("button", {name:zh ? "刷新记录" : "Refresh history",exact:true}).click(); + await readError.waitFor(); + assert.match(await coverage.innerText(), /33 \/ 33/, "Same-source refresh failure preserves its valid snapshot"); + assert.match(await readError.locator(":scope > div > strong").innerText(), /保留|preserv/i, + "A refresh failure still tells the user that this source's valid records remain readable"); + await rm(fault); + await readError.getByRole("button", {name:zh ? "重试" : "Retry",exact:true}).click(); + await readError.waitFor({state:"hidden"}); + await writeFile(fault, catalog[1].label); + await page.getByLabel(zh ? "选择控制面来源" : "Select control plane source", {exact:true}).click(); + await page.getByRole("option", {name:catalog[1].label,exact:true}).click(); + await page.getByRole("heading", {name:captures[1].display_name,exact:true}).waitFor(); + await readError.waitFor(); + assert.equal(await page.getByRole("heading", {name:captures[0].display_name,exact:true}).count(), 0); + await page.screenshot({path:resolve(output, `source-unavailable-${language}-1440.png`)}); + assert.doesNotMatch(await readError.locator(":scope > div > strong").innerText(), /保留|preserv|remain/i, + "A first-read failure cannot claim that records from this source were retained"); + assert.match(await readError.locator(":scope > div > strong").innerText(), /来源|source/i); + assert.doesNotMatch(await coverage.innerText(), /33 \/ 33/, "A new source cannot inherit the old source's complete history"); + assert.equal(await page.locator(".research-history-node").count(), 0, "No old graph survives the failed source switch"); + await page.setViewportSize({width:390,height:844}); + await page.screenshot({path:resolve(output, `source-unavailable-${language}-390.png`)}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false); + await readError.getByRole("button", {name:zh ? "重试" : "Retry",exact:true}).focus(); + await rm(fault); + await page.keyboard.press("Enter"); + await coverage.filter({hasText:"11 / 11"}).waitFor(); + await readError.waitFor({state:"hidden"}); + await page.screenshot({path:resolve(output, `source-recovered-${language}-390.png`)}); + await page.setViewportSize({width:1440,height:900}); + await page.screenshot({path:resolve(output, `source-recovered-${language}-1440.png`)}); + // Recovered data must be the new source's original records, including its + // negative findings, rather than a renamed or summarized old snapshot. + await page.getByRole("button", {name:zh ? "搜索研究记录" : "Search research records",exact:true}).click(); + const records = page.locator(".research-record-search"); + const downloading = page.waitForEvent("download"); + await records.getByRole("button", {name:zh ? "下载匹配记录(JSON)" : "Download matches (JSON)",exact:true}).click(); + const exported = JSON.parse(await readFile(await (await downloading).path(), "utf8")) as {records:CapturedHistory["events"];coverage:unknown}; + assert.deepEqual(exported.records.map(event => event.event_id), captures[1].events.map(event => event.event_id)); + assert.deepEqual(exported.coverage, {loaded_records:11,exported_records:11,history_complete:true,refresh_failed:false}); + for (const [index, original] of captures[1].events.entries()) { + for (const field of ["title", "summary", "status", "recorded_at", "goal_id", "run_id", "result_id"]) { + assert.equal(exported.records[index][field], original[field] ?? ""); + } + assert.deepEqual(exported.records[index].measurements ?? [], original.measurements ?? []); + } + await page.goBack(); + await page.getByRole("heading", {name:captures[0].display_name,exact:true}).waitFor(); + await coverage.filter({hasText:"33 / 33"}).waitFor(); + await page.goForward(); + await coverage.filter({hasText:"11 / 11"}).waitFor(); + assert.deepEqual(writes, [], "Browsing and retry never dispatch work, analysis or source mutations"); + assert.deepEqual(errors, []); + await page.close(); + } + for (let i=0;i