From ac52a37f7c453b816b6ccffc70384e6db9b3ceaa Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:38:41 +0800 Subject: [PATCH 1/3] feat(authority): add recoverable existing-Goal storage settings Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- apps/presentation/dashboard/src/data/chat.ts | 2 +- .../dashboard/src/data/goal-storage.ts | 46 ++++ .../goal-storage-settings.tsx | 123 +++++++++++ .../src/features/personal-workspace/i18n.tsx | 44 +++- .../workspace-settings-page.tsx | 6 +- .../coordination/local_authority_archive.ts | 2 +- .../coordination/local_authority_migration.ts | 57 ++++- loopx/presentation/configuration_api.py | 6 + loopx/presentation/goal_storage_api.py | 95 +++++++++ tests/control_plane/test_chat_goal_storage.py | 199 ++++++++++++++++++ .../local_authority_migration.test.ts | 59 ++++++ 11 files changed, 631 insertions(+), 8 deletions(-) create mode 100644 apps/presentation/dashboard/src/data/goal-storage.ts create mode 100644 apps/presentation/dashboard/src/features/personal-workspace/goal-storage-settings.tsx create mode 100644 loopx/presentation/goal_storage_api.py create mode 100644 tests/control_plane/test_chat_goal_storage.py diff --git a/apps/presentation/dashboard/src/data/chat.ts b/apps/presentation/dashboard/src/data/chat.ts index d466fb71b8..937f361b64 100644 --- a/apps/presentation/dashboard/src/data/chat.ts +++ b/apps/presentation/dashboard/src/data/chat.ts @@ -559,7 +559,7 @@ export async function transitionTypedAction( ).proposal; } -async function requestJson(url: string, init?: RequestInit): Promise { +export async function requestJson(url: string, init?: RequestInit): Promise { let response: Response; try { response = await fetch(chatApiUrl(url), { diff --git a/apps/presentation/dashboard/src/data/goal-storage.ts b/apps/presentation/dashboard/src/data/goal-storage.ts new file mode 100644 index 0000000000..c0aab968e5 --- /dev/null +++ b/apps/presentation/dashboard/src/data/goal-storage.ts @@ -0,0 +1,46 @@ +import {z} from "zod"; +import {ChatApiError, requestJson} from "./chat"; +// The local migration owner's existing built-in subset. Do not pull the Node +// provider graph into the browser; this schema validates its HTTP projection. +const provider = z.enum(["file", "sqlite"]); +export type MigrationProvider = z.infer; +export const storageCarrierSchema = z.object({ + goal_id: z.string().min(1), preview_id: z.string().regex(/^[a-f0-9]{32}$/), + plan_sha256: z.string().regex(/^[a-f0-9]{64}$/), +}); +export type StorageCarrier = z.infer; +export const storageSourceSchema = z.object({ + goal_id: z.string(), canonical: z.boolean(), provider: provider.nullable(), + store_identity: z.string().optional(), provider_revision: z.string().optional(), cursor: z.string().optional(), + todo_count: z.number().int().nonnegative().optional(), unsettled_lease_count: z.number().int().nonnegative().optional(), +}); +export type StorageSource = z.infer; +export const storageResultSchema = z.object({ + ok: z.boolean(), goal_id: z.string(), status: z.string(), + execution_authority_granted: z.literal(false), authority_changed: z.boolean().nullable(), + preview_id: z.string().regex(/^[a-f0-9]{32}$/).optional(), + plan_sha256: z.string().regex(/^[a-f0-9]{64}$/).optional(), + target_provider: provider.optional(), selected_provider: provider.optional(), + reviewed_source: z.object({provider, cursor: z.string(), provider_revision: z.string(), store_identity: z.string()}).optional(), + current: storageSourceSchema.nullable().optional(), + recovery: z.object({phase: z.enum(["prepared", "completed"]), target_store_identity: z.string(), archive_sha256: z.string()}).nullable().optional(), + reason_code: z.string().optional(), +}); +export type StorageResult = z.infer; + +async function read(url: string, init?: RequestInit): Promise { + try { return storageResultSchema.parse(await requestJson(url, init)); } + catch (error) { + if (error instanceof ChatApiError && error.payload.http_status === 409) return storageResultSchema.parse(error.payload); + throw error; + } +} +export function fetchGoalStorage(goalId: string) { + return read(`/api/chat/goal-storage?${new URLSearchParams({goal_id: goalId})}`); +} +export function previewGoalStorage(goalId: string, target: MigrationProvider) { + return read("/api/chat/goal-storage/preview", {method: "POST", body: JSON.stringify({goal_id: goalId, provider: target})}); +} +export function recoverGoalStorage(carrier: StorageCarrier, apply = false) { + return read(`/api/chat/goal-storage/${apply ? "apply" : "recover"}`, {method: "POST", body: JSON.stringify(storageCarrierSchema.parse(carrier))}); +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/goal-storage-settings.tsx b/apps/presentation/dashboard/src/features/personal-workspace/goal-storage-settings.tsx new file mode 100644 index 0000000000..64c87ae0a6 --- /dev/null +++ b/apps/presentation/dashboard/src/features/personal-workspace/goal-storage-settings.tsx @@ -0,0 +1,123 @@ +import {useEffect, useRef, useState} from "react"; +import {fetchGoalStorage, previewGoalStorage, recoverGoalStorage, storageCarrierSchema, + type StorageCarrier, type StorageResult, type StorageSource, type MigrationProvider} from "../../data/goal-storage"; +import {useWorkspaceI18n} from "./i18n"; + +/** Only the immutable opaque carrier survives reload. Neither cached source + * facts nor browser confirmation replace the server's current source checks. */ +export function GoalStorageSettings({goalId, onChanged}: {goalId: string; onChanged: () => void}) { + const {t} = useWorkspaceI18n(); + const key = `loopx-storage-preview:${goalId}`; + const [current, setCurrent] = useState(null); + const [carrier, setCarrier] = useState(null); + const [result, setResult] = useState(null); + const [target, setTarget] = useState("sqlite"); + const [confirmed, setConfirmed] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [reload, setReload] = useState(0); + const [invalidSaved, setInvalidSaved] = useState(false); + const inFlight = useRef(false); + const generation = useRef(0); + useEffect(() => { + const token = ++generation.current; + setCurrent(null); setResult(null); setCarrier(null); setConfirmed(false); setInvalidSaved(false); setError(null); setBusy(true); + let saved: StorageCarrier | null = null; + try { + const raw = localStorage.getItem(key); + saved = raw ? storageCarrierSchema.parse(JSON.parse(raw)) : null; + if (saved && saved.goal_id !== goalId) throw new Error("Goal mismatch"); + setCarrier(saved); + } catch { setInvalidSaved(true); setError(t("storage.savedInvalid")); } + void (async () => { + try { + const observed = await fetchGoalStorage(goalId); + if (token !== generation.current) return; + if (observed.ok && observed.current) { + setCurrent(observed.current); + setTarget(observed.current.provider === "sqlite" ? "file" : "sqlite"); + } else setError(t("storage.unavailable")); + if (saved) { + const recovered = await recoverGoalStorage(saved); + if (token !== generation.current) return; + setResult(recovered); + if (recovered.current !== undefined) setCurrent(recovered.current); + if (!recovered.ok) setError(t("storage.rejected")); + else if (!recovered.current) setError(t("storage.unavailable")); + } + } catch { if (token === generation.current) setError(t("storage.unavailable")); } + finally { if (token === generation.current) setBusy(false); } + })(); + return () => { generation.current++; }; + }, [goalId, key, reload, t]); + + async function submit(apply: boolean) { + if (inFlight.current || busy || invalidSaved || (apply && (!carrier || !confirmed))) return; + inFlight.current = true; setBusy(true); setError(null); + const token = generation.current; + try { + const next = apply ? await recoverGoalStorage(carrier!, true) : await previewGoalStorage(goalId, target); + if (token !== generation.current) return; + setResult(next); + if (!apply && next.ok) { + const saved = storageCarrierSchema.parse(next); + // Persist before showing an enabled write control. Storage failure must + // not allow a write whose original intent cannot be recovered. + localStorage.setItem(key, JSON.stringify(saved)); setCarrier(saved); setConfirmed(false); + } + if (apply) setCurrent(next.current ?? null); + if (!next.ok) setError(t("storage.rejected")); + else if (apply) { + setConfirmed(false); + if (!next.current) setError(t("storage.unavailable")); + onChanged(); + } + } catch { + if (token === generation.current) { + if (apply) { setCurrent(null); setConfirmed(false); } + setError(t(apply ? "storage.ambiguous" : "storage.unavailable")); + } + } + finally { inFlight.current = false; if (token === generation.current) setBusy(false); } + } + function discard() { + try { localStorage.removeItem(key); } + catch { setError(t("storage.savedInvalid")); return; } + setCarrier(null); setResult(null); setConfirmed(false); setInvalidSaved(false); setError(null); + } + const completed = result?.recovery?.phase === "completed"; + return
+
+

{t("storage.title")}

+

{t("storage.boundary")}

+ {current ?
{t("storage.current")} + {current.provider ?? t("ownership.unpromoted")} + {current.canonical ? {t("storage.counts", {todos: current.todo_count ?? 0, leases: current.unsettled_lease_count ?? 0})} :

{t("storage.promoteFirst")}

} +
: null} + {current?.canonical || carrier ? <> + {carrier ?

{t("storage.reviewed", {source: result?.reviewed_source?.provider ?? "?", target: result?.target_provider ?? "?", cursor: result?.reviewed_source?.cursor ?? "?"})}

+ : } + {carrier && !completed ? : null} +
+ {carrier ? <> + + : } +
+ : null} +
+ {invalidSaved ? : null} + +
+ {completed ?

{t("storage.completed")}

: null} + {result?.recovery?.phase === "prepared" ?

{t("storage.prepared")}

: null} + {error ?

{error}

: null} + {result?.reason_code ?

{result.reason_code}

: null} + {current?.canonical || carrier ?
{t("storage.details")} + {current?.canonical ?

{current.store_identity} · {current.provider_revision} · {current.cursor}

: null} + {carrier ?

{carrier.preview_id} · {carrier.plan_sha256}

: null} +
: null} + {busy ?

{t("common.loading")}

: null} +
+
; +} diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 9ea131db95..be34e27e7a 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -12,13 +12,33 @@ export const workspaceLocaleStorageKey = "loopx-pw-locale"; export type WorkspaceLocale = "en" | "zh-CN"; const en = { + "storage.title": "Goal data storage", + "storage.boundary": "Confirmation changes only this existing Goal. New-Goal defaults and task ownership are separate. Preserve journal, metadata and receipts; no execution authority is granted.", + "storage.current": "Current source (fresh readback)", + "storage.counts": "{todos} tasks · {leases} unsettled leases", + "storage.target": "Target storage", + "storage.promoteFirst": "Previous Markdown state: use the backed-up CLI promotion first. This page switches existing canonical File/SQLite stores.", + "storage.reviewed": "Reviewed source: {source}, cursor {cursor} → {target}. A changed source rejects apply.", + "storage.confirm": "I stopped writers and settled leases. I confirm this reviewed storage change.", + "storage.apply": "Back up and switch storage", + "storage.fresh": "Discard original preview", + "storage.preview": "Preview storage change", + "storage.refresh": "Read current storage", + "storage.recover": "Recover original preview and read current storage", + "storage.completed": "The original migration completed and its backup was verified. Current storage is read separately above; a later migration may have changed it.", + "storage.prepared": "The original operation is prepared; its outcome is unconfirmed. Check current storage and retry the same preview.", + "storage.rejected": "Operation rejected. Stop capture and writers, settle leases, then read current storage. If the source changed, discard and review a fresh preview.", + "storage.ambiguous": "Reply lost or interrupted. Keep this original preview; recover or retry it to resolve the outcome.", + "storage.unavailable": "Storage unavailable. Preserve the original preview and retry readback.", + "storage.savedInvalid": "Saved preview unreadable. Explicitly discard it before creating a different intent.", + "storage.details": "Source and original operation details", "ownership.title": "Task ownership", "ownership.current": "Current policy", "ownership.legacy": "Previous policy", "ownership.soft_claim": "Collaborative claims", "ownership.hard_lease": "Exclusive execution leases", "ownership.unpromoted": "Not on canonical storage", - "ownership.promoteFirst": "Promote this Goal through the backed-up CLI migration first. This page does not migrate storage.", + "ownership.promoteFirst": "Promote this Goal through the backed-up CLI migration first. This policy form does not migrate storage.", "ownership.target": "New policy", "ownership.softHelp": "Coordinate assignment without requiring an exclusive execution lease. Active leases must be settled first.", "ownership.hardHelp": "Ownership changes and completion require the task’s original execution lease.", @@ -1335,13 +1355,33 @@ const en = { export type WorkspaceMessageKey = keyof typeof en; const zhCN: Record = { + "storage.title": "Goal 数据存储", + "storage.boundary": "明确确认后仅切换此既有 Goal。新 Goal 默认值与任务所有权是各自的设置。保留日志、metadata 和回执,不授予执行权限。", + "storage.current": "当前来源(实时读回)", + "storage.counts": "{todos} 项任务 · {leases} 项未结算 lease", + "storage.target": "目标存储", + "storage.promoteFirst": "旧 Markdown 状态:请先通过有备份的 CLI 晋升。这里切换已存在的 canonical File/SQLite 存储。", + "storage.reviewed": "已审核来源:{source},游标 {cursor} → {target}。来源变化时拒绝应用。", + "storage.confirm": "我已停止写入方并结算 lease,确认此预览的存储变更。", + "storage.apply": "备份并切换存储", + "storage.fresh": "放弃原预览", + "storage.preview": "预览存储变更", + "storage.refresh": "读回当前存储", + "storage.recover": "恢复原预览并读回当前存储", + "storage.completed": "原迁移已完成且备份已验证。上方当前存储独立读回;后续迁移可能已改变它。", + "storage.prepared": "原操作已准备,结果尚未确认。核对当前存储后重试同一份预览。", + "storage.rejected": "操作被拒绝。请停止 capture 和写入方、结算 lease 并读回当前存储。来源已变化时,放弃并重新审核预览。", + "storage.ambiguous": "响应丢失或中断。请保留原预览,通过恢复或重试它确认结果。", + "storage.unavailable": "存储暂不可用。请保留原预览并重试读回。", + "storage.savedInvalid": "已保存预览无法读取。请明确放弃它后再创建不同操作。", + "storage.details": "来源与原操作详情", "ownership.title": "任务所有权", "ownership.current": "当前策略", "ownership.legacy": "旧策略", "ownership.soft_claim": "协作认领", "ownership.hard_lease": "独占执行租约", "ownership.unpromoted": "尚未使用统一状态存储", - "ownership.promoteFirst": "请先通过带备份的 CLI 迁移将 Goal 晋升到统一存储。此页面不迁移存储。", + "ownership.promoteFirst": "请先通过带备份的 CLI 迁移将 Goal 晋升到统一存储。此策略表单不迁移存储。", "ownership.target": "新策略", "ownership.softHelp": "协调任务归属,不要求独占执行租约。仍在运行的租约必须先结算。", "ownership.hardHelp": "变更所有权和完成任务需要持有该任务原有的执行租约。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx index 55acf4d7ad..094c6b9b20 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/workspace-settings-page.tsx @@ -6,6 +6,7 @@ import { useWorkspaceI18n } from "./i18n"; import { LarkSettingsPage } from "./lark-settings-page"; import { GoalCapabilitySettings } from "./goal-capability-settings"; import { GoalOwnershipSettings } from "./goal-ownership-settings"; +import { GoalStorageSettings } from "./goal-storage-settings"; import { AutomationCadenceSettings } from "./automation-cadence-settings"; import { MachineConfigurationSettings } from "./machine-configuration-settings"; import { ConfigurationBackupSettings } from "./configuration-backup-settings"; @@ -221,7 +222,10 @@ export function WorkspaceSettingsPage({ onChanged={onChanged} /> ) : null} - {tab === "ownership" && selectedGoal ? : null} + {tab === "ownership" && selectedGoal ?
+ + +
: null} {tab === "capabilities" && (capabilityScope === "machine" || capabilityGoalId) ? : null} {tab === "cadence" && selectedGoal ? : null} diff --git a/loopx/control_plane/coordination/local_authority_archive.ts b/loopx/control_plane/coordination/local_authority_archive.ts index 14501223df..9658b64c81 100644 --- a/loopx/control_plane/coordination/local_authority_archive.ts +++ b/loopx/control_plane/coordination/local_authority_archive.ts @@ -96,7 +96,7 @@ export async function manageLocalAuthorityArchive(value: unknown, } return {...base, ...await upgradeAuthorityFormats(request.runtime_roots as string[], request.execute === true)}; } - if (request.action === "plan-migration" || request.action === "migrate") return await manageLocalAuthorityMigration(request); + if (request.action === "plan-migration" || request.action === "migrate" || request.action === "migration-readback") return await manageLocalAuthorityMigration(request); const archive = path(request.archive, "archive path"); if (request.action === "verify") return {...base, status: "verified", archive: await verifyAuthorityArchive(archive)}; const goalId = requireAuthorityStoreId(request.goal_id, "goal id"); diff --git a/loopx/control_plane/coordination/local_authority_migration.ts b/loopx/control_plane/coordination/local_authority_migration.ts index 2e9d822c1f..20ff0c2b25 100644 --- a/loopx/control_plane/coordination/local_authority_migration.ts +++ b/loopx/control_plane/coordination/local_authority_migration.ts @@ -14,6 +14,7 @@ import {indexCoordinationProjection} from "./coordination_projection.ts"; import {canonicalTaskLease} from "./task_lease_state.ts"; import {loadLegacyCoordinationWriterFence} from "./legacy_writer_fence.ts"; import {withCanonicalWriter} from "./local_authority_write.ts"; +import {readShadowManagementState} from "./shadow_management.ts"; import {FileAuthorityStore, syncAuthorityDirectory} from "./file_authority_store.ts"; import {SqliteAuthorityStore} from "./sqlite_authority_store.ts"; import {localAuthorityProviderPaths, openLocalAuthorityStoreHandle, publishLocalAuthoritySelection, @@ -93,6 +94,10 @@ function decodeRecovery(value: unknown, plan: Plan, digest: string): Recovery { return record as Recovery; } async function observe(root: string, goalId: string): Promise<{source: Source; store: AuthorityStore}> { + // A capture binding can still enqueue work from the previous source. Neither + // an empty queue now nor lease expiry proves its writer has stopped. + const capture = await readShadowManagementState(root, goalId); + if (capture && capture.status !== "inactive") throw new Error("Stop and roll back the active capture before provider migration"); const fence = await loadLegacyCoordinationWriterFence(root, goalId); if (fence.status !== "loaded") throw new Error("Migration requires an engaged legacy writer fence"); const handle = await openLocalAuthorityStoreHandle(root, goalId, {}, {existingOnly: true}); @@ -110,6 +115,48 @@ async function observe(root: string, goalId: string): Promise<{source: Source; s return {store: handle.store, source: {provider: selected, store_identity: identity.store_identity, provider_revision: head.provider_revision, cursor: head.cursor, projection_sha256: sha256(head.head), fence_sha256: sha256(fence.fence)}}; } + +async function currentSource(root: string, goalId: string): Promise { + const fence = await loadLegacyCoordinationWriterFence(root, goalId); + if (fence.status !== "loaded") return {goal_id: goalId, canonical: false, provider: null}; + const handle = await openLocalAuthorityStoreHandle(root, goalId, {}, {existingOnly: true}); + const identity = await handle.store.storeIdentity(); + const head = await handle.store.loadAuthority(); + if (identity.status !== "available" || head.status !== "loaded") throw new Error("Current canonical source unavailable"); + const index = indexCoordinationProjection(head.head, goalId); + return {goal_id: goalId, canonical: true, provider: provider(handle.provider), + store_identity: identity.store_identity, provider_revision: head.provider_revision, cursor: head.cursor, + todo_count: index.todos.size, unsettled_lease_count: [...index.leases].filter(([id, row]) => + canonicalTaskLease(row, goalId, id).status === "active").length}; +} + +/** Original completion is historical evidence; it cannot select a provider or + * certify the currently selected store after another migration. */ +async function migrationReadback(root: string, goalId: string, request: JsonObject): Promise { + if (request.plan === undefined) return {current: await currentSource(root, goalId)}; + const plan = decodePlan(await optionalJson(absolute(request.plan))); + const digest = sha256(plan); + if (request.plan_sha256 !== digest || plan.runtime_root !== root || plan.goal_id !== goalId) { + throw new Error("Migration plan digest, runtime or Goal mismatch"); + } + const directory = join(root, "authority-transition", "local-provider", digest); + const raw = await optionalJson(join(directory, "recovery.json")); + const recovery = raw === undefined ? undefined : decodeRecovery(raw, plan, digest); + if (recovery) { + const archive = await verifyAuthorityArchive(join(directory, "source.archive.jsonl")); + checkArchive(archive, plan); + if (archive.archive_sha256 !== recovery.archive_sha256) throw new Error("Migration backup changed"); + } + // An unavailable live store cannot erase a verified historical completion. + // Null is unknown, never a fallback or permission to adopt that old target. + let current: JsonObject | null = null; + try { current = await currentSource(root, goalId); } catch { /* independent read unavailable */ } + return {current, plan_sha256: digest, target_provider: plan.target_provider, + reviewed_source: {provider: plan.source.provider, cursor: plan.source.cursor, + provider_revision: plan.source.provider_revision, store_identity: plan.source.store_identity}, + recovery: recovery ? {phase: recovery.phase, target_store_identity: recovery.target_store_identity, + archive_sha256: recovery.archive_sha256} : null}; +} function checkArchive(archive: AuthorityArchiveSummary, plan: Plan): void { const source = plan.source; if (archive.goal_id !== plan.goal_id || archive.source_provider !== source.provider || @@ -128,7 +175,7 @@ function targetStore(root: string, plan: Plan, identity?: string): AuthorityStor /** No provider factory injection: this administrative operation explicitly owns * the built-in local stores, and never silently adopts a PostgreSQL binding. */ export async function manageLocalAuthorityMigration(request: JsonObject): Promise { - const base = {schema_version: "loopx_local_authority_migration_result_v0", legacy_fallback_used: false, + const base = {ok: true, schema_version: "loopx_local_authority_migration_result_v0", legacy_fallback_used: false, execution_authority_granted: false}; // Publication may have succeeded even if fsync/readback throws. Report unknown, // not a false assertion that no authority changed; retry the same plan. @@ -136,8 +183,10 @@ export async function manageLocalAuthorityMigration(request: JsonObject): Promis try { const root = await realpath(requireLocalAuthorityRuntimeRoot(request.runtime_root)); const goalId = requireAuthorityStoreId(request.goal_id, "goal id"); - const planPath = absolute(request.plan); return await withCanonicalWriter(root, goalId, false, async () => { + if (request.action === "migration-readback") return {...base, status: "observed", authority_changed: false, + ...await migrationReadback(root, goalId, request)}; + const planPath = absolute(request.plan); if (request.action === "plan-migration") { const target = provider(request.provider); const {source} = await observe(root, goalId); @@ -146,6 +195,8 @@ export async function manageLocalAuthorityMigration(request: JsonObject): Promis runtime_root: root, goal_id: goalId, target_provider: target, source}; await writePlan(planPath, plan); return {...base, status: "planned", authority_changed: false, plan_sha256: sha256(plan), plan, + reviewed_source: {provider: source.provider, cursor: source.cursor, + provider_revision: source.provider_revision, store_identity: source.store_identity}, target_provider: target, requires_execute: true}; } if (request.action !== "migrate" || typeof request.execute !== "boolean") throw new Error("Invalid migration action"); @@ -219,7 +270,7 @@ export async function manageLocalAuthorityMigration(request: JsonObject): Promis publication_readback: {cursor: readback.cursor, provider_revision: readback.provider_revision}}; }); } catch (error) { - return {...base, status: "failed", authority_changed: publicationAttempted ? null : false, + return {...base, ok: false, status: "failed", authority_changed: publicationAttempted ? null : false, reason_code: publicationAttempted ? "migration_publication_uncertain" : "migration_rejected", reason: error instanceof Error ? error.message : "Migration unavailable"}; } diff --git a/loopx/presentation/configuration_api.py b/loopx/presentation/configuration_api.py index 6d3a10c4de..9b24233034 100644 --- a/loopx/presentation/configuration_api.py +++ b/loopx/presentation/configuration_api.py @@ -4,6 +4,7 @@ from . import configuration_backup_api as backup_api from . import goal_ownership_api as ownership_api +from . import goal_storage_api as storage_api from .. import chat_usage_statistics_api as usage_api from .. import chat_goal_configuration_api as goal_api from .. import chat_machine_configuration_api as machine_api @@ -12,6 +13,7 @@ class ChatConfigurationRequestMixin( + storage_api.GoalStorageRequestMixin, ownership_api.GoalOwnershipRequestMixin, usage_api.UsageStatisticsRequestMixin, cadence_api.AutomationCadenceRequestMixin, @@ -24,6 +26,7 @@ class ChatConfigurationRequestMixin( def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: return { + storage_api.CHAT_GOAL_STORAGE_PATH: self._storage_inspect, ownership_api.CHAT_GOAL_OWNERSHIP_PATH: self._ownership_inspect, cadence_api.CHAT_AUTOMATION_CADENCE_PATH: self._cadence_read, goal_api.CHAT_GOAL_CONFIGURATION_PATH: self._goal_configuration_inspect, @@ -34,6 +37,9 @@ def _configuration_get_routes(self) -> dict[str, Callable[[], None]]: def _configuration_post_routes(self) -> dict[str, Callable[[], None]]: return { + f"{storage_api.CHAT_GOAL_STORAGE_PATH}/preview": lambda: self._storage_update(action="plan-migration"), + f"{storage_api.CHAT_GOAL_STORAGE_PATH}/apply": lambda: self._storage_update(action="migrate"), + f"{storage_api.CHAT_GOAL_STORAGE_PATH}/recover": lambda: self._storage_update(action="migration-readback"), f"{backup_api.CONFIGURATION_BACKUP_PATH}/export": self._configuration_backup_export, f"{backup_api.CONFIGURATION_BACKUP_PATH}/restore": self._configuration_backup_restore, f"{ownership_api.CHAT_GOAL_OWNERSHIP_PATH}/preview": lambda: self._ownership_update(execute=False), diff --git a/loopx/presentation/goal_storage_api.py b/loopx/presentation/goal_storage_api.py new file mode 100644 index 0000000000..5c60818c4c --- /dev/null +++ b/loopx/presentation/goal_storage_api.py @@ -0,0 +1,95 @@ +"""Path-free App transport for the existing typed local-provider migration owner.""" +from __future__ import annotations + +import re +from typing import Any, TYPE_CHECKING, cast +from urllib.parse import parse_qs, urlparse +from uuid import uuid4 + +from ..control_plane.effect_runtime import effect_runtime_result + +CHAT_GOAL_STORAGE_PATH = "/api/chat/goal-storage" + + +def _token(body: dict[str, Any], name: str, length: int) -> str: + value = body.get(name) + if not isinstance(value, str) or not re.fullmatch(rf"[a-f0-9]{{{length}}}", value): + raise ValueError(f"invalid {name}") + return value + + +class GoalStorageRequestMixin: + server: Any + path: str + + if TYPE_CHECKING: + def _registry_and_goal(self, goal_id: str) -> tuple[dict[str, Any], dict[str, Any]]: ... + def _send_json(self, payload: dict[str, Any], *, status: int = 200) -> None: ... + def _send_error(self, message: str, *, status: int, error_code: str) -> None: ... + def _read_json(self) -> dict[str, Any]: ... + + def _storage_owner(self, goal_id: str, **fields: Any) -> dict[str, Any]: + self._registry_and_goal(goal_id) + return cast(dict[str, Any], effect_runtime_result( + "coordination.authority_archive.manage", + {"schema_version": "loopx_authority_archive_admin_request_v0", + "runtime_root": str(self.server.runtime_root.resolve()), + "goal_id": goal_id, **fields}, timeout=300.0, retry_safe=False, + )) + + def _storage_send(self, result: dict[str, Any], goal_id: str, preview_id: str | None = None) -> None: + # The typed owner decides and verifies. Transport never returns local + # filenames, exception prose, full plans, projections or raw archives. + payload = {key: result[key] for key in ( + "ok", "status", "authority_changed", "execution_authority_granted", + "plan_sha256", "reviewed_source", "target_provider", "selected_provider", + "current", "recovery", "reason_code", + ) if key in result} + payload["goal_id"] = goal_id + if preview_id is not None: + payload["preview_id"] = preview_id + self._send_json(payload, status=200 if result.get("ok") else 409) + + def _storage_inspect(self) -> None: + try: + query = parse_qs(urlparse(self.path).query, keep_blank_values=True) + if set(query) != {"goal_id"} or len(query["goal_id"]) != 1: + raise ValueError("goal_id is required exactly once") + goal_id = query["goal_id"][0] + self._storage_send(self._storage_owner(goal_id, action="migration-readback"), goal_id) + except (KeyError, TypeError, ValueError): + self._send_error("Choose a registered Goal.", status=400, error_code="invalid_goal_storage_request") + except Exception: # noqa: BLE001 - local provider errors stay private. + self._send_error("Current storage unavailable.", status=503, error_code="goal_storage_unavailable") + + def _storage_update(self, *, action: str) -> None: + try: + body = self._read_json() + preview = action == "plan-migration" + allowed = {"goal_id", "provider"} if preview else {"goal_id", "preview_id", "plan_sha256"} + if set(body) != allowed or not isinstance(body.get("goal_id"), str): + raise ValueError("invalid storage request") + goal_id = body["goal_id"] + self._registry_and_goal(goal_id) + preview_id = uuid4().hex if preview else _token(body, "preview_id", 32) + directory = self.server.runtime_root / "chat" / "storage-migrations" + if preview: + directory.mkdir(mode=0o700, parents=True, exist_ok=True) + fields: dict[str, Any] = {"action": action, "plan": str(directory / f"{preview_id}.json")} + if preview: + fields["provider"] = body["provider"] + else: + fields["plan_sha256"] = _token(body, "plan_sha256", 64) + if action == "migrate": + fields["execute"] = True + result = self._storage_owner(goal_id, **fields) + if result.get("ok") and action == "migrate": + # A committed result survives an independent read failure. The + # original carrier is retained until the user reads it back. + readback = self._storage_owner(goal_id, action="migration-readback", plan=fields["plan"], plan_sha256=fields["plan_sha256"]) + result.update({key: readback[key] for key in ("current", "recovery") if key in readback}) + self._storage_send(result, goal_id, preview_id) + except (KeyError, TypeError, ValueError): + self._send_error("Invalid Goal storage request.", status=400, error_code="invalid_goal_storage_request") + except Exception: # noqa: BLE001 - preserve the original carrier on ambiguity. + self._send_error("Result unavailable. Read or retry the original preview.", status=503, error_code="goal_storage_unavailable") diff --git a/tests/control_plane/test_chat_goal_storage.py b/tests/control_plane/test_chat_goal_storage.py new file mode 100644 index 0000000000..43e4788a84 --- /dev/null +++ b/tests/control_plane/test_chat_goal_storage.py @@ -0,0 +1,199 @@ +"""Real HTTP -> existing migration owner -> durable File/SQLite, no active Goals.""" + +import http.client +import json +import threading + +import pytest + +from loopx.chat_server import ChatHTTPServer, ChatRequestHandler +from loopx.control_plane.coordination.local_authority import ( + read_canonical_todos_if_promoted, +) +from loopx.control_plane.coordination.runtime_shadow import ( + build_todo_runtime_shadow_projection, +) +from loopx.control_plane.effect_runtime import restart_effect_runtime +from loopx.todos import add_goal_todo +from tests.control_plane.canonical_authority_fixture import ( + initialize_canonical_authority, + isolate_sqlite_runtime, +) + + +@pytest.fixture(params=["file", "sqlite"]) +def api(tmp_path, monkeypatch, request): + isolate_sqlite_runtime(tmp_path, monkeypatch) + runtime = tmp_path / "runtime" + registry = tmp_path / "registry.json" + rows = [] + for goal in ["example", "other"]: + state = tmp_path / f"{goal}.md" + state.write_text("---\nhandoff_mode: legacy\n---\n\n## Agent Todo\n") + rows.append( + { + "id": goal, + "repo": str(tmp_path), + "state_file": state.name, + "coordination": {"registered_agents": ["agent-a"]}, + } + ) + projection = build_todo_runtime_shadow_projection( + goal_id=goal, handoff_mode="legacy", todos=[] + ) + initialize_canonical_authority( + runtime, goal, projection, state_path=state, provider=request.param + ) + registry.write_text( + json.dumps({"common_runtime_root": str(runtime), "goals": rows}) + ) + server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) + server.runtime_root = runtime + server.registry_path = registry + server.runtime_root_override = str(runtime) + server.verbose = False + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + + def call(action="", body=None, origin=None): + connection = http.client.HTTPConnection( + "127.0.0.1", server.server_port, timeout=90 + ) + headers = {"Content-Type": "application/json"} + if origin: + headers["Origin"] = origin + path = "/api/chat/goal-storage" + ( + f"/{action}" if action else "?goal_id=example" + ) + connection.request( + "POST" if body is not None else "GET", + path, + body=json.dumps(body) if body is not None else None, + headers=headers, + ) + response = connection.getresponse() + value = json.loads(response.read()) + connection.close() + # No local source or backup filenames, exception text or complete state. + assert str(tmp_path) not in json.dumps(value) + return response.status, value + + yield call, runtime, registry + server.shutdown() + thread.join(5) + server.server_close() + restart_effect_runtime() + + +def preview(call, provider): + code, result = call("preview", {"goal_id": "example", "provider": provider}) + assert code == 200 and result["ok"], result + return result + + +def operate(call, plan, action="apply", **overrides): + return call(action, {"goal_id": "example", "preview_id": plan["preview_id"], + "plan_sha256": plan["plan_sha256"], **overrides}) + + +def read(runtime, goal="example"): + return read_canonical_todos_if_promoted(runtime_root=runtime, goal_id=goal, include_leases=True) + + +def test_storage_switch_retains_new_metadata_and_history_after_restart(api): + call, runtime, registry = api + initial = call()[1]["current"]["provider"] + target = "sqlite" if initial == "file" else "file" + task = add_goal_todo(registry_path=registry, goal_id="example", role="agent", text="Before cutover", + priority="P1", claimed_by="agent-a", note="Nested evidence retained", task_class="advancement_task") + before = read(runtime) + plan = preview(call, target) + assert plan["reviewed_source"]["provider"] == initial + assert call()[1]["current"]["provider"] == initial and read(runtime) == before + assert operate(call, plan)[0] == 200 + assert call()[1]["current"]["provider"] == target + assert read(runtime)["todos"] == before["todos"] + later = add_goal_todo(registry_path=registry, goal_id="example", role="agent", text="New data after migration", + priority="P2", claimed_by="agent-a", note="Retain on return", task_class="advancement_task") + written = read(runtime) + restart_effect_runtime() + code, recovered = operate(call, plan, "recover") + assert code == 200 and recovered["recovery"]["phase"] == "completed", recovered + assert recovered["current"]["provider"] == target and recovered["current"]["todo_count"] == 2 + code, replay = operate(call, plan) + assert code == 200 and replay["status"] == "already_applied", replay + assert read(runtime) == written + back = preview(call, initial) + assert operate(call, back)[0] == 200 + assert read(runtime)["todos"] == written["todos"] + assert {row["todo_id"] for row in written["todos"]} == {task["todo_id"], later["todo_id"]} + final = read(runtime) + # Original completion remains historical; it cannot silently re-select the target. + code, old = operate(call, plan, "recover") + assert code == 200 and old["recovery"]["phase"] == "completed" + assert old["target_provider"] == target and old["current"]["provider"] == initial + assert operate(call, plan)[0] == 409 and read(runtime) == final + + +def test_source_conflicts_foreign_handles_and_backup_corruption_cannot_switch(api): + call, runtime, registry = api + initial = call()[1]["current"]["provider"] + target = "sqlite" if initial == "file" else "file" + plan = preview(call, target) + before, other = read(runtime), read(runtime, "other") + assert operate(call, plan, goal_id="other")[0] == 409 + assert operate(call, plan, plan_sha256="0" * 64)[0] == 409 + assert operate(call, plan, "recover", goal_id="other")[0] == 409 + assert read(runtime) == before and read(runtime, "other") == other + add_goal_todo(registry_path=registry, goal_id="example", role="agent", text="Source changed", claimed_by="agent-a") + changed = read(runtime) + assert operate(call, plan)[0] == 409 and read(runtime) == changed + fresh = preview(call, target) + assert operate(call, fresh)[0] == 200 + applied = read(runtime) + archive = runtime / "authority-transition/local-provider" / fresh["plan_sha256"] / "source.archive.jsonl" + archive.write_text("damaged backup") + assert operate(call, fresh, "recover")[0] == 409 + assert operate(call, fresh)[0] == 409 and read(runtime) == applied + + +def test_no_automatic_migration_or_caller_paths_and_active_lease_rejected(api): + call, runtime, registry = api + initial = call()[1]["current"]["provider"] + target = "sqlite" if initial == "file" else "file" + before = read(runtime) + assert call("preview", {"goal_id": "example", "provider": target, "plan": "/tmp/injected"})[0] == 400 + assert call("apply", {"goal_id": "example", "preview_id": "../injected", "plan_sha256": "a" * 64})[0] == 400 + assert call("preview", {"goal_id": "example", "provider": target}, "https://untrusted.example")[0] == 403 + assert call("preview", {"goal_id": "example", "provider": "postgresql"})[0] == 409 + assert read(runtime) == before and call()[1]["current"]["provider"] == initial + from loopx.control_plane.work_items.task_lease import acquire_task_lease + task = add_goal_todo(registry_path=registry, goal_id="example", role="agent", text="Host still running", claimed_by="agent-a") + acquired = acquire_task_lease(registry_path=registry, runtime_root=runtime, goal_id="example", + todo_id=task["todo_id"], owner="agent-a", idempotency_key="host", ttl_seconds=1) + assert acquired["ok"], acquired + import time + time.sleep(1.1) + leased = read(runtime) + assert call()[1]["current"]["unsettled_lease_count"] == 1 + assert call("preview", {"goal_id": "example", "provider": target})[0] == 409 + assert read(runtime) == leased and call()[1]["current"]["provider"] == initial + + +def test_original_receipt_is_readable_when_live_provider_is_unavailable(api): + call, runtime, _ = api + initial = call()[1]["current"]["provider"] + target = "sqlite" if initial == "file" else "file" + plan = preview(call, target) + assert operate(call, plan)[0] == 200 + directory = runtime / "authority" / f"{target}-v0" + unavailable = directory.with_name(directory.name + ".unavailable") + directory.rename(unavailable) + try: + assert call()[0] == 409 + code, original = operate(call, plan, "recover") + assert code == 200 and original["recovery"]["phase"] == "completed", original + assert original["current"] is None and original["execution_authority_granted"] is False + assert operate(call, plan)[0] == 409 + finally: + unavailable.rename(directory) diff --git a/tests/control_plane_ts/local_authority_migration.test.ts b/tests/control_plane_ts/local_authority_migration.test.ts index a35e7b2d1f..728ef70528 100644 --- a/tests/control_plane_ts/local_authority_migration.test.ts +++ b/tests/control_plane_ts/local_authority_migration.test.ts @@ -300,3 +300,62 @@ test("Todo metadata survives every historical row, including null, false, empty assert.equal(current.completion_validation_revision, 0); } }); + +test("readback separates original completion from current source without replaying", async t => { + const root = await fixture(t); + const first = await plan(root, "sqlite"); + assert.equal((await manage(request(root, first))).status, "migrated"); + await append(root, 3); + const current = await manage(request(root, {action: "migration-readback"})); + assert.equal((current.current as JsonObject).cursor, "3"); + const back = await plan(root, "file"); + assert.equal((await manage(request(root, back))).status, "migrated"); + const before = await (await selected(root, goal)).store.scanCommitted(null, 10); + const read = await manage(request(root, {...first, action: "migration-readback"})); + assert.equal(read.ok, true, JSON.stringify(read)); + assert.equal(read.target_provider, "sqlite"); + assert.equal((read.recovery as JsonObject).phase, "completed"); + assert.equal((read.current as JsonObject).provider, "file"); + assert.deepEqual(await (await selected(root, goal)).store.scanCommitted(null, 10), before); + assert.equal((await manage(request(root, {...first, action: "migration-readback", goal_id: "another-goal"}))).ok, false); + assert.equal((await manage(request(root, {...first, action: "migration-readback", plan_sha256: "0".repeat(64)}))).ok, false); +}); + +test("an active capture blocks cutover even with an empty outbox; pending data stays intact", async t => { + const root = await fixture(t); + const {bootstrapManagedShadow} = await import("../../loopx/control_plane/coordination/shadow_management.ts"); + const {mkdir} = await import("node:fs/promises"); + const capture = await bootstrapManagedShadow({runtime_root: root, goal_id: goal, operation_id: "capture", + source_version: "state:1", source_snapshot: {state_path: join(root, "state.md")}, + projection: {schema_version: "loopx_coordination_shadow_projection_v0", goal_id: goal, todos: [], leases: []}}, + {withPrimaryLocks: async fn => await fn(), verifySourceSnapshot: async () => {}}); + assert.equal(capture.status, "applied", JSON.stringify(capture)); + const file = join(root, "active-capture-plan.json"); + const denied = await manage(request(root, {action: "plan-migration", provider: "sqlite", plan: file})); + assert.equal(denied.ok, false); + assert.match(String(denied.reason), /active capture/); + const pending = join(root, "authority-shadow", "outbox", goal, "todos"); + await mkdir(pending, {recursive: true}); + await writeFile(join(pending, "entry.prepared.json"), "pending source write"); + const again = await manage(request(root, {action: "plan-migration", provider: "sqlite", plan: file})); + assert.equal(again.ok, false); + assert.equal(await readFile(join(pending, "entry.prepared.json"), "utf8"), "pending source write"); + assert.equal((await selected(root, goal)).provider, "file"); +}); + +test("verified historical completion survives unavailable current provider without adoption", async t => { + const root = await fixture(t); + const first = await plan(root, "sqlite"); + assert.equal((await manage(request(root, first))).status, "migrated"); + const {rename} = await import("node:fs/promises"); + const paths = localAuthorityProviderPaths(root, goal); + await rename(paths.sqlite, paths.sqlite + ".unavailable"); + assert.equal((await manage(request(root, {action: "migration-readback"}))).ok, false); + const read = await manage(request(root, {...first, action: "migration-readback"})); + assert.equal(read.ok, true, JSON.stringify(read)); + assert.equal(read.current, null); + assert.equal((read.recovery as JsonObject).phase, "completed"); + assert.equal(read.execution_authority_granted, false); + assert.equal((await manage(request(root, first))).ok, false); + await assert.rejects(selected(root, goal)); +}); From 58c586bc00eac5065a30282dce57aeac4ed6999a Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:38:41 +0800 Subject: [PATCH 2/3] docs(authority): distinguish storage recovery and default qualification Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- .../rfcs/loopx-overall-roadmap-v0.md | 9 ++++ .../local-authority-provider-selection.md | 47 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md index 27baa0fac0..a134d61ba5 100644 --- a/docs/architecture/rfcs/loopx-overall-roadmap-v0.md +++ b/docs/architecture/rfcs/loopx-overall-roadmap-v0.md @@ -875,6 +875,15 @@ L3 checkpoint: standalone acquisition/takeover, atomic claim admission and maint - **Exit:** use shared-authority Section 7.2's separate decisions for a bounded change, reversible opt-in cohort and released default. Each requires affected real CLI/backend and independent baseline/negative/recovery evidence at its own scope. Formal D2 retains applicable volume and at least ten-day evidence; a cohort need not wait for that certificate. D3 retains explicit cutover authority. This plan runs no soak or provider promotion. - **Rollback:** reviewed fenced export/import and schema-aware downgrade; replacing a binary cannot restore old write authority. +Existing canonical File/SQLite cutover now has a packaged settings journey using +the existing typed migration/archive owner: immutable preview, explicit apply, +original-plan recovery and independent current-source readback. Active capture +(including an empty queue) and unsettled leases refuse cutover. File→SQLite→new +writes→File must use the current head; historical completion never selects the +provider. This is a bounded App companion, not full old-source/Host upgrade or +D2/release-default qualification. Continue those original acceptance frontiers +and retire each last caller separately. + The [Goal instance/recovery RFC](goal-instance-identity-and-orphan-recovery-v0.md) adds a bounded R5 dependency for R2/R3 retirement and late-result safety. The codec and source-session lifetime transaction exist; attached Chat, handoff and diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index 250395c063..2581de2564 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -255,3 +255,50 @@ canonical 写入绕过旧 writer,不代表未迁移 Goal 的调用方已消失 不可用时明确失败;不降级到 Markdown writer,不破坏历史恢复,回退保留迁移后的 新写入。现有回归覆盖不等于长期 SQLite、真实 PostgreSQL、打包 App 或发布默认 已通过,trial 与正式默认的资格继续分别记录。 + +## Existing canonical Goal storage in the App + +Open a Goal’s settings → Task ownership → **Goal data storage**. This control +uses the same typed local-provider migration, archive and publication owner as +`authority-archive plan-migration/migrate`; it changes neither new-Goal defaults +nor ownership policy. Previous Markdown Goals must first use reviewed promotion. +PostgreSQL cutover is outside this local File/SQLite operation. + +1. Stop writers and settle active leases, including expired active leases. An + active capture blocks preview/apply even when its outbox currently looks + empty. Finish the existing capture rollback/recovery; do not delete its queue. +2. Read current storage, select File or SQLite and preview. The immutable plan + pins the source identity, revision, cursor, projection and writer fence. +3. Confirm the reviewed operation. Apply verifies the full backup and target + history before publishing and independently reading the selected provider. +4. After a lost response or server restart, **recover the original preview**. + The browser retains only its opaque handle and digest. Recovery reports the + original prepared/completed record separately from today’s source; it never + replays a superseded operation or changes the provider. Retry that same plan + to resolve an interrupted publication. A stale source needs a fresh preview. +5. To return to File, preview a new migration from the **current SQLite head**. + This retains writes made after the first migration. Restoring the original + backup alone would discard those writes and is not a reverse cutover. + +HTTP accepts registered Goal and opaque preview/digest bindings, never caller +paths, source overrides or complete state. Its current-source/recovery reads are +observations, not execution grants or whole-Goal backups. Configuration remains +unchanged until explicit apply. Real File/SQLite HTTP and packaged-App recovery +validation does not qualify sustained D2, release defaults, a stopped external +Host or all historical-source upgrade paths. + +### 既有 canonical Goal 的 App 存储切换 + +进入 Goal 设置 → 任务所有权 → **Goal 数据存储**。新 Goal 默认值、任务所有权 +策略与此处的既有存储切换分别管理,复用同一 TS migration/archive/provider owner。 +旧 Markdown Goal 先走有备份的审核晋升;这里不提供 PostgreSQL 切换。 + +先停止写入方并结算 active lease,过期不证明 Host 已停。active capture 即使暂时 +空队列也拒绝切换;按原生 rollback/recovery 处理,不能删队列。读回当前来源、 +选择 File/SQLite、预览并明确确认后才备份和发布。来源变化拒绝应用。 + +丢失响应或重启后恢复同一 opaque 预览与摘要。历史 completed 回执与当前来源 +分开读回,旧操作被后续切换替代后不会重新激活。中断操作重试原计划;源变更才 +审核新计划。回退 File 必须从当前 SQLite head 新建计划,保留升级后的新增写入, +不能直接恢复旧备份替换当前数据。本入口不授予执行权限或迁移所有 Goal;D2、 +发布默认、完整旧源/Host 恢复继续按原验收分别资格化。 From a8728d3539441c8340fab23edd4acf7c569ff2b2 Mon Sep 17 00:00:00 2001 From: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:40:37 +0800 Subject: [PATCH 3/3] fix(presentation): retain reviewed source after storage apply Signed-off-by: LoopX Agent <337587101+loopx-agent@users.noreply.github.com> --- loopx/presentation/goal_storage_api.py | 2 +- tests/control_plane/test_chat_goal_storage.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/loopx/presentation/goal_storage_api.py b/loopx/presentation/goal_storage_api.py index 5c60818c4c..5389f68e07 100644 --- a/loopx/presentation/goal_storage_api.py +++ b/loopx/presentation/goal_storage_api.py @@ -87,7 +87,7 @@ def _storage_update(self, *, action: str) -> None: # A committed result survives an independent read failure. The # original carrier is retained until the user reads it back. readback = self._storage_owner(goal_id, action="migration-readback", plan=fields["plan"], plan_sha256=fields["plan_sha256"]) - result.update({key: readback[key] for key in ("current", "recovery") if key in readback}) + result.update({key: readback[key] for key in ("current", "recovery", "reviewed_source", "target_provider") if key in readback}) self._storage_send(result, goal_id, preview_id) except (KeyError, TypeError, ValueError): self._send_error("Invalid Goal storage request.", status=400, error_code="invalid_goal_storage_request") diff --git a/tests/control_plane/test_chat_goal_storage.py b/tests/control_plane/test_chat_goal_storage.py index 43e4788a84..c96d3c897a 100644 --- a/tests/control_plane/test_chat_goal_storage.py +++ b/tests/control_plane/test_chat_goal_storage.py @@ -110,7 +110,9 @@ def test_storage_switch_retains_new_metadata_and_history_after_restart(api): plan = preview(call, target) assert plan["reviewed_source"]["provider"] == initial assert call()[1]["current"]["provider"] == initial and read(runtime) == before - assert operate(call, plan)[0] == 200 + code, applied = operate(call, plan) + assert code == 200 and applied["reviewed_source"]["provider"] == initial + assert applied["target_provider"] == target assert call()[1]["current"]["provider"] == target assert read(runtime)["todos"] == before["todos"] later = add_goal_todo(registry_path=registry, goal_id="example", role="agent", text="New data after migration",