diff --git a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs index 82d9f4eac5..f62d0e68b0 100644 --- a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs @@ -117,6 +117,7 @@ try { assert.equal(loaded.length, 3); await check("access", () => json(access), ["access"], 1); await check("original source unavailable", () => json({ error_code: "goal_source_unavailable" }, 503), ["source"], 1); + await check("storage recovery does not retry without migration", () => json({ error_code: "canonical_authority_required" }, 503), ["authority"], 1); for (const body of [null, {}, [], { error_code: "future_code" }, { error_code: 5 }, { error: "permission denied" }]) { await check("unknown envelope", () => json(body), ["service"], 3); } diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index 18ad2463b3..009e503c4e 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -25,7 +25,7 @@ const directorySchema = z.object({ contract: directoryContractSchema.optional(), }); export type WorkspaceDirectory = z.infer; -export type WorkspaceLoadError = "timeout" | "network" | "service" | "source" | "access" | "revision" | "scope" | "invalid"; +export type WorkspaceLoadError = "timeout" | "network" | "service" | "source" | "access" | "authority" | "revision" | "scope" | "invalid"; export type WorkspaceReadScope = "all" | "missing"; export type WorkspaceProgress = { directory: WorkspaceDirectory; @@ -124,7 +124,7 @@ export function directoryStatusPayload(directory: WorkspaceDirectory): StatusPay }); } -async function statusFailure(response: Response, signal: AbortSignal): Promise<"access" | "source" | "service"> { +async function statusFailure(response: Response, signal: AbortSignal): Promise<"access" | "source" | "authority" | "service"> { if (!response.body) return "service"; const reader = response.body.getReader(); const cancel = () => { void reader.cancel().catch(() => {}); }; @@ -147,6 +147,7 @@ async function statusFailure(response: Response, signal: AbortSignal): Promise<" && "error_code" in payload) { if (payload.error_code === "workspace_status_access_denied") return "access"; if (payload.error_code === "goal_source_unavailable") return "source"; + if (payload.error_code === "canonical_authority_required") return "authority"; } return "service"; } catch { diff --git a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx index 54ff01db61..d4b06d0942 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/i18n.tsx @@ -709,6 +709,8 @@ const en = { "startup.error.timeout": "The request timed out. Retry when the local service is ready.", "startup.error.network": "The connection was interrupted. Retry to reconnect.", "startup.error.service": "The Goal status could not be read. Retry later; if the problem continues, check the status source.", + "startup.error.authority": "This Goal uses an older storage format. Review its storage upgrade to restore status reads.", + "startup.storageSettings": "Review storage upgrade", "startup.error.source": "The original Goal source is unavailable. Its link is preserved; retry when the source is available again.", "startup.error.access": "The status source cannot access files or runtime directories required for this Goal. Check the permissions of the account running that source, then retry.", "startup.error.revision": "The Goal directory changed during loading. Refresh to synchronize.", @@ -2350,6 +2352,8 @@ const zhCN: Record = { "startup.error.timeout": "读取超时,可在本地服务就绪后重试。", "startup.error.network": "连接中断,请重试以重新连接。", "startup.error.service": "Goal 状态读取失败。请稍后重试;若问题持续,请检查状态来源。", + "startup.error.authority": "此 Goal 使用旧版存储,需要检查并迁移后才能恢复状态读取。", + "startup.storageSettings": "检查存储升级", "startup.error.source": "原 Goal 来源暂不可用,关联仍保留。来源恢复后可重试。", "startup.error.access": "状态来源无法访问此 Goal 所需的文件或运行时目录。请检查该来源运行账户的访问权限,修复后重试。", "startup.error.revision": "加载期间 Goal 目录发生变化,请刷新以同步。", diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx index 4c643f5443..e71d1796c7 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-page.tsx @@ -2851,7 +2851,9 @@ export function PersonalWorkspacePage({
{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}

{t(selectedGoal.loadError ? `startup.error.${selectedGoal.loadError}` : "startup.independent")}

- {selectedGoal.loadState === "error" ? : null}
+ {selectedGoal.loadState === "error" ? selectedGoal.loadError === "authority" && !readOnly ? + + : : null}
) : selectedGoal ? ( item.key === requestedTab)?.key ?? tabs[0].key; const selectedGoal = scope === "goal" ? goals.find(item => item.goalId === initialGoalId) : undefined; const editableGoal = selectedGoal && !selectedGoal.sourceAccess?.readOnly ? selectedGoal : undefined; + const canonicalSettingsReady = editableGoal?.loadState !== "loading" && editableGoal?.loadError !== "authority"; const [ownershipRefresh, setOwnershipRefresh] = useState(0); const navigate = (nextScope: "machine" | "goal", nextGoalId?: string, nextTab?: SettingsPage) => { onNavigate({kind: "settings", scope: nextScope, @@ -248,15 +249,15 @@ export function WorkspaceSettingsPage({ /> ) : null} {tab === "ownership" && editableGoal ?
- + {canonicalSettingsReady ? : null} { setOwnershipRefresh(value => value + 1); onChanged(); }} /> - + {canonicalSettingsReady ? <> - + : null}
: null} {tab === "capabilities" && (scope === "machine" || editableGoal) ? : null} {tab === "cadence" && editableGoal ? : null} diff --git a/docs/assets/goal-storage-recovery/desktop.png b/docs/assets/goal-storage-recovery/desktop.png new file mode 100644 index 0000000000..577f20666f Binary files /dev/null and b/docs/assets/goal-storage-recovery/desktop.png differ diff --git a/docs/assets/goal-storage-recovery/mobile.png b/docs/assets/goal-storage-recovery/mobile.png new file mode 100644 index 0000000000..7c37fc525f Binary files /dev/null and b/docs/assets/goal-storage-recovery/mobile.png differ diff --git a/docs/reference/local-authority-provider-selection.md b/docs/reference/local-authority-provider-selection.md index 1c5f8a3947..2ffaf249d5 100644 --- a/docs/reference/local-authority-provider-selection.md +++ b/docs/reference/local-authority-provider-selection.md @@ -207,6 +207,22 @@ already-canonical Goals use the [reviewed File/SQLite cutover](file-authority-st Retain verified backups, stop writers, settle leases and preserve newer writes on reverse migration. Supported historical backup/format/receipt readers remain. +When an older Goal cannot load, the App preserves the storage owner's +`canonical_authority_required` error and offers **Review storage upgrade**. +This opens the existing Goal storage inspector directly, without automatic +retries or migration. Canonical-only settings wait until status can be read; +the inspector remains available after reload. Read-only sources retain their +read-only boundary. Reviewed preview, stopped writers and explicit import +confirmation are still required. Packaged desktop/mobile validation covers +File/SQLite recovery, response loss and refusal of unsettled work; this does +not establish that an installed user's Goals have been migrated. + +旧版 Goal 无法加载时,App 显示存储升级原因,并通过“检查存储升级”直接打开 +现有存储检查页,不自动重试或迁移。状态恢复前暂不加载依赖 canonical 存储的 +设置,重载后仍可检查存储。只读来源保留只读边界;迁移仍须审阅预览、停止写入 +并明确确认。打包后的桌面和手机验证覆盖 File/SQLite 恢复、响应丢失及未结算 +工作的拒绝;这些证据不代表用户已安装的 Goal 已完成迁移。 + After reviewed import or promotion, reconnect the registered Goal with ordinary bootstrap, without `--force`: diff --git a/examples/personal-workspace-browser/goal-settings-backend.mjs b/examples/personal-workspace-browser/goal-settings-backend.mjs index 5552832d3c..3c8ae1b487 100644 --- a/examples/personal-workspace-browser/goal-settings-backend.mjs +++ b/examples/personal-workspace-browser/goal-settings-backend.mjs @@ -16,7 +16,7 @@ export async function startAuthority({unsettled = true} = {}) { " \n"; await writeFile(resolve(root, "state.md"), source); await writeFile(resolve(root, "registry.json"), JSON.stringify({ - common_runtime_root: resolve(root, "runtime"), goals: [{id: "multi-agent-projection", + common_runtime_root: resolve(root, "runtime"), goals: [{id: "multi-agent-projection", display_name: "Multi Agent Projection", repo: root, state_file: "state.md", coordination: {registered_agents: ["agent-a"]}}], })); const leases = resolve(root, "runtime/goals/multi-agent-projection/task-leases"); @@ -33,6 +33,9 @@ server = ChatHTTPServer(("127.0.0.1", 0), ChatRequestHandler) server.registry_path = Path(sys.argv[1]) / "registry.json" server.runtime_root = Path(sys.argv[1]) / "runtime" server.runtime_root_override = str(server.runtime_root) +server.selected_goal_id = None +server.scan_roots = [] +server.limit = 20 server.verbose = False print(server.server_port, flush=True) server.serve_forever() diff --git a/examples/personal-workspace-browser/goal-showcase.mjs b/examples/personal-workspace-browser/goal-showcase.mjs index 76bf1575bb..5c28af27fc 100644 --- a/examples/personal-workspace-browser/goal-showcase.mjs +++ b/examples/personal-workspace-browser/goal-showcase.mjs @@ -103,8 +103,18 @@ export const goalShowcaseScenario = { const errorHeight = await failure.evaluate(node => node.getBoundingClientRect().height); assert.ok(errorHeight > 0 && errorHeight <= 28, `A region failure is one announced line, not a banner (${errorHeight}px)`); await page.screenshot({ path: resolve(outputDir, "goal-showcase-region-error.png"), animations: "disabled" }); + assert.match(await page.locator(".goal-overview-progress").innerText(), /2\/4 项条件当前通过/, + "An unrelated delivery read failure does not invalidate current acceptance readback"); + state.acceptanceFails = true; + const checks = overview.locator(".delivery-acceptance-contract"); + await checks.locator(".delivery-check-summary").click(); + await checks.getByText("高级配置与诊断", {exact: true}).click(); + await checks.getByRole("button", {name: "刷新结果", exact: true}).click(); + await page.locator(".goal-overview-progress").filter({hasText: "当前保留旧快照"}).waitFor(); assert.match(await page.locator(".goal-overview-progress").innerText(), /当前保留旧快照/, "A failed owning read cannot present retained criteria as currently passing"); + state.acceptanceFails = false; + await checks.getByRole("button", {name: "刷新结果", exact: true}).last().click(); state.deliveryFails = false; await page.locator(".delivery-review-toolbar button").first().click(); await page.locator(".goal-overview-progress").filter({ hasText: "2/4 项条件当前通过" }).waitFor(); diff --git a/examples/personal-workspace-browser/goal-storage.mjs b/examples/personal-workspace-browser/goal-storage.mjs index abf03fc730..af456cc0d5 100644 --- a/examples/personal-workspace-browser/goal-storage.mjs +++ b/examples/personal-workspace-browser/goal-storage.mjs @@ -16,6 +16,12 @@ async function importSettled(browser, url, provider) { let applyCount = 0; try { const connect = () => openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => { + await page.route(/\/status\.json(?:\?|$)/, async route => { + const query = new URL(route.request().url()).searchParams; + if (query.has("view") || query.has("goal_id")) { + await route.fulfill({response: await fetchWorkspaceBackend(route, authority.url)}); + } else await route.fallback(); + }); await page.route(/\/api\/chat\/goal-(storage|ownership)(?:[/?]|$)/, async route => { const parsed = new URL(route.request().url()); const response = await fetchWorkspaceBackend(route, authority.url); @@ -27,12 +33,26 @@ async function importSettled(browser, url, provider) { }}); context = await connect(); let {page} = context; + let recoveryOpened = false; async function open() { const existing = page.getByRole("region", {name: "Goal 数据存储"}); if (await existing.isVisible()) return existing; const navigation = page.getByRole("button", {name: "打开 Goal 导航", exact: true}); if (await navigation.isVisible()) await navigation.click(); await page.locator(".personal-goal-link", {hasText: "Multi Agent Projection"}).click(); + const recovery = page.getByRole("button", {name: "检查存储升级", exact: true}); + if (!recoveryOpened || await recovery.isVisible()) { + await recovery.waitFor(); + await page.getByText("此 Goal 使用旧版存储,需要检查并迁移后才能恢复状态读取。", {exact: true}).waitFor(); + await page.screenshot({path: resolve(outputDir, `goal-storage-recovery-${provider}.png`), animations: "disabled"}); + await page.setViewportSize({width: 390, height: 844}); + assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth + 1), false); + await page.screenshot({path: resolve(outputDir, `goal-storage-recovery-${provider}-mobile.png`), animations: "disabled"}); + await recovery.focus(); + await page.keyboard.press("Enter"); + recoveryOpened = true; + return page.getByRole("region", {name: "Goal 数据存储"}); + } await page.getByRole("button", {name: "Goal 设置", exact: true}).click(); await page.getByRole("button", {name: "任务所有权", exact: true}).click(); return page.getByRole("region", {name: "Goal 数据存储"}); @@ -92,7 +112,7 @@ async function importSettled(browser, url, provider) { assert.equal(await readFile(resolve(authority.root, "state.md"), "utf8"), authority.source); await panel.scrollIntoViewIfNeeded(); await page.screenshot({path: resolve(outputDir, `goal-storage-import-${provider}-mobile.png`), animations: "disabled"}); - assert.equal(context.errors.filter(e => !e.includes("ERR_FAILED")).length, 0, context.errors.join("; ")); + assert.equal(context.errors.filter(e => !e.includes("ERR_FAILED") && !e.includes("503")).length, 0, context.errors.join("; ")); } finally { await context?.close(); await authority.close(); } } @@ -104,6 +124,12 @@ const goalStorageScenario = { let writes = 0; try { context = await openWorkspacePage(browser, url, {beforeGoto: async (_api, page) => { + await page.route(/\/status\.json(?:\?|$)/, async route => { + const query = new URL(route.request().url()).searchParams; + if (query.has("view") || query.has("goal_id")) { + await route.fulfill({response: await fetchWorkspaceBackend(route, authority.url)}); + } else await route.fallback(); + }); await page.route(/\/api\/chat\/goal-(storage|ownership)(?:[/?]|$)/, async route => { if (route.request().method() !== "GET") writes++; await route.fulfill({response: await fetchWorkspaceBackend(route, authority.url)}); @@ -114,8 +140,7 @@ const goalStorageScenario = { const existing = page.getByRole("region", {name: language === "zh" ? "Goal 数据存储" : "Goal data storage"}); if (await existing.isVisible()) return existing; await page.locator(".personal-goal-link", {hasText: "Multi Agent Projection"}).click(); - await page.getByRole("button", {name: language === "zh" ? "Goal 设置" : "Goal settings", exact: true}).click(); - await page.getByRole("button", {name: language === "zh" ? "任务所有权" : "Task ownership", exact: true}).click(); + await page.getByRole("button", {name: language === "zh" ? "检查存储升级" : "Review storage upgrade", exact: true}).click(); return page.getByRole("region", {name: language === "zh" ? "Goal 数据存储" : "Goal data storage"}); } let panel = await open(); diff --git a/examples/personal-workspace-browser/navigation-sorting.mjs b/examples/personal-workspace-browser/navigation-sorting.mjs index 76db3b8ddd..4d8dae9b2d 100644 --- a/examples/personal-workspace-browser/navigation-sorting.mjs +++ b/examples/personal-workspace-browser/navigation-sorting.mjs @@ -259,7 +259,8 @@ export const navigationSortingScenario = { await remote.getByRole("button", { name: /^(Tasks|任务)$/, current: "page" }).waitFor({ state: "visible" }); if (await remote.getByRole("button", { name: "Goal 设置", exact: true }).count()) throw new Error("Remote Goal exposed local capability settings"); await remote.getByRole("button", { name: "概览", exact: true }).click(); - await remote.locator(".goal-overview-source-note").waitFor({ state: "visible" }); + await remote.getByRole("group", { name: "工作视图", exact: true }).getByRole("button", { name: "关系图", exact: true }).click(); + await remote.getByText("此来源仅提供同步的状态与验收观察,交付链需要实时本机来源。", { exact: true }).waitFor({ state: "visible" }); if (await remote.locator(".delivery-review").count()) throw new Error("Remote Overview queried the local delivery source"); await remote.getByRole("button", { name: "任务", exact: true }).click(); await remote.locator(".personal-object-list", { hasText: "进行中" }).locator("button").first().click(); diff --git a/examples/personal-workspace-browser/showcase-workspace.mjs b/examples/personal-workspace-browser/showcase-workspace.mjs index d989315d0a..d83395f86f 100644 --- a/examples/personal-workspace-browser/showcase-workspace.mjs +++ b/examples/personal-workspace-browser/showcase-workspace.mjs @@ -123,7 +123,9 @@ export async function installShowcaseWorkspace(api, page, state) { // The check panel refreshes its owning read independently of the map. This // visual fixture supplies that same synthetic contract; real execution is // qualified by goal-acceptance-oracle.mjs against SQLite/HTTP/CLI. - await page.route("**/api/chat/goal-acceptance?*", route => route.fulfill({ + await page.route("**/api/chat/goal-acceptance?*", route => state.acceptanceFails + ? route.fulfill({status: 503, json: {ok: false, error: "acceptance temporarily unavailable"}}) + : route.fulfill({ contentType: "application/json", json: {ok: true, goal_id: goalId, provider_revision: "showcase:3", goal_acceptance_contract: acceptance.goal_acceptance_contract}, })); diff --git a/examples/personal-workspace-browser/typed-actions.mjs b/examples/personal-workspace-browser/typed-actions.mjs index 8b0a860afe..ad31936c81 100644 --- a/examples/personal-workspace-browser/typed-actions.mjs +++ b/examples/personal-workspace-browser/typed-actions.mjs @@ -1021,6 +1021,8 @@ export const typedActionsScenario = { throw new Error(`Work-Agent filter did not consistently filter task cards: ${filteredText}`); } const runtimeSelector = page.getByRole("combobox", { name: "选择聊天 Runtime" }); + if (await runtimeSelector.count()) throw new Error("Tasks view exposed a Chat executor without its composer"); + await page.getByRole("button", { name: "对话", exact: true }).click(); if (!await runtimeSelector.count()) throw new Error("Chat runtime selector is not explicitly labelled independently from work-Agent lanes"); await goalButton.click(); await tasksTab.click(); @@ -1709,9 +1711,10 @@ export const typedActionsScenario = { const previewCountBeforeAnalysis = api.actionPreviews.length; const turnCountBeforeAnalysis = api.turnRequests.length; - await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Tasks|任务)$/ }).click(); await composer.fill("做一次只读分析:判断刚刚新增的 Todo 是否与当前 Goal 一致,并在当前 Chat 返回两点理由。不要修改状态。"); await page.getByRole("button", { name: "发送", exact: true }).click(); + await page.locator(".personal-message-pending").waitFor({state: "hidden"}); + await page.getByRole("navigation", { name: "Goal 视图" }).getByRole("button", { name: /^(Tasks|任务)$/ }).click(); const taskConversationReceipt = page.getByRole("region", { name: "最近对话" }); await taskConversationReceipt.getByText("对话有新回复", { exact: true }).waitFor({ state: "visible", timeout: 10_000 }); await taskConversationReceipt.getByRole("button", { name: "转为任务草稿", exact: true }).waitFor({ state: "visible" }); @@ -1944,9 +1947,11 @@ export const typedActionsScenario = { await page.getByRole("button", { name: "关闭", exact: true }).click(); } api.nextActionPreviewDelayMs = 900; + await goalNavigation.getByRole("button", { name: /^(Chat|对话)$/ }).click(); const conversationRuntime = page.getByRole("combobox", { name: "选择聊天 Runtime" }); const conversationAgentId = await conversationRuntime.getAttribute("data-value"); if (conversationAgentId !== "codex") throw new Error(`Quick Todo actor fixture requires the selected Codex Runtime, got ${conversationAgentId}`); + await goalNavigation.getByRole("button", { name: /^(Tasks|任务)$/ }).click(); const claimedTask = taskCards.filter({ hasText: "Monitor ArcticDB #3179 LoopX comment" }); const unassignedTask = taskCards.filter({ hasText: "todo todo_1596c3a678bb" }); if (await claimedTask.count() !== 1 || await unassignedTask.count() !== 1) { diff --git a/loopx/chat_status_api.py b/loopx/chat_status_api.py index e01f7e1331..9a1b5d3913 100644 --- a/loopx/chat_status_api.py +++ b/loopx/chat_status_api.py @@ -26,6 +26,7 @@ EffectRuntimeStartupError, effect_runtime_request_scope, ) +from .control_plane.coordination.local_authority import LocalCoordinationAuthorityUnavailable from .control_plane.runtime.public_safety import ( public_safe_compact_text, validate_public_safe_value, @@ -336,6 +337,15 @@ def _status(self, *, delivery_review: bool = False) -> None: ) ) except Exception as exc: # noqa: BLE001 - do not expose local projection failures. + # The storage owner already exposes inspection and reviewed import. + # Preserve its typed refusal, not private paths or migration payloads. + if isinstance(exc, LocalCoordinationAuthorityUnavailable) and exc.code == "canonical_authority_required": + self._send_error( + "This Goal needs a storage upgrade. Open its storage settings to review recovery.", + status=503, + error_code=exc.code, + ) + return self._send_error( "LoopX status could not be projected for the workspace.", status=500, diff --git a/tests/control_plane/test_quota_acceptance_references.py b/tests/control_plane/test_quota_acceptance_references.py index 1834c211cb..1aa0f17eaf 100644 --- a/tests/control_plane/test_quota_acceptance_references.py +++ b/tests/control_plane/test_quota_acceptance_references.py @@ -67,11 +67,16 @@ def test_exact_copies_share_one_complete_source_without_changing_signed_action(w def test_different_contracts_are_retained_in_full(change): source = packet(False) changed = source["autonomous_replan_obligation"]["replan_context"]["core_goal"]["acceptance_contract"] - if change == "revision": changed["revision"] = 4 - if change == "scope": changed["scope"] = {"kind": "selected_work"} - if change == "failure": changed["verification"]["failures"] += ["New failure"] - if change == "type": changed["verification"]["passed"] = 0 # False == 0 in Python - if change == "source": changed["sources"][0]["ref"] = "message:corrected" + if change == "revision": + changed["revision"] = 4 + if change == "scope": + changed["scope"] = {"kind": "selected_work"} + if change == "failure": + changed["verification"]["failures"] += ["New failure"] + if change == "type": + changed["verification"]["passed"] = 0 # False == 0 in Python + if change == "source": + changed["sources"][0]["ref"] = "message:corrected" shared = _reference_inline_goal_acceptance(source) assert shared["autonomous_replan_obligation"]["replan_context"]["core_goal"]["acceptance_contract"] == changed assert "acceptance_contract_ref" not in shared["autonomous_replan_obligation"]["replan_context"]["core_goal"] diff --git a/tests/presentation/test_linked_goal_sources.py b/tests/presentation/test_linked_goal_sources.py index ba7b96aeb4..c383b90b29 100644 --- a/tests/presentation/test_linked_goal_sources.py +++ b/tests/presentation/test_linked_goal_sources.py @@ -364,7 +364,8 @@ def test_original_saved_findings_keep_full_text_associations_and_source_fences(w # Malformed/wrong-Goal evidence fails closed and invalidates previous pages. events = log.read_text().splitlines() - wrong = json.loads(events[-1]); wrong['goal_id'] = 'unrelated' + wrong = json.loads(events[-1]) + wrong['goal_id'] = 'unrelated' log.write_text('\n'.join([*events[:-1], json.dumps(wrong)])) with pytest.raises(HTTPError) as corrupt: request(endpoint + query) @@ -377,12 +378,15 @@ def test_original_saved_findings_keep_full_text_associations_and_source_fences(w # The prepared source adapter serves the same finding shape as the real App. source_server = RuntimeWorkspaceServer(GoalSource.parse(raw), w['tmp_path']) - worker = Thread(target=source_server.serve_forever, daemon=True); worker.start() + worker = Thread(target=source_server.serve_forever, daemon=True) + worker.start() try: with urlopen(f'http://127.0.0.1:{source_server.server_port}' + endpoint + query + '&view=findings', timeout=30) as response: assert json.load(response)['items'] == first['items'] finally: - source_server.shutdown(); worker.join(timeout=5); source_server.server_close() + source_server.shutdown() + worker.join(timeout=5) + source_server.server_close() request('/api/chat/goal-sources', {'parent_goal_id': 'research-owner', 'goal_id': 'solver-goal', 'source_namespace': namespace}) diff --git a/tests/test_chat_server_cors.py b/tests/test_chat_server_cors.py index 5cf8c447db..6374577205 100644 --- a/tests/test_chat_server_cors.py +++ b/tests/test_chat_server_cors.py @@ -691,6 +691,43 @@ def fail(*_args, **_kwargs): server.server_close() +def test_workspace_legacy_storage_refusal_exposes_recovery_without_mutation(tmp_path) -> None: + state = tmp_path / "GOAL.md" + source = "# Original Goal\n\n## Agent Todo\n\n- [ ] Original private task\n \n" + state.write_text(source) + registry = tmp_path / "registry.json" + registry.write_text(json.dumps({ + "schema_version": 1, + "common_runtime_root": str(tmp_path / "runtime"), + "goals": [{"id": "alpha", "repo": str(tmp_path), "state_file": state.name}], + })) + server, thread = _start_server() + server.registry_path = registry + server.runtime_root = tmp_path / "runtime" + server.runtime_root.mkdir() + server.runtime_root_override = str(server.runtime_root) + try: + response = _request(server.server_port, method="GET", origin=None, path="/status.json?goal_id=alpha") + assert response.status == 503 + assert json.loads(response.read()) == { + "ok": False, + "error": "This Goal needs a storage upgrade. Open its storage settings to review recovery.", + "error_code": "canonical_authority_required", + } + response = _request(server.server_port, method="GET", origin=None, + path="/api/chat/goal-storage?goal_id=alpha") + observed = json.loads(response.read()) + assert response.status == 200, observed + assert observed["current"]["canonical"] is False + assert observed["cold_source"]["active_todo_count"] == 1 + assert observed["pending_preview"] is None + assert state.read_text() == source + finally: + server.shutdown() + thread.join(timeout=5) + server.server_close() + + def test_workspace_directory_states_no_warning_for_a_present_registry(tmp_path) -> None: registry_path = tmp_path / ".loopx" / "registry.json" registry_path.parent.mkdir(parents=True)