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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ const directorySchema = z.object({
contract: directoryContractSchema.optional(),
});
export type WorkspaceDirectory = z.infer<typeof directorySchema>;
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;
Expand Down Expand Up @@ -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(() => {}); };
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down Expand Up @@ -2350,6 +2352,8 @@ const zhCN: Record<WorkspaceMessageKey, string> = {
"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 目录发生变化,请刷新以同步。",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2851,7 +2851,9 @@ export function PersonalWorkspacePage({
<section className="personal-manager-greeting" role="status" data-testid="goal-status-loading">
<div><strong>{t(selectedGoal.loadState === "error" ? "startup.goalError" : "startup.goalLoading")}</strong>
<p>{t(selectedGoal.loadError ? `startup.error.${selectedGoal.loadError}` : "startup.independent")}</p>
{selectedGoal.loadState === "error" ? <button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => void callbacks.onRefresh?.("missing")}>{t("startup.retry")}</button> : null}</div>
{selectedGoal.loadState === "error" ? selectedGoal.loadError === "authority" && !readOnly ?
<button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => openSettings({goalId: selectedGoal.goalId, kind: "settings", tab: "ownership"})}>{t("startup.storageSettings")}</button>
: <button className="min-h-11 rounded-md border px-3 py-2 text-sm" type="button" onClick={() => void callbacks.onRefresh?.("missing")}>{t("startup.retry")}</button> : null}</div>
</section>
) : selectedGoal ? (
<GoalWorkspacePanels key={`${researchReadSourceUrl ?? statusSourceControl?.activeSource.statusUrl ?? "/status.json"}:${selectedGoal.goalId}`}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export function WorkspaceSettingsPage({
const tab = tabs.find(item => 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,
Expand Down Expand Up @@ -248,15 +249,15 @@ export function WorkspaceSettingsPage({
/>
) : null}
{tab === "ownership" && editableGoal ? <div key={editableGoal.goalId}>
<GoalOwnershipSettings goalId={editableGoal.goalId} onChanged={onChanged} refreshKey={ownershipRefresh} />
{canonicalSettingsReady ? <GoalOwnershipSettings goalId={editableGoal.goalId} onChanged={onChanged} refreshKey={ownershipRefresh} /> : null}
<GoalStorageSettings goalId={editableGoal.goalId} onChanged={() => {
setOwnershipRefresh(value => value + 1);
onChanged();
}} />
<GoalAmendmentSettings goalId={editableGoal.goalId} />
{canonicalSettingsReady ? <><GoalAmendmentSettings goalId={editableGoal.goalId} />
<GoalObjectiveSettings goalId={editableGoal.goalId} />
<GoalDirectionSettings goalId={editableGoal.goalId} />
<GoalAcceptanceContractSection goalId={editableGoal.goalId} current canManage management copy={goalAcceptanceContractCopy[locale]} />
<GoalAcceptanceContractSection goalId={editableGoal.goalId} current canManage management copy={goalAcceptanceContractCopy[locale]} /></> : null}
</div> : null}
{tab === "capabilities" && (scope === "machine" || editableGoal) ? <ConfigurationBackupSettings key={`${scope}:${initialGoalId ?? ""}`} goalId={scope === "goal" ? editableGoal!.goalId : null} /> : null}
{tab === "cadence" && editableGoal ? <AutomationCadenceSettings key={editableGoal.goalId} goal={editableGoal} /> : null}
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/assets/goal-storage-recovery/mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
16 changes: 16 additions & 0 deletions docs/reference/local-authority-provider-selection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function startAuthority({unsettled = true} = {}) {
" <!-- loopx:todo todo_id=todo_old role=agent status=done -->\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");
Expand All @@ -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()
Expand Down
10 changes: 10 additions & 0 deletions examples/personal-workspace-browser/goal-showcase.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
31 changes: 28 additions & 3 deletions examples/personal-workspace-browser/goal-storage.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 数据存储"});
Expand Down Expand Up @@ -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(); }
}

Expand All @@ -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)});
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}));
Expand Down
Loading
Loading