Skip to content

Commit 466f147

Browse files
feat: surface research admission gates in settings
1 parent fe9fae9 commit 466f147

5 files changed

Lines changed: 265 additions & 2 deletions

File tree

apps/web/src/app/(workspace)/workspace/settings/SettingsClient.test.tsx

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,43 @@ function withToast(ui: React.ReactElement) {
1313
}
1414

1515
describe("SettingsClient account verification", () => {
16+
it("renders Runner admission gates from research boundaries without promoting watch items", () => {
17+
const markup = renderToStaticMarkup(withToast(
18+
<SettingsClient
19+
locale="en-US"
20+
oauthEnabled={{ google: true, github: true }}
21+
initialResearchBoundaries={{
22+
status: "ok",
23+
source: "registry",
24+
candidate_policy: "not-exposed-as-live-jobs",
25+
boundaries: [
26+
{
27+
key: "h2-output-cloud-geometry-candidate-no-runtime-job",
28+
title: "H2 output-cloud geometry candidate",
29+
admission_status: "watch",
30+
},
31+
{
32+
key: "rediffuse-stl10-bounded-scout-and-score-norm-completed-weak-results-no-runtime-job",
33+
title: "ReDiffuse STL-10 weak scout",
34+
admission_status: "watch",
35+
},
36+
],
37+
source_readiness: { ready: true },
38+
}}
39+
/>,
40+
));
41+
42+
expect(markup).toContain("data-runner-boundary-panel");
43+
expect(markup).toContain("Runner admission gates");
44+
expect(markup).toContain("Watch-only");
45+
expect(markup).toContain(">2<");
46+
expect(markup).toContain("Admitted");
47+
expect(markup).toContain(">0<");
48+
expect(markup).toContain("Research candidates stay outside live jobs");
49+
expect(markup).toContain("H2 output-cloud geometry candidate");
50+
expect(markup).toContain("ReDiffuse STL-10 weak scout");
51+
});
52+
1653
it("renders a verification entry point for pending email addresses", () => {
1754
const markup = renderToStaticMarkup(withToast(
1855
<SettingsClient

apps/web/src/app/(workspace)/workspace/settings/SettingsClient.tsx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ import { useTheme } from "@/hooks/use-theme";
1818
import type { ThemeMode } from "@/lib/theme";
1919
import type { CurrentUserProfile } from "@/lib/auth";
2020
import { setDemoModeClient } from "@/lib/demo-mode-client";
21+
import {
22+
getResearchBoundarySummary,
23+
type ResearchBoundariesPayload,
24+
} from "@/lib/research-boundaries";
2125
import { WORKSPACE_COPY } from "@/lib/workspace-copy";
2226

2327
const STORAGE_KEYS = {
@@ -58,7 +62,6 @@ type GatewayHealth = {
5862
date?: string;
5963
};
6064
};
61-
6265
function formatProviderName(provider: string) {
6366
if (provider === "google") return "Google";
6467
if (provider === "github") return "GitHub";
@@ -167,6 +170,7 @@ interface SettingsClientProps {
167170
initialProviderLinkStatus?: ProviderLinkStatus | null;
168171
oauthEnabled: { google: boolean; github: boolean };
169172
mode?: SettingsMode;
173+
initialResearchBoundaries?: ResearchBoundariesPayload | null;
170174
}
171175

172176
export function SettingsClient({
@@ -178,6 +182,7 @@ export function SettingsClient({
178182
initialProviderLinkStatus,
179183
oauthEnabled,
180184
mode = "settings",
185+
initialResearchBoundaries,
181186
}: SettingsClientProps) {
182187
const localeCopy = WORKSPACE_COPY[locale];
183188
const copy = localeCopy.settings;
@@ -226,6 +231,13 @@ export function SettingsClient({
226231
const [gatewayHealth, setGatewayHealth] = useState<GatewayHealth | null>(null);
227232
const [gatewayHealthError, setGatewayHealthError] = useState(false);
228233
const [gatewayHealthLoading, setGatewayHealthLoading] = useState(true);
234+
const [researchBoundaries, setResearchBoundaries] = useState<ResearchBoundariesPayload | null>(
235+
initialResearchBoundaries ?? null,
236+
);
237+
const [researchBoundariesError, setResearchBoundariesError] = useState(false);
238+
const [researchBoundariesLoading, setResearchBoundariesLoading] = useState(
239+
initialResearchBoundaries === undefined,
240+
);
229241

230242
// Audit templates
231243
const [templates, setTemplates] = useState<SavedTemplate[]>([]);
@@ -331,6 +343,42 @@ export function SettingsClient({
331343
};
332344
}, []);
333345

346+
useEffect(() => {
347+
if (initialResearchBoundaries !== undefined) return;
348+
349+
const controller = new AbortController();
350+
const timeoutId = window.setTimeout(() => controller.abort(), 3000);
351+
352+
async function loadResearchBoundaries() {
353+
try {
354+
const response = await fetch("/api/v1/research-boundaries", {
355+
cache: "no-store",
356+
signal: controller.signal,
357+
});
358+
if (!response.ok) {
359+
setResearchBoundariesError(true);
360+
return;
361+
}
362+
const payload = (await response.json().catch(() => null)) as ResearchBoundariesPayload | null;
363+
setResearchBoundaries(payload);
364+
setResearchBoundariesError(false);
365+
} catch {
366+
setResearchBoundaries(null);
367+
setResearchBoundariesError(true);
368+
} finally {
369+
window.clearTimeout(timeoutId);
370+
setResearchBoundariesLoading(false);
371+
}
372+
}
373+
374+
void loadResearchBoundaries();
375+
376+
return () => {
377+
window.clearTimeout(timeoutId);
378+
controller.abort();
379+
};
380+
}, [initialResearchBoundaries]);
381+
334382
useEffect(() => {
335383
setThemeMounted(true);
336384
}, []);
@@ -653,6 +701,10 @@ export function SettingsClient({
653701
{ key: "google", label: profile ? copy.account.connectGoogle : copy.account.signInGoogle },
654702
] as const).filter((provider) => oauthEnabled[provider.key] && (profile ? !connectedProviders.includes(provider.key) : true));
655703
const accessSummary = getAccessSummary(profile, copy.account);
704+
const researchBoundarySummary = getResearchBoundarySummary(
705+
researchBoundaries,
706+
copy.systemStatus.runnerBoundaryUnknown,
707+
);
656708
const accountStateItems = [
657709
{
658710
key: "email",
@@ -791,6 +843,59 @@ export function SettingsClient({
791843
</div>
792844
) : null}
793845

846+
<div className="rounded-2xl border border-border bg-muted/10 p-4" data-runner-boundary-panel>
847+
{researchBoundariesLoading ? (
848+
<div className="space-y-3">
849+
<div className="animate-pulse rounded-xl bg-muted/30 h-4 w-2/3" />
850+
<div className="animate-pulse rounded-xl bg-muted/30 h-4 w-1/2" />
851+
</div>
852+
) : (
853+
<>
854+
<div className="flex items-center justify-between gap-3">
855+
<span className="text-xs text-muted-foreground">{copy.systemStatus.runnerBoundary}</span>
856+
<StatusBadge tone={researchBoundarySummary.ready ? "info" : "neutral"} compact>
857+
{researchBoundarySummary.ready ? copy.systemStatus.runnerBoundaryReady : copy.systemStatus.runnerBoundaryUnavailable}
858+
</StatusBadge>
859+
</div>
860+
<div className="mt-3 grid grid-cols-2 gap-2">
861+
<div className="rounded-xl border border-border bg-background/40 px-2.5 py-2">
862+
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
863+
{copy.systemStatus.runnerBoundaryWatch}
864+
</div>
865+
<div className="mt-1 text-sm font-semibold text-foreground">
866+
{researchBoundarySummary.watchOnlyBoundaryCount}
867+
</div>
868+
</div>
869+
<div className="rounded-xl border border-border bg-background/40 px-2.5 py-2">
870+
<div className="text-[10px] uppercase tracking-[0.16em] text-muted-foreground">
871+
{copy.systemStatus.runnerBoundaryAdmitted}
872+
</div>
873+
<div className="mt-1 text-sm font-semibold text-foreground">
874+
{researchBoundarySummary.admittedBoundaryCount}
875+
</div>
876+
</div>
877+
</div>
878+
<p className="mt-3 text-[11px] leading-5 text-muted-foreground">
879+
{copy.systemStatus.runnerBoundaryPolicy}
880+
</p>
881+
{researchBoundarySummary.previewLabels.length > 0 ? (
882+
<div className="mt-3 space-y-1.5">
883+
{researchBoundarySummary.previewLabels.map((label) => (
884+
<div key={label} className="truncate rounded-lg bg-muted/20 px-2 py-1 font-mono text-[10px] text-muted-foreground">
885+
{label}
886+
</div>
887+
))}
888+
</div>
889+
) : null}
890+
</>
891+
)}
892+
</div>
893+
{researchBoundariesError ? (
894+
<div className="rounded-2xl border border-[var(--warning)]/30 bg-[var(--warning)]/10 px-3 py-2 text-[11px] leading-5 text-[var(--warning)]">
895+
{copy.systemStatus.runnerBoundaryError}
896+
</div>
897+
) : null}
898+
794899
<div className="rounded-2xl border border-border bg-muted/10 p-3">
795900
<div className="flex items-center justify-between gap-3">
796901
<div className="flex min-w-0 items-center gap-3">
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { getResearchBoundarySummary } from "./research-boundaries";
4+
5+
describe("getResearchBoundarySummary", () => {
6+
it("keeps watch-only research candidates out of admitted counts", () => {
7+
const summary = getResearchBoundarySummary({
8+
status: "ok",
9+
boundaries: [
10+
{
11+
key: "h2-output-cloud-geometry-candidate-no-runtime-job",
12+
title: "H2 output-cloud geometry candidate",
13+
admission_status: "watch",
14+
},
15+
{
16+
key: "rediffuse-stl10-bounded-scout-and-score-norm-completed-weak-results-no-runtime-job",
17+
title: "ReDiffuse STL-10 weak scout",
18+
admission_status: "watch",
19+
},
20+
],
21+
source_readiness: { ready: true },
22+
}, "Unnamed boundary");
23+
24+
expect(summary).toMatchObject({
25+
boundaryCount: 2,
26+
watchOnlyBoundaryCount: 2,
27+
admittedBoundaryCount: 0,
28+
ready: true,
29+
});
30+
expect(summary.previewLabels).toEqual([
31+
"H2 output-cloud geometry candidate",
32+
"ReDiffuse STL-10 weak scout",
33+
]);
34+
});
35+
36+
it("sanitizes boundary preview labels before they are rendered", () => {
37+
const summary = getResearchBoundarySummary({
38+
boundaries: [
39+
{
40+
title: "Failed at D:\\private\\runtime.log via http://127.0.0.1:8765",
41+
admission_status: "watch",
42+
},
43+
],
44+
}, "Unnamed boundary");
45+
46+
expect(summary.previewLabels[0]).not.toContain("D:\\private");
47+
expect(summary.previewLabels[0]).not.toContain("127.0.0.1");
48+
expect(summary.previewLabels[0]).toContain("<local-path>");
49+
expect(summary.previewLabels[0]).toContain("<runtime-url>");
50+
});
51+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { sanitizeRuntimeText } from "@/lib/runtime-text";
2+
3+
export type ResearchBoundary = {
4+
key?: string;
5+
title?: string;
6+
label?: string;
7+
status?: string;
8+
admission_status?: string;
9+
};
10+
11+
export type ResearchBoundariesPayload = {
12+
status?: string;
13+
source?: string;
14+
candidate_policy?: string;
15+
boundaries?: ResearchBoundary[];
16+
runtime_configured?: boolean;
17+
demo_mode?: boolean;
18+
source_readiness?: {
19+
configured?: boolean;
20+
ready?: boolean;
21+
};
22+
};
23+
24+
export type ResearchBoundarySummary = {
25+
boundaryCount: number;
26+
watchOnlyBoundaryCount: number;
27+
admittedBoundaryCount: number;
28+
ready: boolean;
29+
previewLabels: string[];
30+
};
31+
32+
export function getResearchBoundarySummary(
33+
payload: ResearchBoundariesPayload | null | undefined,
34+
fallbackLabel: string,
35+
): ResearchBoundarySummary {
36+
const boundaryItems = Array.isArray(payload?.boundaries)
37+
? payload.boundaries
38+
: [];
39+
40+
return {
41+
boundaryCount: boundaryItems.length,
42+
watchOnlyBoundaryCount: boundaryItems.filter((boundary) => !isAdmittedBoundary(boundary)).length,
43+
admittedBoundaryCount: boundaryItems.filter(isAdmittedBoundary).length,
44+
ready: payload?.status === "ok" || payload?.source_readiness?.ready === true,
45+
previewLabels: boundaryItems.slice(0, 3).map((boundary) => (
46+
sanitizeRuntimeText(boundary.title ?? boundary.label ?? boundary.key ?? fallbackLabel)
47+
?? fallbackLabel
48+
)),
49+
};
50+
}
51+
52+
function isAdmittedBoundary(boundary: ResearchBoundary) {
53+
return (boundary.admission_status ?? boundary.status ?? "").toLowerCase() === "admitted";
54+
}

apps/web/src/lib/workspace-copy.ts

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -753,7 +753,7 @@ export const WORKSPACE_COPY: Record<
753753
eyebrow: string;
754754
title: string;
755755
description: string;
756-
systemStatus: { title: string; runtime: string; snapshot: string; snapshotReady: string; snapshotMissing: string; build: string; unknown: string; demoMode: string; demoOn: string; demoOff: string; demoHintOn: string; demoHintOff: string; gatewayError: string };
756+
systemStatus: { title: string; runtime: string; snapshot: string; snapshotReady: string; snapshotMissing: string; build: string; unknown: string; demoMode: string; demoOn: string; demoOff: string; demoHintOn: string; demoHintOff: string; gatewayError: string; runnerBoundary: string; runnerBoundaryReady: string; runnerBoundaryUnavailable: string; runnerBoundaryWatch: string; runnerBoundaryAdmitted: string; runnerBoundaryPolicy: string; runnerBoundaryError: string; runnerBoundaryUnknown: string };
757757
auditConfig: { title: string; defaultRounds: string; defaultBatchSize: string; saved: string; roundsClamped: string; batchClamped: string };
758758
account: { title: string; username: string; email: string; pendingEmail: string; pendingEmailNote: string; addEmail: string; changeEmail: string; emailPlaceholder: string; saveEmail: string; savingEmail: string; cancelEmailEdit: string; emailSaved: string; emailInvalid: string; emailInUse: string; generateVerificationLink: string; generatingVerificationLink: string; verificationWorkspaceMode: string; verificationLinkReady: string; openVerificationLink: string; copyVerificationLink: string; showVerificationDetails: string; hideVerificationDetails: string; verificationLinkCopied: string; verificationRequestFailed: string; passwordSaveFailed: string; verificationSuccess: string; verificationMissing: string; verificationInvalid: string; verificationExpired: string; verificationMissingPending: string; providers: string; connectGoogle: string; connectGithub: string; signInGoogle: string; signInGithub: string; providerLinkedGoogle: string; providerLinkedGithub: string; providerAlreadyLinkedGoogle: string; providerAlreadyLinkedGithub: string; providerInUseGoogle: string; providerInUseGithub: string; accessSummary: string; accessSummaryPrefix: string; accessSummaryPasswordOn: string; accessSummaryPasswordOff: string; accessSummaryPendingEmail: string; accessSummaryNoProvider: string; connectAnotherProvider: string; password: string; passwordManage: string; passwordSet: string; passwordUnset: string; loginId: string; loginIdPending: string; verified: string; unverified: string; noEmail: string; securityNote: string; privacy: string; terms: string; currentPassword: string; currentPasswordPlaceholder: string; currentPasswordRequired: string; currentPasswordIncorrect: string; newPassword: string; newPasswordPlaceholder: string; confirmPassword: string; confirmPasswordPlaceholder: string; passwordHintNew: string; passwordHintExisting: string; openPasswordCreate: string; openPasswordChange: string; closePasswordEditor: string; createLocalAccount: string; savePassword: string; savingPassword: string; passwordSaved: string; passwordMismatch: string; passwordTooShort: string; passwordRequired: string; passwordUnauthorized: string; twoFactor: string; twoFactorHint: string; twoFactorEnabled: string; twoFactorDisabled: string; twoFactorEnable: string; twoFactorDisable: string; twoFactorSaving: string; twoFactorSavedOn: string; twoFactorSavedOff: string; twoFactorSaveFailed: string; twoFactorNetworkFailed: string; notSignedIn: string; chooseSignInMethod: string; githubAvatarPriority: string; logout: string };
759759
preferences: { title: string; language: string; languageNote: string; theme: string; themeLight: string; themeDark: string; themeSystem: string };
@@ -1749,6 +1749,14 @@ export const WORKSPACE_COPY: Record<
17491749
demoHintOn: "Using snapshot contracts, jobs, and reports across the workspace.",
17501750
demoHintOff: "Using live Runtime and API responses instead of snapshot data.",
17511751
gatewayError: "Gateway health check failed. Some status information may be unavailable.",
1752+
runnerBoundary: "Runner admission gates",
1753+
runnerBoundaryReady: "Synced",
1754+
runnerBoundaryUnavailable: "Unavailable",
1755+
runnerBoundaryWatch: "Watch-only",
1756+
runnerBoundaryAdmitted: "Admitted",
1757+
runnerBoundaryPolicy: "Research candidates stay outside live jobs until Runtime exposes an admitted contract.",
1758+
runnerBoundaryError: "Research boundary status is unavailable. Live job admission remains conservative.",
1759+
runnerBoundaryUnknown: "Unnamed boundary",
17521760
},
17531761
auditConfig: {
17541762
title: "Audit defaults",
@@ -2915,6 +2923,14 @@ export const WORKSPACE_COPY: Record<
29152923
demoHintOn: "当前工作台统一使用演示快照:合约、任务、报告都会显示模拟数据",
29162924
demoHintOff: "当前使用实时 Runtime / API 数据,不再显示演示快照。",
29172925
gatewayError: "网关健康检查失败,部分状态信息可能不可用。",
2926+
runnerBoundary: "Runner 准入边界",
2927+
runnerBoundaryReady: "已同步",
2928+
runnerBoundaryUnavailable: "不可用",
2929+
runnerBoundaryWatch: "观察中",
2930+
runnerBoundaryAdmitted: "已准入",
2931+
runnerBoundaryPolicy: "Research 候选结果在 Runtime 暴露已准入合约前,不会进入实时任务。",
2932+
runnerBoundaryError: "Research 边界状态不可用。实时任务准入仍保持保守策略。",
2933+
runnerBoundaryUnknown: "未命名边界",
29182934
},
29192935
auditConfig: {
29202936
title: "审计默认值",

0 commit comments

Comments
 (0)