From 09d3d0520b3e019421b2a419e29d13fc663f3d80 Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:48:04 +0800 Subject: [PATCH 1/2] fix(dashboard): re-read only the Goal an action touched Pausing one Goal sent every other Goal back to the loading lane. The reconciliation that follows a lifecycle action loaded the workspace directory and then threw away all per-Goal snapshots, so each peer had to be read again from the status service before its card returned. The directory entry is the cheap authoritative signal for "this Goal's lifecycle did not move here". A same-source refresh now keeps the snapshot of every Goal whose entry held, and re-reads the Goals the action touched or whose entry changed. The reconcile also names the touched Goal, so a peer is never re-read for another Goal's pause, resume or open, while the shipped full re-read on the manual refresh is unchanged. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../src/data/workspace-progressive-status.ts | 27 +++++++++++++++++ .../personal-workspace-model.ts | 4 ++- .../personal-workspace-page.tsx | 29 +++++++++++++------ .../dashboard/src/views/dashboard-page.tsx | 20 +++++++++---- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts index efb433cec0..2341796f30 100644 --- a/apps/presentation/dashboard/src/data/workspace-progressive-status.ts +++ b/apps/presentation/dashboard/src/data/workspace-progressive-status.ts @@ -20,6 +20,33 @@ export type WorkspaceProgress = { errors: Record; }; +/** + * Snapshots a same-source refresh may keep instead of re-reading. + * + * A directory entry is the cheap authoritative signal for "this Goal's + * lifecycle did not move here": when the entry is unchanged, its snapshot still + * describes the Goal, and re-reading it costs one full status collection per + * Goal. Goals the caller just acted on are never reused, and a Goal that left + * the directory loses its snapshot with it. + */ +export function reusableGoalSnapshots( + previous: Pick | null, + directory: WorkspaceDirectory, + options: { invalidateGoalIds?: Iterable } = {}, +): Record { + if (!previous) return {}; + const invalidated = new Set(options.invalidateGoalIds ?? []); + const before = new Map(previous.directory.goals.map((goal) => [goal.id, goal])); + return Object.fromEntries(directory.goals.flatMap((goal) => { + const earlier = before.get(goal.id); + const snapshot = previous.snapshots[goal.id]; + if (!earlier || !snapshot || invalidated.has(goal.id)) return []; + if (earlier.display_name !== goal.display_name + || earlier.activation_state !== goal.activation_state) return []; + return [[goal.id, snapshot]]; + })); +} + function queryUrl(url: string, fields: Record, base: string) { const parsed = new URL(url, base); parsed.searchParams.delete("goal_activation"); diff --git a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts index da085993a8..53bb9fbc1b 100644 --- a/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts +++ b/apps/presentation/dashboard/src/features/personal-workspace/personal-workspace-model.ts @@ -390,7 +390,9 @@ export type PersonalWorkspaceCallbacks = { projectionVerified: boolean; }>; onGoalDeleted?: (goalId: string) => void; - onReconcileStatus?: () => void | Promise; + /** Re-read the workspace projection after an applied action. `invalidateGoalIds` + * names the Goals the action touched, so a peer's snapshot is not re-read for it. */ + onReconcileStatus?: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh?: () => void | Promise; onRetryGoalArchive?: () => void | Promise; onPreviewAction?: (request: WorkspaceActionPreviewRequest) => WorkspaceActionPreview | Promise; 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 c9d25141a2..82af55ee3b 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 @@ -1194,7 +1194,7 @@ export function PersonalWorkspacePage({ callbacks.onGoalActivationStateChange?.(goal.goalId, result.activationState); setActionFeedback(t("feedback.completed", { title: summaryByOperation[operation] })); if (operation === "stop") selectGoal(null); - await (callbacks.onReconcileStatus ?? callbacks.onRefresh)?.(); + await reconcileStatus([goal.goalId]); return; } const proposal = await createPreview({ @@ -1320,6 +1320,22 @@ export function PersonalWorkspacePage({ } } + /** + * Reconcile the projection after an applied action. The touched Goal is the + * only one whose snapshot is dropped; a peer keeps the snapshot it already + * had, so one Goal's pause does not send the rest of the workspace back to + * its loading lane. + */ + function reconcileStatus(invalidateGoalIds?: string[]) { + const reconcile = callbacks.onReconcileStatus; + const request = reconcile + ? reconcile({ invalidateGoalIds }) + : callbacks.onRefresh?.(); + return Promise.resolve(request).catch(() => { + setActionFeedback(t("feedback.goalRefreshFailed")); + }); + } + async function applyProposal( proposal: WorkspaceActionPreview, options: { @@ -1362,8 +1378,7 @@ export function PersonalWorkspacePage({ if (proposal.lifecycleOperation === "delete" && proposal.goalId) { callbacks.onGoalDeleted?.(proposal.goalId); } - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => undefined); + void reconcileStatus(proposal.goalId ? [proposal.goalId] : undefined); } return; } @@ -1404,8 +1419,7 @@ export function PersonalWorkspacePage({ callbacks.onGoalDeleted?.(applied.goalId); } if (applied.actionKind === "goal.lifecycle") { - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => undefined); + void reconcileStatus(applied.goalId ? [applied.goalId] : undefined); } } catch (error) { if (lifecycleChange) { @@ -1460,10 +1474,7 @@ export function PersonalWorkspacePage({ }, onOpenGoal: (goalId) => { selectGoal(goalId); - const reconcile = callbacks.onReconcileStatus ?? callbacks.onRefresh; - void Promise.resolve().then(() => reconcile?.()).catch(() => { - setActionFeedback(t("feedback.goalRefreshFailed")); - }); + void reconcileStatus([goalId]); }, onOpenGoalView: (tab) => { setSelectedGoalTab(tab); diff --git a/apps/presentation/dashboard/src/views/dashboard-page.tsx b/apps/presentation/dashboard/src/views/dashboard-page.tsx index e2ba346c63..4dc1ede6c0 100644 --- a/apps/presentation/dashboard/src/views/dashboard-page.tsx +++ b/apps/presentation/dashboard/src/views/dashboard-page.tsx @@ -1,7 +1,7 @@ import type { GoalAcceptanceObservation } from "../data/goal-acceptance-observation"; import { attentionDetails, sourceAttention } from "../features/personal-workspace/attention-details"; import type { AttentionDetails } from "../features/personal-workspace/attention-details"; -import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status"; +import { directoryStatusPayload, fetchWorkspaceDirectory, loadWorkspaceGoalSnapshots, reusableGoalSnapshots, type WorkspaceProgress, type WorkspaceLoadError } from "../data/workspace-progressive-status"; import { useEffect, useMemo, useRef, useState } from "react"; import { CircleAlert, Moon, RefreshCw, Sun } from "lucide-react"; @@ -1369,7 +1369,7 @@ function PersonalGoalHome({ onGoalActivationStateChange: (goalId: string, activationState: "active" | "stopped") => void; onGoalDeleted: (goalId: string) => void; onSelectGoal: (goalId: string) => void; - onReconcileStatus: () => void | Promise; + onReconcileStatus: (options?: { invalidateGoalIds?: string[] }) => void | Promise; onRefresh: () => void | Promise; onRetryGoalArchive: () => void | Promise; payload: StatusPayload; @@ -3088,6 +3088,8 @@ export function DashboardPage() { options: { background?: boolean; retryOnly?: boolean; + reuseSnapshots?: boolean; + invalidateGoalIds?: string[]; resyncAttempt?: number; selectionRevision?: number; } = {}, @@ -3118,8 +3120,14 @@ export function DashboardPage() { const directory = await fetchWorkspaceDirectory(trimmed, window.location.href).catch(() => null); if (!statusRequestCanCommit(statusRequestFenceRef.current, request)) return; if (directory) { - const retained = options.retryOnly && source.kind === "url" && source.label === trimmed - && progress?.directory.registry_revision === directory.registry_revision ? progress.snapshots : {}; + // A refresh that keeps the same source only re-reads the Goals whose + // directory entry moved or that the caller just acted on. Dropping every + // snapshot here would send the whole workspace back to its loading lane + // after one Goal's pause, resume or open. + const retained = (options.retryOnly || options.reuseSnapshots) + && source.kind === "url" && source.label === trimmed + ? reusableGoalSnapshots(progress, directory, { invalidateGoalIds: options.invalidateGoalIds }) + : {}; setProgress({ directory, snapshots: retained, errors: {} }); const requestedDirectory = { ...directory, goals: directory.goals.filter((goal) => !retained[goal.id]) }; let directoryChanged = false; @@ -3351,9 +3359,9 @@ export function DashboardPage() { ) } : current); }} onSelectGoal={selectGoal} - onReconcileStatus={() => loadFromUrl( + onReconcileStatus={(options) => loadFromUrl( source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), - { background: true }, + { background: true, invalidateGoalIds: options?.invalidateGoalIds, reuseSnapshots: true }, )} onRetryGoalArchive={retryGoalArchive} onRefresh={() => loadFromUrl(source.kind === "url" ? source.label : (statusUrl || defaultGlobalStatusUrl), { retryOnly: Boolean(progress && Object.keys(progress.errors).length) })} From 9ad5fcc8beb512f6b850643603bf18eeadd3027a Mon Sep 17 00:00:00 2001 From: huangruiteng <14976749+huangruiteng@users.noreply.github.com> Date: Wed, 16 Sep 2026 21:48:04 +0800 Subject: [PATCH 2/2] test(dashboard): cover progressive loading across a Goal lifecycle action The browser harness never served the workspace directory, so the progressive path had no end-to-end coverage at all and the peer re-read could not be caught. The fixture can now answer the directory view and one Goal at a time, behind an explicit option so every existing scenario keeps its current path, and the new scenario pauses and resumes one Goal while asserting that no peer is re-read, no card re-enters the loading state and every peer card stays on the board. The loader smoke adds the retention rule itself, including the touched Goal, a renamed Goal and a Goal that left the directory. Signed-off-by: huangruiteng <14976749+huangruiteng@users.noreply.github.com> --- .../workspace-progressive-status-smoke.mjs | 34 +++++++- examples/personal-workspace-browser-smoke.mjs | 3 +- .../personal-workspace-browser/fixture.mjs | 86 +++++++++++++++---- .../progressive-loading.mjs | 86 +++++++++++++++++++ 4 files changed, 188 insertions(+), 21 deletions(-) create mode 100644 examples/personal-workspace-browser/progressive-loading.mjs diff --git a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs index 0040a8a4f6..048ea9598f 100644 --- a/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs +++ b/apps/presentation/dashboard/smoke/workspace-progressive-status-smoke.mjs @@ -10,9 +10,39 @@ 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 } = await import(pathToFileURL(resolve(outDir, "loader.mjs")).href); +const { loadWorkspaceGoalSnapshots, directoryStatusPayload, reusableGoalSnapshots } = 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 }] }; + +// What a same-source refresh may keep: a paused Goal must not send its peers +// back to the loading lane, and the touched Goal must not survive as a stale +// snapshot. +const entry = (id, activation_state = "active", display_name = id) => + ({ id, display_name, activation_state, registry_member: true }); +const snapshots = { alpha: { alpha: true }, beta: { beta: true }, gamma: { gamma: true } }; +const earlier = { + directory: { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r1", + goals: [entry("alpha"), entry("beta"), entry("gamma")] }, + snapshots, +}; +const paused = { ok: true, schema_version: "loopx_workspace_directory_v1", registry_revision: "r2", + goals: [entry("alpha"), entry("beta", "stopped"), entry("gamma")] }; +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused)), ["alpha", "gamma"], + "a Goal whose lifecycle moved is re-read instead of reused"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["gamma"] })), ["alpha"], + "a Goal the action touched is re-read even when its directory entry held"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["beta"] })), ["alpha", "gamma"], + "invalidating the Goal that already changed keeps its peers"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, { + ...paused, goals: [entry("alpha"), entry("gamma"), entry("delta")], +})), ["alpha", "gamma"], "a Goal that left the directory loses its snapshot"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, { + ...paused, goals: [entry("alpha"), entry("beta", "active", "Renamed"), entry("gamma")], +})), ["alpha", "gamma"], "a renamed Goal is re-read rather than reused under its new title"); +assert.deepEqual(reusableGoalSnapshots(null, paused), {}, "a first read has nothing to reuse"); +assert.deepEqual(Object.keys(reusableGoalSnapshots(earlier, paused, { invalidateGoalIds: ["alpha", "gamma"] })), [], + "invalidating every Goal is a full re-read"); +const retentionChecks = 7; const access = { error_code: "workspace_status_access_denied" }; const original = { fetch, setTimeout, clearTimeout }; const deadline = {}; @@ -79,7 +109,7 @@ try { const payload = { ...directoryStatusPayload(directory), workspace_registry_revision: "r1" }; await check("success", () => json(payload, 200), ["success"], 1); await check("wrong Goal", () => json({ ...payload, run_history: { ...payload.run_history, goals: [{ id: "beta" }] } }, 200), ["scope"], 1); - console.log(JSON.stringify({ ok: true, checks })); + console.log(JSON.stringify({ ok: true, checks: checks + retentionChecks })); } finally { globalThis.fetch = original.fetch; globalThis.setTimeout = original.setTimeout; diff --git a/examples/personal-workspace-browser-smoke.mjs b/examples/personal-workspace-browser-smoke.mjs index 7c665a9681..16872f5371 100644 --- a/examples/personal-workspace-browser-smoke.mjs +++ b/examples/personal-workspace-browser-smoke.mjs @@ -22,10 +22,11 @@ import { startServer, } from "./personal-workspace-browser/fixture.mjs"; import { navigationSortingScenario } from "./personal-workspace-browser/navigation-sorting.mjs"; +import { progressiveLoadingScenario } from "./personal-workspace-browser/progressive-loading.mjs"; import { teamPlanScenario } from "./personal-workspace-browser/team-plan.mjs"; import { typedActionsScenario } from "./personal-workspace-browser/typed-actions.mjs"; -const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario]; +const scenarioCatalog = [navigationSortingScenario, chatRecoveryScenario, typedActionsScenario, teamPlanScenario, executionChipScenario, progressiveLoadingScenario]; const requestedScenario = process.env.LOOPX_PERSONAL_WORKSPACE_SCENARIO; const scenarios = requestedScenario ? scenarioCatalog.filter((scenario) => scenario.id === requestedScenario) diff --git a/examples/personal-workspace-browser/fixture.mjs b/examples/personal-workspace-browser/fixture.mjs index 543623a8fc..4c0c92eefe 100644 --- a/examples/personal-workspace-browser/fixture.mjs +++ b/examples/personal-workspace-browser/fixture.mjs @@ -261,9 +261,28 @@ function capturedStatusGeneration(state) { return state.capturedStatusGeneration; } -function filterStatusFixtureToScope(fixture, statusGeneration, scope) { - const activationForGoal = (goalId) => statusGeneration.get(goalId) ?? "active"; - const matchesScope = (goalId) => activationForGoal(goalId) === scope; +/** The registry revision the progressive loader fences every Goal read against. */ +function workspaceRegistryRevision(state) { + return [...capturedStatusGeneration(state).entries()] + .sort(([left], [right]) => left.localeCompare(right)) + .map(([goalId, activationState]) => `${goalId}:${activationState}`) + .join("|"); +} + +/** + * Goals the fixture injects into both the workspace directory and the status + * payload. One list keeps a Goal's registered name identical in a per-Goal read + * and in the directory entry that outlives its snapshot. + */ +const directoryGoalFixtures = [ + { id: "product-release", display_name: "Product Release" }, + { id: "research-monitor", display_name: "Research Monitor" }, + { id: "progress-projection", display_name: "Progress Projection" }, + { id: "legacy-benchmark", display_name: "Legacy Benchmark" }, + { id: "archived-notes", display_name: "Archived Notes" }, +]; + +function filterStatusFixtureToScope(fixture, matchesScope) { fixture.attention_queue.items = fixture.attention_queue.items.filter((item) => matchesScope(item.goal_id)); fixture.attention_queue.item_count = fixture.attention_queue.items.length; if (fixture.todo_index?.items) { @@ -288,7 +307,7 @@ function filterStatusFixtureToScope(fixture, statusGeneration, scope) { } } -export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, runtimeAgents = null } = {}) { +export async function installApi(page, { goalSubagentConfigurationEnabled = true, initialActionProposals = [], managerChannelBinding = null, progressiveWorkspace = false, runtimeAgents = null } = {}) { let turnCounter = 0; const runtime = page.__loopxRuntime ??= { actionProposals: new Map(), goalSubagentConfigurations: new Map(), larkConnections: [], messages: new Map(), sessions: new Map(), turnMessages: new Map() }; const actionProposals = runtime.actionProposals; @@ -359,12 +378,34 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true capturedStatusGeneration: null, activationChangeAfterCapturedActive: null, statusRequestCount: 0, + // Progressive loading reads one Goal at a time; the scenario asserts which + // Goals a single action sent back through the loader. + goalStatusRequests: [], + workspaceDirectoryRequests: 0, turnRequests: [], get larkConnections() { return runtime.larkConnections; }, get goalSubagentConfigurations() { return runtime.goalSubagentConfigurations; }, }; await page.route(`http://127.0.0.1:${port}/status.json*`, async (route) => { state.statusRequestCount += 1; + const requestUrl = new URL(route.request().url()); + const requestedGoalId = requestUrl.searchParams.get("goal_id"); + if (progressiveWorkspace && requestUrl.searchParams.get("view") === "workspace-directory") { + state.workspaceDirectoryRequests += 1; + // The directory carries the same registered names as the per-Goal + // payloads, so a Goal keeps its title while it has no snapshot. + const registeredNames = new Map(directoryGoalFixtures.map((goal) => [goal.id, goal.display_name])); + await route.fulfill({ contentType: "application/json", json: { + ok: true, + schema_version: "loopx_workspace_directory_v1", + registry_revision: workspaceRegistryRevision(state), + goals: [...state.goalActivationStates.entries()].map(([id, activation_state]) => ({ + activation_state, display_name: registeredNames.get(id) ?? id, id, registry_member: true, + })), + }, status: 200 }); + return; + } + if (progressiveWorkspace && requestedGoalId) state.goalStatusRequests.push(requestedGoalId); const fixture = structuredClone(require(resolve(repoRoot, "examples/status.example.json"))); const defaultSubagentConfiguration = { mode: "default", spawn_allowed: false, max_children: 0, allowed_domains: [] }; const projectedSubagentConfiguration = (goalId, fallback) => state.freezeGoalSubagentStatusProjection @@ -376,14 +417,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true periodic_report_index_url: "/periodic-report-workspace", periodic_report_detail_url: "/periodic-report-workspace-projection", }; - const directoryFixtures = [ - { id: "product-release", display_name: "Product Release" }, - { id: "research-monitor", display_name: "Research Monitor" }, - { id: "progress-projection", display_name: "Progress Projection" }, - { id: "legacy-benchmark", display_name: "Legacy Benchmark" }, - { id: "archived-notes", display_name: "Archived Notes" }, - ]; - for (const directoryGoal of directoryFixtures) { + for (const directoryGoal of directoryGoalFixtures) { const activation_state = statusGeneration.get(directoryGoal.id) ?? "active"; const existingGoal = fixture.run_history.goals.find((goal) => goal.id === directoryGoal.id); if (existingGoal) { @@ -584,10 +618,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true const isActiveScope = goalActivationScope === "active"; const activeGoalCount = fixture.run_history.goals.filter((goal) => goal.activation_state !== "stopped").length; const stoppedGoalCount = fixture.run_history.goals.length - activeGoalCount; - const registryRevision = [...statusGeneration.entries()] - .sort(([left], [right]) => left.localeCompare(right)) - .map(([goalId, activationState]) => `${goalId}:${activationState}`) - .join("|"); + const registryRevision = workspaceRegistryRevision(state); const delayMs = state.nextStatusDelayMs; state.nextStatusDelayMs = 0; if (delayMs > 0) await new Promise((resolveWait) => setTimeout(resolveWait, delayMs)); @@ -596,6 +627,25 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true await route.fulfill({ contentType: "application/json", json: { error: "temporary status failure" }, status: 503 }); return; } + if (progressiveWorkspace) { + // The real service answers a per-Goal read with exactly that Goal and the + // registry revision the directory was read at. + fixture.workspace_registry_revision = registryRevision; + if (requestedGoalId) { + fixture.goal_projection = { + schema_version: "loopx_goal_projection_scope_v0", + scope: "active", + complete: true, + projected_goal_count: 1, + registry_goal_count: fixture.run_history.goals.length, + registry_revision: registryRevision, + }; + fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.id === requestedGoalId); + filterStatusFixtureToScope(fixture, (goalId) => goalId === requestedGoalId); + await route.fulfill({ contentType: "application/json", json: fixture, status: 200 }); + return; + } + } if (isActiveScope) { fixture.goal_projection = { schema_version: "loopx_goal_projection_scope_v0", @@ -606,7 +656,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true registry_revision: registryRevision, }; fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.activation_state !== "stopped"); - filterStatusFixtureToScope(fixture, statusGeneration, "active"); + filterStatusFixtureToScope(fixture, (goalId) => (statusGeneration.get(goalId) ?? "active") === "active"); // Freeze only the first half of the active-first read. The stopped // request must observe the registry after the intervening lifecycle // change so this fixture exercises the cross-snapshot revision fence. @@ -635,7 +685,7 @@ export async function installApi(page, { goalSubagentConfigurationEnabled = true registry_revision: registryRevision, }; fixture.run_history.goals = fixture.run_history.goals.filter((goal) => goal.activation_state === "stopped"); - filterStatusFixtureToScope(fixture, statusGeneration, "stopped"); + filterStatusFixtureToScope(fixture, (goalId) => (statusGeneration.get(goalId) ?? "active") === "stopped"); } else { fixture.goal_projection = { schema_version: "loopx_goal_projection_scope_v0", diff --git a/examples/personal-workspace-browser/progressive-loading.mjs b/examples/personal-workspace-browser/progressive-loading.mjs new file mode 100644 index 0000000000..1d3c869a69 --- /dev/null +++ b/examples/personal-workspace-browser/progressive-loading.mjs @@ -0,0 +1,86 @@ +import { openWorkspacePage } from "./scenario-context.mjs"; + +// One Goal's lifecycle action must not send the rest of the workspace back to +// the loading lane. The directory entry is the cheap signal for "this Goal did +// not move", so only the touched Goal is re-read. +export const progressiveLoadingScenario = { + id: "progressive-loading", + async run({ browser, collectCoverage, url }) { + const context = await openWorkspacePage(browser, url, { + apiOptions: { progressiveWorkspace: true }, + collectCoverage, + }); + const { api, coverageEntries, errors: pageErrors, page } = context; + // Only the product's own reads are asserted: the development server may + // refuse an asset it considers outside its root, which is a harness path + // question rather than a client failure. + const failedResponses = []; + page.on("response", (response) => { + if (response.status() >= 400 && response.url().includes("/api/")) { + failedResponses.push(`${response.status()} ${response.url()}`); + } + }); + try { + await page.waitForFunction( + () => document.querySelectorAll(".personal-home-lane[aria-live]").length === 0, + null, + { timeout: 60_000 }, + ); + if (api.workspaceDirectoryRequests === 0) throw new Error("Progressive loading never read the workspace directory"); + const cardTitles = () => page.locator(".personal-home-lanes .personal-home-goal-card strong").allInnerTexts(); + const peers = (await cardTitles()).filter((title) => title !== "Product Release"); + if (peers.length === 0) throw new Error("Progressive loading rendered no peer Goal cards"); + const loadingCards = () => page.locator('.personal-home-goal-card[data-goal-state="loading"]').count(); + if (await loadingCards()) throw new Error("The workspace settled with a Goal still in its loading state"); + + api.goalStatusRequests.length = 0; + await page.getByRole("button", { name: "停止 Product Release", exact: true }).click(); + await page.waitForTimeout(3_000); + + const rereadPeers = [...new Set(api.goalStatusRequests)].filter((goalId) => goalId !== "product-release"); + if (rereadPeers.length) { + throw new Error(`Pausing one Goal re-read its peers: ${JSON.stringify(rereadPeers)}`); + } + if (await loadingCards()) throw new Error("Pausing one Goal sent the workspace back to its loading lane"); + const afterStop = await cardTitles(); + for (const title of peers) { + if (!afterStop.includes(title)) throw new Error(`Pausing one Goal dropped the peer card ${title}`); + } + const stoppedDirectory = page.locator(".personal-stopped-goals"); + if (!await stoppedDirectory.evaluate((node) => node.open)) { + await stoppedDirectory.locator("summary").click(); + } + + api.goalStatusRequests.length = 0; + await page.getByRole("button", { name: "恢复 Product Release", exact: true }).click(); + await page.getByText("确认执行", { exact: true }).waitFor({ state: "visible" }); + await page.locator('[data-action-review="review"]').filter({ hasText: "恢复自动调度前需要确认" }).waitFor({ state: "visible" }); + await page.getByRole("button", { name: "恢复 Goal", exact: true }).click(); + await page.waitForTimeout(3_000); + + const rereadPeersAfterResume = [...new Set(api.goalStatusRequests)] + .filter((goalId) => goalId !== "product-release"); + if (rereadPeersAfterResume.length) { + throw new Error(`Resuming one Goal re-read its peers: ${JSON.stringify(rereadPeersAfterResume)}`); + } + if (await loadingCards()) throw new Error("Resuming one Goal sent the workspace back to its loading lane"); + const resumed = await cardTitles(); + for (const title of peers) { + if (!resumed.includes(title)) throw new Error(`Resuming one Goal dropped the peer card ${title}`); + } + if (!resumed.includes("Product Release")) throw new Error("The resumed Goal did not return to the workspace board"); + + if (failedResponses.length) throw new Error(`Progressive reconciliation failed requests: ${failedResponses.join(" | ")}`); + // The harness collects uncaught page errors and console errors; a + // dev-server resource status is not a client-side exception. + const scriptErrors = pageErrors.filter((message) => !message.startsWith("Failed to load resource")); + if (scriptErrors.length) throw new Error(`Progressive reconciliation raised page errors: ${scriptErrors.join(" | ")}`); + return { + coverageEntries, + note: "progressive loading reads the workspace directory once and then one Goal at a time; a single Goal's pause and resume re-read no peer, never re-enters the loading lane and keeps every peer card on the board", + }; + } finally { + coverageEntries.push(...await context.close()); + } + }, +};