diff --git a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs index b0ae9671c7..55dd52ca7c 100644 --- a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs @@ -10,7 +10,7 @@ await build({ configFile: false, logLevel: "silent", build: { lib: { entry: resolve("src/data/workspace-progressive-status.ts"), formats: ["es"], fileName: () => "loader.mjs" }, rolldownOptions: { external: ["zod"] }, } }); -const { loadWorkspaceGoalSnapshots, directoryStatusPayload, reusableGoalSnapshots, workspaceReadPlan } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href); +const { loadWorkspaceGoalSnapshots, directoryStatusPayload, reusableGoalSnapshots, scopeWorkspaceDirectory, workspaceReadPlan } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href); const directory = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1", goals: [{ id: "alpha", display_name: "Alpha", activation_state: "active", registry_member: true }] }; @@ -53,6 +53,21 @@ assert.deepEqual(partial.requestedDirectory.goals.map((goal) => goal.id), ["beta assert.deepEqual(workspaceReadPlan(null, earlier.directory, "missing").snapshots, {}, "a different source cannot retain the previous source's data"); const retentionChecks = 12; +const related = (id, parent_goal_id) => ({ ...entry(id), parent_goal_id }); +const forest = { ...directory, goals: [entry("parent"), related("child", "parent"), + related("grandchild", "child"), entry("daily"), related("orphan", "missing"), + related("cycle-a", "cycle-b"), related("cycle-b", "cycle-a")] }; +const subtree = scopeWorkspaceDirectory(forest, "parent"); +assert.deepEqual(subtree.goals.map(g => g.id), ["parent", "child", "grandchild"], + "the read frontier uses the same actual recursive hierarchy as the view"); +assert.equal(subtree.registry_revision, forest.registry_revision, "the original source revision remains authoritative"); +assert.equal(scopeWorkspaceDirectory(forest), forest, "unscoped reading preserves the existing directory exactly"); +assert.deepEqual(scopeWorkspaceDirectory(forest, "missing").goals, [], "a missing root cannot expose an unrelated tree"); +assert.deepEqual(scopeWorkspaceDirectory(forest, "cycle-a").goals.map(g => g.id), ["cycle-a"], + "cyclic organization cannot pull another root into a subtree"); +const scopedRefresh = workspaceReadPlan({ directory: forest, snapshots: { daily: { private: true } } }, subtree); +assert.deepEqual(scopedRefresh.snapshots, {}, "another subtree's retained state leaves the read model"); +assert.deepEqual(scopedRefresh.requestedDirectory.goals.map(g => g.id), ["parent", "child", "grandchild"]); const access = { error_code: "workspace_status_access_denied" }; const original = { fetch, setTimeout, clearTimeout }; const deadline = {}; @@ -87,6 +102,19 @@ async function check(name, respond, expected, attempts, verify = () => {}) { } const json = (body, status = 500) => Response.json(body, { status }); try { + const readGoals = []; + globalThis.fetch = async (url) => { + const id = new URL(url).searchParams.get("goal_id"); + readGoals.push(id); + return json({ ...directoryStatusPayload({ ...subtree, goals: subtree.goals.filter(g => g.id === id) }), + workspace_registry_revision: subtree.registry_revision }, 200); + }; + const loaded = []; + await loadWorkspaceGoalSnapshots("http://status/status.json", "http://status", scopedRefresh.requestedDirectory, + (id, _payload, error) => { assert.equal(error, null); loaded.push(id); }, () => true, () => "daily"); + assert.deepEqual(readGoals.sort(), ["child", "grandchild", "parent"], + "even an out-of-scope preferred Goal must not broaden actual state reads"); + assert.equal(loaded.length, 3); await check("access", () => json(access), ["access"], 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 562798e148..98a1fa2ca2 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -1,5 +1,6 @@ import { z } from "zod"; import { parseStatusPayload, type StatusPayload } from "./status"; +import { goalSubtree } from "../features/personal-workspace/goal-hierarchy"; /** The source facts a discovery read can state without opening runtime state; older services omit it. */ const directoryContractSchema = z.object({ @@ -31,6 +32,20 @@ export type WorkspaceProgress = { errors: Record; }; +/** Narrow only this source's discovered Goals before reading their state. + * Organization is not an access grant: an absent root stays absent, and the + * existing source membership/revision checks still validate every response. */ +export function scopeWorkspaceDirectory( + directory: WorkspaceDirectory, + rootGoalId?: string | null, +): WorkspaceDirectory { + if (!rootGoalId) return directory; + const included = new Set(goalSubtree(directory.goals.map(goal => ({ + goalId: goal.id, parentGoalId: goal.parent_goal_id, + })), rootGoalId).map(goal => goal.goalId)); + return { ...directory, goals: directory.goals.filter(goal => included.has(goal.id)) }; +} + /** * Snapshots a same-source read may keep visible while it reconciles. * diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index dd9e8c15fe..abee83cca6 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -9,7 +9,7 @@ import {compactWorkspaceText as compactShareText, workspaceAgentTodoFromItem} fr import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, attentionDetailsFromSnapshot, sourceAttention } from "../features/personal-workspace/attention-details"; import type { AttentionDetails } from "../features/personal-workspace/attention-details"; -import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, workspaceReadPlan, type WorkspaceProgress, type WorkspaceLoadError, type WorkspaceReadScope } from "../data/workspace-progressive-status"; +import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, scopeWorkspaceDirectory, workspaceReadPlan, type WorkspaceProgress, type WorkspaceLoadError, type WorkspaceReadScope } from "../data/workspace-progressive-status"; import { useEffect, useMemo, useRef, useState } from "react"; import { CircleAlert, Moon, RefreshCw, Sun } from "lucide-react"; import type { DigestAttentionSummary } from "../../../../../loopx/control_plane/presentation/goal_workspace_digest.js"; @@ -3260,6 +3260,9 @@ export function DashboardPage() { const progressiveAbortRef = useRef(null); const preferredGoalRef = useRef(search.goalId); preferredGoalRef.current = search.goalId; + const rootGoalRef = useRef(search.rootGoalId); + rootGoalRef.current = search.rootGoalId; + const requestedRootRef = useRef(undefined); const [payload, setPayload] = useState(exampleStatusPayload); const [source, setSource] = useState({ kind: "example", label: "bundled example" }); const [statusSourceCatalog, setStatusSourceCatalog] = useState(() => { @@ -3416,6 +3419,10 @@ export function DashboardPage() { selectionRevision: options.selectionRevision, }); if (!request) return; + const requestRoot = rootGoalRef.current; + requestedRootRef.current = requestRoot; + const canCommit = () => rootGoalRef.current === requestRoot + && statusRequestCanCommit(statusRequestFenceRef.current, request); progressiveAbortRef.current?.abort(); const progressiveAbort = new AbortController(); progressiveAbortRef.current = progressiveAbort; @@ -3428,9 +3435,12 @@ export function DashboardPage() { setGoalArchiveLoadState({ error: null, phase: "idle" }); } try { - const directory = await fetchWorkspaceDirectory(trimmed, window.location.href).catch(() => null); - if (!statusRequestCanCommit(statusRequestFenceRef.current, request)) return; - if (directory) { + const discovered = await fetchWorkspaceDirectory(trimmed, window.location.href).catch(() => null); + if (!canCommit()) return; + if (discovered) { + // Root views must not open unrelated Goals just to hide them later. + // Keep discovery metadata and the source's original revision fence. + const directory = scopeWorkspaceDirectory(discovered, requestRoot); // Keep valid snapshots on screen during a refresh. Only an explicit // partial read may skip them; lifecycle identity is not data freshness. const { snapshots, requestedDirectory } = workspaceReadPlan( @@ -3445,9 +3455,10 @@ export function DashboardPage() { setGoalArchiveLoadState({ error: null, phase: "loading" }); await loadWorkspaceGoalSnapshots(trimmed, window.location.href, requestedDirectory, (id, snapshot, error) => { + if (!canCommit()) return; if (error === "revision") directoryChanged = true; setProgress((current) => { - if (!current) return current; + if (!canCommit() || !current) return current; const snapshots = { ...current.snapshots }; const errors = { ...current.errors }; if (snapshot) { snapshots[id] = snapshot; delete errors[id]; } @@ -3455,16 +3466,16 @@ export function DashboardPage() { return { ...current, snapshots, errors }; }); }, - () => statusRequestCanCommit(statusRequestFenceRef.current, request), + canCommit, () => preferredGoalRef.current, progressiveAbort.signal, ); if (directoryChanged && (options.resyncAttempt ?? 0) < 1 - && statusRequestCanCommit(statusRequestFenceRef.current, request)) { + && canCommit()) { await loadFromUrl(trimmed, { resyncAttempt: 1 }); return; } - if (statusRequestCanCommit(statusRequestFenceRef.current, request)) { + if (canCommit()) { setGoalArchiveLoadState({ error: null, phase: "ready" }); } return; @@ -3472,7 +3483,7 @@ export function DashboardPage() { const nextPayload = await fetchStatusPayload( scopedStatusUrl(trimmed, "active", window.location.href), ); - if (!statusRequestCanCommit(statusRequestFenceRef.current, request)) return; + if (!canCommit()) return; setProgress(null); request.registryRevision = nextPayload.goal_projection?.registry_revision ?? null; if (!await commitLoadedStatus(trimmed, nextPayload, request)) return; @@ -3483,13 +3494,13 @@ export function DashboardPage() { } loadGoalArchive(trimmed, request, options.resyncAttempt ?? 0); } catch (error) { - if (!statusRequestIsCurrent(statusRequestFenceRef.current, request)) return; + if (!canCommit()) return; if (!background) setLoadError(formatStatusError(error)); // A confirmed action keeps its receipt while its caller reports that // the independent status read needs retrying. Passive refreshes stay quiet. if (options.rejectOnError) throw error; } finally { - if (!background && statusRequestIsCurrent(statusRequestFenceRef.current, request)) { + if (!background && canCommit()) { setIsLoading(false); } } @@ -3570,6 +3581,17 @@ export function DashboardPage() { }; useEffect(() => { + if (requestedRootRef.current !== search.rootGoalId && !exampleModeRequested) { + // Re-scope an in-flight source selection as well as a loaded source. + // Older root responses cannot populate the newly selected subtree. + // A history entry restores source and root together, so the route's + // restored source outranks the source that is still loaded. Only when + // the route names no source does the loaded source act as the fallback. + const target = requestedStatusUrl + ?? (search.statusUrl.trim() || (source.kind === "url" ? source.label : "")); + void loadFromUrl(target || defaultGlobalStatusUrl); + return; + } const trimmedStatusUrl = search.statusUrl.trim(); if (trimmedStatusUrl) { if (suppressedStatusUrlRef.current === trimmedStatusUrl) { @@ -3595,9 +3617,10 @@ export function DashboardPage() { if (source.kind === "example") { void loadFromUrl(defaultGlobalStatusUrl); } - }, [exampleModeRequested, requestedStatusUrl, search.statusUrl, source.kind, source.label]); + }, [exampleModeRequested, requestedStatusUrl, search.statusUrl, search.rootGoalId, source.kind, source.label]); useEffect(() => { + if (isLoading && search.rootGoalId) return; if (search.statusUrl && source.kind === "example") { return; } @@ -3622,7 +3645,7 @@ export function DashboardPage() { }), }); } - }, [goalArchiveLoadState.phase, goalRows, navigate, search.goalId, search.statusUrl, source.kind]); + }, [goalArchiveLoadState.phase, goalRows, isLoading, navigate, search.goalId, search.rootGoalId, search.statusUrl, source.kind]); useEffect(() => { if (!progress || isLoading || !search.goalId || source.kind !== "url") return; diff --git a/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/desktop.png b/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/desktop.png new file mode 100644 index 0000000000..a8da04e0f6 Binary files /dev/null and b/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/desktop.png differ diff --git a/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/phone.png b/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/phone.png new file mode 100644 index 0000000000..f9fba1caba Binary files /dev/null and b/examples/personal-workspace-browser/evidence/goal-subtree-read-scope/phone.png differ diff --git a/examples/personal-workspace-browser/goal-hierarchy.mjs b/examples/personal-workspace-browser/goal-hierarchy.mjs index da17eb4962..e43aaee40e 100644 --- a/examples/personal-workspace-browser/goal-hierarchy.mjs +++ b/examples/personal-workspace-browser/goal-hierarchy.mjs @@ -10,7 +10,8 @@ export const goalHierarchyScenario = { id: "goal-hierarchy", async run({browser, collectCoverage, url}) { const root = await mkdtemp(join(tmpdir(), "loopx-goal-tree-")); - let owner, context, denied = false; + let owner, ownerB, context, denied = false; + const statusReads = []; const captures = [ ["research", "Research · synthetic"], ["portfolio", "Portfolio", "research"], @@ -28,6 +29,16 @@ export const goalHierarchyScenario = { return path; })); owner = await startHistoryOwner(paths); + // A second, independent status backend with its own "research" root so a + // history entry can restore a source and a root scope together. + const remotePaths = await Promise.all([["research", "Research"], ["operations", "Operations"], + ["operations-child", "Operations child", "operations"]] + .map(async ([goal_id, display_name, parent_goal_id]) => { + const path = join(root, `remote-${goal_id}.json`); + await writeFile(path, JSON.stringify({goal_id, display_name, parent_goal_id, events: []})); + return path; + })); + ownerB = await startHistoryOwner(remotePaths); const directory = await (await fetch(`${owner.base}/status.json?view=workspace-directory`)).json(); assert.equal(directory.goals.find(g => g.id === "portfolio").parent_goal_id, "research"); const entry = new URL(url); @@ -35,13 +46,36 @@ export const goalHierarchyScenario = { research_pack: "a".repeat(64), research_analysis_goal: "calibration"}; for (const [key, value] of Object.entries(researchFocus)) entry.searchParams.set(key, value); context = await openWorkspacePage(browser, entry.href, {collectCoverage, beforeGoto: async (_api, page) => { + await page.addInitScript(({statusUrl}) => { + localStorage.setItem("loopx-status-source-catalog-v1", JSON.stringify({ + schemaVersion: 1, + sources: [{hostAlias: "remote-b", kind: "ssh_tunnel", label: "Remote B", statusUrl}], + })); + }, {statusUrl: `${ownerB.base}/status.json`}); + await page.route(new URL("/ssh-hosts", url).href, route => route.fulfill({ + contentType: "application/json", + json: {ok: true, schema_version: "ssh_host_catalog_v0", hosts: [{alias: "remote-b"}]}, + status: 200, + })); + await page.route(new URL("/api/ssh-source/ensure", url).href, route => route.fulfill({ + contentType: "application/json", + json: {ok: true, remote_started: true, status_url: `${ownerB.base}/status.json`, tunnel_required: true}, + status: 200, + })); await page.route(target => target.pathname === "/status.json", async route => { + const selected = new URL(route.request().url()).searchParams.get("goal_id"); + if (selected) statusReads.push(selected); if (denied && new URL(route.request().url()).searchParams.get("goal_id") === "graph") { return route.fulfill({status: 403, json: {ok: false, error_code: "workspace_status_access_denied"}}); } const response = await fetchWorkspaceBackend(route, owner.base); await route.fulfill({response}); }); + await page.route(`${ownerB.base}/status.json*`, async route => { + const response = await fetch(route.request().url()); + await route.fulfill({status: response.status, headers: { + "access-control-allow-origin": "*", "content-type": "application/json"}, body: await response.text()}); + }); }}); const {page, api} = context; const row = id => page.locator(`[data-reorder-goal="${id}"]`); @@ -64,18 +98,26 @@ export const goalHierarchyScenario = { assert.equal(await row("portfolio").count(), 0, "Children start folded on the ordinary workspace"); for (const id of ["daily", "orphan", "cycle-a", "cycle-b"]) await row(id).waitFor(); await page.screenshot({path: resolve(outputDir, "goal-hierarchy-overview.png"), animations: "disabled"}); + await page.waitForLoadState("networkidle"); + statusReads.length = 0; await row("research").locator(".personal-goal-link").click(); await page.getByRole("button", {name: "只看此 Goal 及子 Goal", exact: true}).click(); await page.waitForURL(target => target.searchParams.get("rootGoalId") === "research"); assertResearchFocus(); await row("portfolio").waitFor(); + await page.waitForLoadState("networkidle"); + assert(statusReads.every(id => ["calibration", "graph", "portfolio", "research"].includes(id)), + "reusing loaded subtree snapshots must not open unrelated Goal state"); for (const id of ["daily", "orphan", "cycle-a", "cycle-b"]) assert.equal(await row(id).count(), 0); assert.equal(await page.locator(".personal-manager-link").count(), 0); await row("portfolio").getByRole("button", {name: "展开子 Goal:Portfolio", exact: true}).focus(); await page.keyboard.press("Enter"); await row("calibration").locator(".personal-goal-link").click(); + statusReads.length = 0; await page.reload({waitUntil: "networkidle"}); await row("calibration").locator('[aria-current="page"]').waitFor(); + assert.deepEqual([...new Set(statusReads)].sort(), ["calibration", "graph", "portfolio", "research"], + "opening a scoped workspace reads its recursive subtree without opening unrelated Goal state"); assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research"); assertResearchFocus(); await assertTreeLayout(); @@ -111,9 +153,11 @@ export const goalHierarchyScenario = { const unavailable = new URL(page.url()); unavailable.searchParams.set("rootGoalId", "missing"); unavailable.searchParams.set("goalId", "daily"); + statusReads.length = 0; await page.goto(unavailable.href, {waitUntil: "networkidle"}); await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); assert.equal(await page.locator(".personal-goal-row").count(), 0); + assert.equal(statusReads.length, 0, "an absent root never fans out to unrelated Goal state"); assert.equal(await page.getByTestId("personal-first-goal").count(), 0); assert.equal(await page.locator(".personal-manager-greeting, .personal-composer-wrap").count(), 0, "An unavailable research root must not open an unrelated steward conversation"); @@ -139,6 +183,48 @@ export const goalHierarchyScenario = { await page.getByRole("alert").filter({hasText: "当前来源中此 Goal 范围不可用"}).waitFor(); assert.equal(await page.locator(".personal-goal-row").count(), 0, "Back preserves an explicitly invalid scope"); } + // A history entry restores the source and the root scope together. The + // restored route must drive the read; preferring the still-loaded source + // would re-read Remote B with source A's root and strand the page there. + const scopedA = new URL(url); + scopedA.searchParams.set("statusUrl", "/status.json"); + scopedA.searchParams.set("rootGoalId", "research"); + await page.goto(scopedA.href, {waitUntil: "networkidle"}); + await row("portfolio").waitFor(); + const sourceSelect = page.getByRole("combobox", {name: "选择控制面来源"}); + await sourceSelect.click(); + await page.getByRole("listbox", {name: "选择控制面来源"}) + .getByRole("option", {name: "Remote B", exact: true}).click(); + await page.waitForURL(target => target.searchParams.get("statusUrl")?.includes(`:${ownerB.port}/`)); + await page.waitForLoadState("networkidle"); + assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research", + "switching sources keeps the selected root"); + assert.equal(await row("portfolio").count(), 0, "Remote B has no children under its own research root"); + await page.getByRole("button", {name: "全部 Goals", exact: true}).click(); + await row("operations").waitFor(); + await row("operations").locator(".personal-goal-link").click(); + await page.getByRole("button", {name: "只看此 Goal 及子 Goal", exact: true}).click(); + await page.waitForURL(target => target.searchParams.get("rootGoalId") === "operations"); + await page.waitForLoadState("networkidle"); + await row("operations-child").waitFor(); + await page.locator(".personal-status-source-meta .is-connected").waitFor(); + await page.evaluate(() => history.go(-4)); + await page.waitForURL(target => target.searchParams.get("statusUrl") === "/status.json" + && target.searchParams.get("rootGoalId") === "research").catch(async error => { + throw new Error(`${error.message}; url=${page.url()}; errors=${context.errors.join(" | ")}; body=${(await page.locator("body").innerText()).slice(0, 1000)}`); + }); + await page.waitForLoadState("networkidle"); + await row("portfolio").waitFor().catch(async error => { + throw new Error(`${error.message}; url=${page.url()}; errors=${context.errors.join(" | ")}; body=${(await page.locator("body").innerText()).slice(0, 1000)}`); + }); + assert.equal(await row("operations").count(), 0, + "restoring the combined source and root entry returns to source A"); + assert.equal(new URL(new URL(page.url()).searchParams.get("statusUrl"), page.url()).href, + new URL("/status.json", page.url()).href, "the restored route keeps source A"); + assert.equal(new URL(page.url()).searchParams.get("rootGoalId"), "research", + "the restored scope still selects source A's research root"); + assert.equal(await sourceSelect.locator(".personal-select-value").innerText(), "本机", + "the restored history entry selects source A again"); assert.equal(api.turnRequests.length, 0); assert.equal(api.actionApplies.length, 0); const readback = await (await fetch(`${owner.base}/fixture/readback`)).json(); @@ -150,6 +236,7 @@ export const goalHierarchyScenario = { } finally { await context?.close(); await owner?.close(); + await ownerB?.close(); await rm(root, {recursive: true, force: true}); } }, diff --git a/loopx/cli_commands/support_control_chat.py b/loopx/cli_commands/support_control_chat.py index 61c1a96849..5037a6ca30 100644 --- a/loopx/cli_commands/support_control_chat.py +++ b/loopx/cli_commands/support_control_chat.py @@ -72,7 +72,7 @@ def register_chat_and_dashboard_commands( help="Open the local Goal Studio and review Agent-proposed LoopX Todos.", ) chat_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." + "--goal-id", help="Restrict this workspace to one Goal. Omit to browse the registered Goal hierarchy." ) chat_parser.add_argument( "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host." @@ -161,7 +161,7 @@ def register_chat_and_dashboard_commands( help="Start the local LoopX dashboard, status service, and Chat service.", ) dashboard_parser.add_argument( - "--goal-id", help="Goal to select when the local workspace opens." + "--goal-id", help="Restrict this workspace to one Goal. Omit to browse the registered Goal hierarchy." ) dashboard_parser.add_argument( "--host", default=DEFAULT_CHAT_HOST, help="Loopback bind host."