diff --git a/apps/presentation/dashboard/src/features/personal-workspace/state-recovery-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/state-recovery-settings.tsx index 6e3b0c6412..ab3541b923 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/state-recovery-settings.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/state-recovery-settings.tsx @@ -112,7 +112,13 @@ export function StateRecoverySettings() { {audit.measurements ?

{zh ? "归档字节" : "Archive bytes"}: {audit.measurements.archive_bytes.toLocaleString(locale)} · {zh ? "普通文件字节" : "Regular file bytes"}: {audit.measurements.regular_file_bytes.toLocaleString(locale)}
{zh ? "归档检查与复制" : "Archive checks and copy"}: {audit.measurements.archive_processing_ms.toLocaleString(locale)} ms · {zh ? "记录读取" : "Owner reads"}: {audit.measurements.owner_read_ms.toLocaleString(locale)} ms

:

{zh ? "此历史报告未记录数据量和阶段耗时。" : "This historical report has no recorded size or phase durations."}

} - + : null} {history ?
diff --git a/docs/reference/configuration-backup.md b/docs/reference/configuration-backup.md index 6de9c45516..cccb04497c 100644 --- a/docs/reference/configuration-backup.md +++ b/docs/reference/configuration-backup.md @@ -214,14 +214,29 @@ pending external operation is resumed. This is the M1 inspection path in the [complete recovery RFC](../architecture/rfcs/complete-state-recovery-v0.md), not controlled reactivation. -The following public synthetic scene uses a built wheel and the real File -backend. Before this change, the entry only restored configuration snapshots. -The new path reads the saved Goal work after its original source was removed; -it does not register that Goal. The phone view is explicitly a historical report. +Readable File/SQLite Goal components retain the `provider_revision` and `cursor` +read from the captured authority after its complete head/history check. The JSON +audit, CLI summary and the packaged view's existing **Verification details** show +that historical checkpoint, even when the original source is unavailable or has +later commits. Saved-report readback preserves the recorded position; older +reports without it keep the checkpoint unknown. These are positions in the saved +component, not producer capture intervals, a shared consistency point or current +execution authority. Other owners and M2/M3 qualification remain unchanged. + +中文:可读 File/SQLite Goal 的报告保留隔离副本实际读取的存储版本和游标, +可在 JSON、CLI 和原有“核对信息”中核对。来源之后的新提交不会成为这份备份的 +恢复位置;历史报告重读保留原值,旧报告缺少该信息时仍为未知。这只标识保存组件的 +历史检查点,不证明跨 owner 捕获一致性,也不授予继续执行的权限。 + +These public synthetic scenes use the existing offline owners after source +loss. The configuration and phone references use a built wheel and real File +backend; the current desktop checkpoint view uses the packaged bundle and real +SQLite backend. None registers the saved Goal. The phone view is explicitly a +historical report, and checkpoint identifiers stay behind Verification details. | Before: configuration only | Inert full-archive inspection | Phone historical readback | | --- | --- | --- | -| ![Configuration-only entry](images/state-recovery/before-configuration.png) | ![Inert backup audit](images/state-recovery/inert-audit-desktop.png) | ![Historical audit on phone](images/state-recovery/historical-audit-phone.png) | +| ![Configuration-only entry](images/state-recovery/before-configuration.png) | ![Inert backup audit with its captured checkpoint](images/state-recovery/authority-checkpoint.png) | ![Historical audit on phone](images/state-recovery/historical-audit-phone.png) | Use **Previous inspections** after reloading to read the saved, digest-checked report. A historical report describes its recorded observation time; reading it diff --git a/docs/reference/images/state-recovery/authority-checkpoint.png b/docs/reference/images/state-recovery/authority-checkpoint.png new file mode 100644 index 0000000000..67f281e5b9 Binary files /dev/null and b/docs/reference/images/state-recovery/authority-checkpoint.png differ diff --git a/examples/personal-workspace-browser/configuration-backup.mjs b/examples/personal-workspace-browser/configuration-backup.mjs index a720bad937..57d89eeb11 100644 --- a/examples/personal-workspace-browser/configuration-backup.mjs +++ b/examples/personal-workspace-browser/configuration-backup.mjs @@ -25,13 +25,13 @@ source_registry.write_text(json.dumps({'common_runtime_root':str(source_runtime) projection=build_todo_runtime_shadow_projection(goal_id='recovery-fixture',todos=[{'schema_version':'todo_item_v0','todo_id':'todo_recovery_work','role':'agent','status':'open','done':False,'archive_state':'active','source_section':'Agent Todo','text':'Preserve the original commitment','task_class':'advancement_task'}],handoff_mode='soft_claim') provider=(pathlib.Path(loopx.__file__).parent/'control_plane/coordination/local_authority_provider.ts').as_uri() source_provider=os.environ.get('LOOPX_RECOVERY_TEST_PROVIDER','sqlite');assert source_provider in ('file','sqlite') -script='import {selectLocalSqliteAuthority,openLocalAuthorityStore} from '+json.dumps(provider)+';let input="";for await(const c of process.stdin)input+=c;const r=JSON.parse(input);if(r.provider==="sqlite")await selectLocalSqliteAuthority(r.root,r.goal,true);const s=await openLocalAuthorityStore(r.root,r.goal);const x=await s.commitAuthority({expected_provider_revision:null,operation_id:"original-work",events:[],receipts:[],next_projection:r.projection});if(x.status!=="applied")throw new Error(JSON.stringify(x));' -subprocess.run(['node','--no-warnings','--experimental-sqlite','--experimental-strip-types','--input-type=module','-e',script],input=json.dumps({'root':str(source_runtime),'goal':'recovery-fixture','provider':source_provider,'projection':projection}),text=True,check=True,capture_output=True) +script='import {selectLocalSqliteAuthority,openLocalAuthorityStore} from '+json.dumps(provider)+';let input="";for await(const c of process.stdin)input+=c;const r=JSON.parse(input);if(r.provider==="sqlite")await selectLocalSqliteAuthority(r.root,r.goal,true);const s=await openLocalAuthorityStore(r.root,r.goal);const x=await s.commitAuthority({expected_provider_revision:null,operation_id:"original-work",events:[],receipts:[],next_projection:r.projection});if(x.status!=="applied")throw new Error(JSON.stringify(x));console.log(JSON.stringify({provider_revision:x.provider_revision,cursor:x.cursor}));' +source_position=json.loads(subprocess.run(['node','--no-warnings','--experimental-sqlite','--experimental-strip-types','--input-type=module','-e',script],input=json.dumps({'root':str(source_runtime),'goal':'recovery-fixture','provider':source_provider,'projection':projection}),text=True,check=True,capture_output=True).stdout) saved=execute_state_backup_plan(build_state_backup_plan(project=source_project,runtime_root=source_runtime,registry_path=source_registry,output_dir=r/'source-backup',backup_id='recovery-fixture',include_registry_projects=False,include_automations=False,include_skills=False)) source_project.rename(r/'unavailable-project');source_runtime.rename(r/'unavailable-runtime');source_registry.rename(r/'unavailable-registry.json') assert not source_project.exists() and not source_runtime.exists() and not source_registry.exists() s=ChatHTTPServer(('127.0.0.1',0),ChatRequestHandler);s.registry_path=registry;s.runtime_root=runtime;s.verbose=False -print(json.dumps({'port':s.server_address[1],'python':sys.executable,'module_path':loopx.__file__,'archive':saved['archive_path'],'manifest':saved['manifest_path'],'source_provider':source_provider,'original_sources_absent':True}),flush=True);s.serve_forever() +print(json.dumps({'port':s.server_address[1],'python':sys.executable,'module_path':loopx.__file__,'archive':saved['archive_path'],'manifest':saved['manifest_path'],'source_provider':source_provider,'source_position':source_position,'original_sources_absent':True}),flush=True);s.serve_forever() `; export const configurationBackupScenario = { @@ -128,7 +128,10 @@ export const configurationBackupScenario = { const verificationDetails = recovery.locator("details"); await verificationDetails.locator("summary").click(); await verificationDetails.getByText("归档字节:", {exact: false}).waitFor(); - const recordedMeasurements = await verificationDetails.locator("p").innerText(); + const recordedMeasurements = await verificationDetails.locator("p").filter({hasText: "归档字节:"}).innerText(); + await verificationDetails.getByText(backend.source_position.provider_revision, {exact: true}).waitFor(); + await verificationDetails.getByText(backend.source_position.cursor, {exact: true}).waitFor(); + const recordedCheckpoint = await verificationDetails.locator("p").filter({hasText: "备份中的存储检查点"}).innerText(); await verificationDetails.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "state-recovery-measurements.png"), animations: "disabled"}); await verificationDetails.locator("summary").click(); @@ -178,8 +181,10 @@ export const configurationBackupScenario = { await reopened.getByLabel("历史备份检查", {exact: true}).getByRole("button").click(); await reopened.getByText("已读取历史检查记录;尚未重新验证副本。", {exact: true}).waitFor(); await reopened.locator(".personal-recovery-result details summary").click(); - assert.equal(await reopened.locator(".personal-recovery-result details p").innerText(), recordedMeasurements, + assert.equal(await reopened.locator(".personal-recovery-result details p").filter({hasText: "归档字节:"}).innerText(), recordedMeasurements, "historical readback must retain original measured values, not time the reload"); + assert.equal(await reopened.locator(".personal-recovery-result details p").filter({hasText: "备份中的存储检查点"}).innerText(), recordedCheckpoint, + "historical readback must retain the captured authority position"); await page.getByRole("button", {name: "语言", exact: true}).click(); await page.getByRole("radio", {name: "English", exact: true}).check(); await page.locator(".personal-settings-tabs").getByRole("button", {name: /Capabilit/}).click(); @@ -189,6 +194,9 @@ export const configurationBackupScenario = { await english.getByRole("button", {name: "Previous inspections", exact: true}).click(); await english.getByLabel("Previous backup inspections", {exact: true}).getByRole("button").click(); await english.getByText("Historical inspection loaded; the copy has not been reverified.", {exact: true}).waitFor(); + await english.locator(".personal-recovery-result details summary").click(); + await english.getByText("Checkpoint in backup:", {exact: false}).waitFor(); + await english.getByText(backend.source_position.provider_revision, {exact: true}).waitFor(); await english.getByRole("status").scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, "state-recovery-english.png"), animations: "disabled"}); return {coverageEntries: context.coverageEntries, errors: context.errors, backend, verified: `Packaged configuration recovery and full archive inspection; real ${backend.source_provider} head/history after original-source loss, no Goal registration, keyboard, corrupt-input recovery, immutable history reload and narrow viewport`}; diff --git a/loopx/control_plane/state_recovery.ts b/loopx/control_plane/state_recovery.ts index 89f24d401e..6ea7e4af15 100644 --- a/loopx/control_plane/state_recovery.ts +++ b/loopx/control_plane/state_recovery.ts @@ -172,6 +172,7 @@ async function inspectGoal(root: string, goalId: string, standaloneSqlite: Set 0 + && facts.cursor === String(facts.history_commits)); + } ids.add(row.component_id); counts[row.state as RecoveryComponentState]++; } diff --git a/loopx/control_plane/state_recovery_io.py b/loopx/control_plane/state_recovery_io.py index 60d56f6be9..18883f80b8 100644 --- a/loopx/control_plane/state_recovery_io.py +++ b/loopx/control_plane/state_recovery_io.py @@ -439,4 +439,9 @@ def render_state_recovery_markdown(payload: dict[str, Any]) -> str: if isinstance(measurements, dict): lines.append(f"Archive: {measurements['archive_bytes']} bytes; regular files: {measurements['regular_file_bytes']} bytes; " f"archive checks/copy: {measurements['archive_processing_ms']} ms; owner reads: {measurements['owner_read_ms']} ms.") + for component in payload.get("components", []): + facts = component["facts"] + if "provider_revision" in facts: + lines.append(f"Captured authority {component['component_id']}: " + f"provider revision {facts['provider_revision']}; cursor {facts['cursor']}.") return "\n".join(lines) diff --git a/tests/control_plane_ts/state_recovery.test.ts b/tests/control_plane_ts/state_recovery.test.ts index f8ecc23265..36349a281c 100644 --- a/tests/control_plane_ts/state_recovery.test.ts +++ b/tests/control_plane_ts/state_recovery.test.ts @@ -85,3 +85,22 @@ test("recorded measurements reject invalid values and absent historical measurem } } }); + +test("historical authority positions stay optional but supplied positions must agree with the inspected history", () => { + const input = audit(); + input.components[0] = {...input.components[0], component_id: "goal:fixture", + owner_kind: "coordination_authority", facts: {history_commits: 2}}; + assert.doesNotThrow(() => decodeStateRecoveryAudit(input)); + const position = {provider_revision: `file:2:${"a".repeat(24)}`, cursor: "2"}; + const positioned = (facts: Record) => ({...input, + components: [{...input.components[0], facts: {history_commits: 2, ...facts}}]}); + assert.doesNotThrow(() => decodeStateRecoveryAudit(positioned(position))); + for (const facts of [{provider_revision: position.provider_revision}, {cursor: "2"}, + {...position, cursor: "3"}, {...position, cursor: "02"}, {...position, cursor: 2}, + {...position, provider_revision: "/private/source"}, {...position, provider_revision: "a".repeat(129)}]) { + assert.throws(() => decodeStateRecoveryAudit(positioned(facts))); + } + const wrongOwner = positioned(position); + wrongOwner.components[0].owner_kind = "configuration"; + assert.throws(() => decodeStateRecoveryAudit(wrongOwner)); +}); diff --git a/tests/test_state_recovery.py b/tests/test_state_recovery.py index 41dda7c982..3b14d6530c 100644 --- a/tests/test_state_recovery.py +++ b/tests/test_state_recovery.py @@ -72,6 +72,19 @@ def _rewrite(archive: Path, manifest: Path, extra: tarfile.TarInfo | None = None @pytest.mark.parametrize("provider", ["file", "sqlite"]) def test_real_backup_recovers_readable_authority_after_original_sources_disappear(tmp_path, provider): archive, manifest = _backup(tmp_path, provider) + source_args = ("--registry", tmp_path / "registry.json", "--runtime-root", tmp_path / "runtime") + code, captured = _cli(*source_args, "todo", "list", "--goal-id", "goal-a") + assert code == 0, captured + captured_position = captured["authority_read"] + assert captured_position["cursor"] == "2" + # A later real source commit must not become this backup's recovery point. + code, later = _cli(*source_args, "todo", "add", "--goal-id", "goal-a", "--role", "agent", + "--claimed-by", "agent-a", "--operation-id", "after-backup", + "--text", "This later work is outside the captured checkpoint") + assert code == 0, later + code, current = _cli(*source_args, "todo", "list", "--goal-id", "goal-a") + assert code == 0, current + assert current["authority_read"]["provider_revision"] != captured_position["provider_revision"] (tmp_path / "project").rename(tmp_path / "unavailable-project") (tmp_path / "runtime").rename(tmp_path / "unavailable-runtime") (tmp_path / "registry.json").rename(tmp_path / "unavailable-registry.json") @@ -98,6 +111,11 @@ def test_real_backup_recovers_readable_authority_after_original_sources_disappea assert goal["facts"]["todo_count"] == 1 assert goal["facts"]["history_commits"] == 2 assert goal["facts"]["provider"] == provider + assert goal["facts"]["provider_revision"] == captured_position["provider_revision"] + assert goal["facts"]["cursor"] == captured_position["cursor"] + assert captured_position["provider_revision"] in render_state_recovery_markdown(audit) + saved = read_state_recovery_audit(destination) + assert saved["components"] == audit["components"] captures = json.loads(manifest.read_text())["included"] state_path = next(row["archive_path"] for row in captures if row["key"] == "registry_active_state:goal-a") assert "Preserve the full Goal, including unfinished acceptance." in (destination / "data" / state_path).read_text()