diff --git a/.gitignore b/.gitignore index 7593cb6c9..f178655c0 100644 --- a/.gitignore +++ b/.gitignore @@ -88,7 +88,6 @@ docs/superpowers/ # Claude .claude/scheduled_tasks.lock .claude/worktrees -support.js # analytics-engine apps/analytics-engine/*.md diff --git a/apps/control-center/src/client/App.test.tsx b/apps/control-center/src/client/App.test.tsx index d22de923d..83e17095d 100644 --- a/apps/control-center/src/client/App.test.tsx +++ b/apps/control-center/src/client/App.test.tsx @@ -235,17 +235,15 @@ beforeEach(() => { afterEach(cleanup); describe('App orchestration', () => { - it('protects the invariant that Home renders only after both slow context reads settle', async () => { - let releaseQueues!: (value: unknown) => void; - const pendingQueues = new Promise((resolve) => { - releaseQueues = resolve; - }); + it('renders Home once the four home reads resolve while statements, queues and journey stay pending', async () => { + const pending = new Promise(() => {}); api.getJson.mockImplementation(async (url: string) => { - if (url === '/api/pipeline/queues') { - return pendingQueues; - } - if (url === '/api/growth') { - return acquisition; + if ( + url === '/api/statements' || + url === '/api/pipeline/queues' || + url === '/api/growth' + ) { + return pending; } if (url === '/api/overview') { return overview; @@ -259,19 +257,27 @@ describe('App orchestration', () => { if (url === '/api/costs/podcast') { return podcastCosts; } - if (url === '/api/statements') { - return statements; - } throw new Error(`unexpected read ${url}`); }); render(); - expect(await screen.findByText('skeleton-home')).toBeVisible(); - expect(screen.queryByText('home-ready')).toBeNull(); - releaseQueues(queues); expect(await screen.findByText('home-ready')).toBeVisible(); + expect(screen.queryByText('skeleton-home')).toBeNull(); expect(screen.getByTestId('shell')).toHaveTextContent('overview-at|false'); - // mutation target — removing queues from dashboardViewReady renders Home early. + }); + + it('never requests statements on Home and lazy-loads them once on the pipeline view', async () => { + await renderReadyHome(); + expect(api.getJson).not.toHaveBeenCalledWith('/api/statements'); + + await navigate('pipeline'); + expect(screen.getByText('statement-pipeline is healthy')).toBeVisible(); + expect(screen.getByTestId('shell')).toHaveTextContent('statements-at'); + const statementCalls = api.getJson.mock.calls.filter( + (call) => + (call[0] as string).replace('?force=1', '') === '/api/statements', + ); + expect(statementCalls).toHaveLength(1); }); it('protects the invariant that independent context failures degrade to readable data', async () => { diff --git a/apps/control-center/src/client/App.tsx b/apps/control-center/src/client/App.tsx index cc067880d..8edeaa12f 100644 --- a/apps/control-center/src/client/App.tsx +++ b/apps/control-center/src/client/App.tsx @@ -123,27 +123,25 @@ export function App() { // Today is the operator inbox. It composes existing read models rather than // creating another server contract: overview supplies company pulse and // release data, operations supplies ranked intervention candidates, and the - // persisted podcast ledger supplies failed-attempt spend. + // persisted podcast ledger supplies failed-attempt spend. Statements stay + // lazy: only the pipeline view reads them. const loadHome = useCallback( (sync = false) => run(async () => { if (sync) { await sendJson('/api/costs/sync', 'POST'); } - const [next, history, snapshot, episodeCosts, statementsNext] = - await Promise.all([ - getJson('/api/overview'), - getJson('/api/costs/history'), - getJson('/api/operations'), - getJson('/api/costs/podcast'), - getJson('/api/statements'), - ]); + const [next, history, snapshot, episodeCosts] = await Promise.all([ + getJson('/api/overview'), + getJson('/api/costs/history'), + getJson('/api/operations'), + getJson('/api/costs/podcast'), + ]); setOverview(next); setCostHistory(history); setPodcastCosts(episodeCosts); setSocial(next.social); setOperations(snapshot); - setStatements(statementsNext); }), [run], ); @@ -530,8 +528,7 @@ function dashboardViewReady(input: { input.overview && input.operations && input.podcastCosts && - input.queues && - input.journey, + input.costHistory, ); } if (input.view === 'pipeline') { diff --git a/apps/control-center/src/client/pages/TodayPage.test.tsx b/apps/control-center/src/client/pages/TodayPage.test.tsx index 15b77f774..bc1dca7a8 100644 --- a/apps/control-center/src/client/pages/TodayPage.test.tsx +++ b/apps/control-center/src/client/pages/TodayPage.test.tsx @@ -1,7 +1,7 @@ // @vitest-environment jsdom import '@testing-library/jest-dom/vitest'; -import { cleanup, render, screen } from '@testing-library/react'; +import { cleanup, render, screen, act } from '@testing-library/react'; import { afterEach, describe, expect, it, vi } from 'vitest'; import type { SocialGrowthJourney } from '../../shared/growth-journey.js'; @@ -307,6 +307,53 @@ describe('Today observer trust', () => { screen.queryByText('目前沒有需要你處理的 operational issue'), ).not.toBeInTheDocument(); }); + + it('names the unobserved domain instead of reporting healthy', () => { + renderToday({ + data: null, + operations: { + domains: [{ domain: 'costs', signalCount: 1, status: 'unknown' }], + generatedAt: new Date().toISOString(), + priorities: [], + signals: [], + status: 'healthy', + }, + podcastCosts: null, + }); + expect( + screen.queryByText('目前沒有需要你處理的 operational issue'), + ).not.toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveTextContent('costs'); + }); + + it('flips from healthy to stale on a timer without a refetch', () => { + vi.useFakeTimers(); + try { + renderToday({ + data: null, + operations: { + domains: [], + generatedAt: new Date().toISOString(), + priorities: [], + signals: [], + status: 'healthy', + }, + podcastCosts: null, + }); + expect( + screen.getByText('目前沒有需要你處理的 operational issue'), + ).toBeInTheDocument(); + act(() => { + vi.advanceTimersByTime(16 * 60_000); + }); + expect(screen.getByRole('status')).toHaveTextContent('已過期'); + expect( + screen.queryByText('目前沒有需要你處理的 operational issue'), + ).not.toBeInTheDocument(); + } finally { + vi.useRealTimers(); + } + }); }); describe('Today degradation', () => { diff --git a/apps/control-center/src/client/pages/TodayPage.tsx b/apps/control-center/src/client/pages/TodayPage.tsx index cfd6e2f20..beeeb4d83 100644 --- a/apps/control-center/src/client/pages/TodayPage.tsx +++ b/apps/control-center/src/client/pages/TodayPage.tsx @@ -6,6 +6,7 @@ import { ShieldCheck, TrendingUp, } from 'lucide-react'; +import { useEffect, useState } from 'react'; import type { SocialGrowthJourney } from '../../shared/growth-journey.js'; import { podcastCostEvidenceTotals } from '../../shared/podcast-cost-evidence.js'; @@ -53,7 +54,19 @@ export function TodayPage(props: { }) { const operations = props.operations; const top = operations?.priorities.slice(0, 3) ?? []; + // Re-evaluate staleness on a timer so an idle tab flips from healthy to + // stale without a refetch. The tick carries no data; Date.now() is read + // during render. + const [, setTick] = useState(0); + useEffect(() => { + const id = setInterval(() => setTick((tick) => tick + 1), 60_000); + return () => clearInterval(id); + }, []); const stale = isStale(operations); + const unknownDomains = (operations?.domains ?? []) + .filter((entry) => entry.status === 'unknown') + .map((entry) => entry.domain); + const hasUnknown = unknownDomains.length > 0; return (
@@ -74,7 +87,12 @@ export function TodayPage(props: {
- +
{top.map((priority) => ( ))} - {operations?.status === 'healthy' && !stale && top.length === 0 ? ( + {operations?.status === 'healthy' && + !stale && + !hasUnknown && + top.length === 0 ? ( Waiting for operational signals.

; @@ -199,6 +221,13 @@ function HeroNotice(props: {

); } + if (props.unknownDomains.length > 0 && props.top === 0) { + return ( +

+ 部分觀測缺失({props.unknownDomains.join('、')}),請查看可靠性。 +

+ ); + } if (props.operations.status !== 'healthy' && props.top === 0) { return (

diff --git a/apps/control-center/src/server/services/operations/operator/render-signals.ts b/apps/control-center/src/server/services/operations/operator/render-signals.ts index 3a74c4780..34b2b058a 100644 --- a/apps/control-center/src/server/services/operations/operator/render-signals.ts +++ b/apps/control-center/src/server/services/operations/operator/render-signals.ts @@ -21,6 +21,8 @@ function isRetryableRenderFailure(target: RenderTarget): boolean { ); } +export { isRetryableRenderFailure }; + export async function renderSignals( store: OperatorStore, now: Date, diff --git a/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts b/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts index 92112f4cf..9e197691e 100644 --- a/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts +++ b/apps/control-center/src/server/services/operations/operator/runner.coverage.test.ts @@ -233,4 +233,100 @@ describe('runOperatorCycle coverage gaps', () => { 'ops_retry_render', ]); }); + + it('skips an abandoned failed render and selects the retryable one', async () => { + const retryableId = '33333333-3333-4333-8333-333333333333'; + const abandoned = { + ...failedTarget(), + localizationId, + abandonedAt: '2026-09-05T11:34:35Z', + }; + const retryable = { + ...failedTarget(), + localizationId: retryableId, + abandonedAt: null, + }; + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([abandoned, retryable]), + }); + const ops = operations(); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(ops.investigate).toHaveBeenCalledWith( + `social-queue:render/${retryableId}`, + false, + ); + expect(result).toMatchObject({ + fingerprint: `social-queue:render/${retryableId}`, + }); + }); + + it('falls through to snapshot priorities when every failed render is terminal', async () => { + const fingerprint = 'sentry:issues/podcast-pipeline'; + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([ + { + ...failedTarget(), + abandonedAt: '2026-09-05T11:34:35Z', + }, + { + ...failedTarget(), + localizationId: '33333333-3333-4333-8333-333333333333', + visualVersion: 'v3', + }, + ]), + }); + const ops = operations({ + getOperations: vi.fn().mockResolvedValue({ + generatedAt: now.toISOString(), + priorities: [{ signal: { fingerprint } }], + }), + }); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(ops.investigate).toHaveBeenCalledWith(fingerprint, false); + expect(result).toEqual({ state: 'needs_human', fingerprint, action: null }); + }); + + it('goes idle when failed renders are terminal and there are no priorities', async () => { + const persistence = store({ + renderTargets: vi.fn().mockResolvedValue([ + { + ...failedTarget(), + abandonedAt: '2026-09-05T11:34:35Z', + }, + ]), + }); + const ops = operations({ + getOperations: vi.fn().mockResolvedValue({ + generatedAt: now.toISOString(), + priorities: [], + }), + }); + + const result = await runOperatorCycle({ + operations: ops, + store: persistence as unknown as OperatorStore, + config, + actor: 'test', + mutationsEnabled: true, + }); + + expect(result).toEqual({ state: 'idle' }); + expect(ops.investigate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/control-center/src/server/services/operations/operator/runner.ts b/apps/control-center/src/server/services/operations/operator/runner.ts index 1a6c76487..52b283aca 100644 --- a/apps/control-center/src/server/services/operations/operator/runner.ts +++ b/apps/control-center/src/server/services/operations/operator/runner.ts @@ -3,6 +3,7 @@ import { fixSchema, observeRecovery } from './observe.js'; import { randomUUID } from 'node:crypto'; import type { OpsMcpOperations } from '../../../mcp/types.js'; import { renderAction } from './actions.js'; +import { isRetryableRenderFailure } from './render-signals.js'; import type { OperatorStore } from './store.js'; type OperatorCycleInput = { @@ -95,8 +96,8 @@ async function runOperatorCycleBody(input: OperatorCycleInput) { } const snapshot = await input.operations.getOperations(false); const targets = await input.store.renderTargets(); - const target = targets.find( - (candidate) => candidate.renderStatus === 'failed', + const target = targets.find((candidate) => + isRetryableRenderFailure(candidate), ); const fingerprint = target ? `social-queue:render/${target.localizationId}`