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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,6 @@ docs/superpowers/
# Claude
.claude/scheduled_tasks.lock
.claude/worktrees
support.js

# analytics-engine
apps/analytics-engine/*.md
Expand Down
40 changes: 23 additions & 17 deletions apps/control-center/src/client/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(<App />);
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 () => {
Expand Down
21 changes: 9 additions & 12 deletions apps/control-center/src/client/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<OverviewResponse>('/api/overview'),
getJson<CostHistoryResponse>('/api/costs/history'),
getJson<OperationsResponse>('/api/operations'),
getJson<PodcastCostResponse>('/api/costs/podcast'),
getJson<StatementsResponse>('/api/statements'),
]);
const [next, history, snapshot, episodeCosts] = await Promise.all([
getJson<OverviewResponse>('/api/overview'),
getJson<CostHistoryResponse>('/api/costs/history'),
getJson<OperationsResponse>('/api/operations'),
getJson<PodcastCostResponse>('/api/costs/podcast'),
]);
setOverview(next);
setCostHistory(history);
setPodcastCosts(episodeCosts);
setSocial(next.social);
setOperations(snapshot);
setStatements(statementsNext);
}),
[run],
);
Expand Down Expand Up @@ -530,8 +528,7 @@ function dashboardViewReady(input: {
input.overview &&
input.operations &&
input.podcastCosts &&
input.queues &&
input.journey,
input.costHistory,
);
}
if (input.view === 'pipeline') {
Expand Down
49 changes: 48 additions & 1 deletion apps/control-center/src/client/pages/TodayPage.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
33 changes: 31 additions & 2 deletions apps/control-center/src/client/pages/TodayPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<div className="cc-stack today-page">
Expand All @@ -74,7 +87,12 @@ export function TodayPage(props: {
</div>
</header>
<div className="cc-card-body">
<HeroNotice operations={operations} stale={stale} top={top.length} />
<HeroNotice
operations={operations}
stale={stale}
top={top.length}
unknownDomains={unknownDomains}
/>
<div className="today-actions">
{top.map((priority) => (
<ActionCard
Expand All @@ -83,7 +101,10 @@ export function TodayPage(props: {
priority={priority}
/>
))}
{operations?.status === 'healthy' && !stale && top.length === 0 ? (
{operations?.status === 'healthy' &&
!stale &&
!hasUnknown &&
top.length === 0 ? (
<EmptyState
detail="系統仍在收集 signals;有事情跨過 action threshold 才會出現在這裡。"
icon={ShieldCheck}
Expand Down Expand Up @@ -188,6 +209,7 @@ function HeroNotice(props: {
operations: OperationsResponse | null;
stale: boolean;
top: number;
unknownDomains: string[];
}) {
if (!props.operations) {
return <p className="today-notice">Waiting for operational signals.</p>;
Expand All @@ -199,6 +221,13 @@ function HeroNotice(props: {
</p>
);
}
if (props.unknownDomains.length > 0 && props.top === 0) {
return (
<p className="today-notice" role="status">
部分觀測缺失({props.unknownDomains.join('、')}),請查看可靠性。
</p>
);
}
if (props.operations.status !== 'healthy' && props.top === 0) {
return (
<p className="today-notice" role="status">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ function isRetryableRenderFailure(target: RenderTarget): boolean {
);
}

export { isRetryableRenderFailure };

export async function renderSignals(
store: OperatorStore,
now: Date,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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}`
Expand Down
Loading