feat(features): FF-01 功能开关文件化配置(fail-open 可见性开关)
- 后端 server/agent_core/feature_flags.py:12 键功能目录 + JSON 配置(默认 server/data/features.json,APS_FEATURES_PATH 可覆盖) - 网关 GET /api/features(P0 只读,实时读文件,fail-open 显式 error) - 前端四处入口按开关隐藏:右侧导轨 / ProjectPanel / AppMenuBar 菜单 / Ctrl+K 命令面板 - 语义:可用性开关非权限边界,文件缺失/损坏默认全开 - 验证:tests/golden/test_feature_flags.py 6 passed - 文档:harness.md 端点备案 + CHANGELOG
This commit is contained in:
parent
fee1a4c13d
commit
8b7063f1f8
|
|
@ -4,7 +4,8 @@
|
|||
// ============================================================
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { DueRow, FlexCapacityData, FlexGanttData, GanttData, LoadData, ViewportCommand, ViewportMode, WorldSummary } from './api/types';
|
||||
import { createWorkspaceFile, fetchDue, fetchFlexCapacity, fetchFlexGantt, fetchGantt, fetchLoad, fetchSummary, fetchTimeline } from './api/client';
|
||||
import { createWorkspaceFile, fetchDue, fetchFeatures, fetchFlexCapacity, fetchFlexGantt, fetchGantt, fetchLoad, fetchSummary, fetchTimeline } from './api/client';
|
||||
import { featureOn, toEnabledMap, type FeatureEnabledMap } from './features/flags';
|
||||
import ChatPanel, { type ChatHandle } from './chat/ChatPanel';
|
||||
import TimelineRail from './timeline/TimelineRail';
|
||||
import type { InspectTarget } from './viewport/WorldDiffView';
|
||||
|
|
@ -115,6 +116,19 @@ export default function App() {
|
|||
const [masterRefreshKey, setMasterRefreshKey] = useState(0);
|
||||
const [projData, setProjData] = useState<ProjectStoreData>(() => loadStore());
|
||||
const [workspaceReady, setWorkspaceReady] = useState(false);
|
||||
// 功能开关(文件化配置):null = 未加载,fail-open 全部视为开启
|
||||
const [featureMap, setFeatureMap] = useState<FeatureEnabledMap | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void fetchFeatures()
|
||||
.then(resp => { if (!cancelled) setFeatureMap(toEnabledMap(resp)); })
|
||||
.catch(() => { /* 拉取失败 fail-open:全部功能保持可见 */ });
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
const featOn = useCallback(
|
||||
(key: string) => featureOn(featureMap, key),
|
||||
[featureMap],
|
||||
);
|
||||
// 治理控制台(Codex 设置页式覆盖层)与命令面板
|
||||
const [govOpen, setGovOpen] = useState(false);
|
||||
const [govSection, setGovSection] = useState<GovSection>('gate.pending');
|
||||
|
|
@ -238,7 +252,7 @@ export default function App() {
|
|||
const hasVisualResult = !!activeResult?.world.summary?.hasVersion
|
||||
|| !!activeResult?.world.flexGantt?.workOrders?.length
|
||||
|| !!(activeResult?.world.flexCapacity?.pools?.length);
|
||||
const rightVisible = rightOpen && !!activeSession && (
|
||||
const rightVisible = rightOpen && !!activeSession && featOn(rightTool) && (
|
||||
rightTool === 'orders' || rightTool === 'master' || rightTool === 'flex' || rightTool === 'timeline' || rightTool === 'mesh'
|
||||
|| rightTool === 'drawing'
|
||||
|| (rightTool === 'visual' && hasVisualResult)
|
||||
|
|
@ -436,35 +450,41 @@ export default function App() {
|
|||
}, []);
|
||||
|
||||
// 命令面板条目:口令(发对话)/ 面板(布局开关)/ 视图(视口切换)
|
||||
const paletteItems: PaletteItem[] = [
|
||||
...[
|
||||
'多策略方案对比', '试排一版交期优先', '产能均衡重排', '建一个检查点',
|
||||
'回滚到上一个检查点', '查看冲突', '标出超期订单', '查看当前KPI', '发布当前排产版本',
|
||||
].map(q => ({
|
||||
id: `cmd-${q}`, kind: '口令' as const, label: q, hint: '发送到对话',
|
||||
run: () => { chatRef.current?.send(q); },
|
||||
})),
|
||||
{ id: 'panel-orders', kind: '面板', label: '订单管理', hint: rightTool === 'orders' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('orders') },
|
||||
{ id: 'panel-master', kind: '面板', label: '主数据', hint: rightTool === 'master' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('master') },
|
||||
{ id: 'panel-flex', kind: '面板', label: '柔性工作台', hint: rightTool === 'flex' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('flex') },
|
||||
{ id: 'panel-timeline', kind: '面板', label: '时间线', hint: rightTool === 'timeline' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('timeline') },
|
||||
{ id: 'panel-drawing', kind: '面板', label: '图纸识别', hint: rightTool === 'drawing' && rightOpen ? '当前已展开' : 'DXF → 主数据候选', run: () => openRightTool('drawing') },
|
||||
{ id: 'panel-settings', kind: '面板', label: '设置', hint: '偏好 / 模型与集成 / 治理', run: () => openGov('settings.personal') },
|
||||
{ id: 'panel-gate', kind: '面板', label: '设置 · 门禁待审批', hint: '审批 / 审计链 / 权力矩阵', run: () => openGov('gate.pending') },
|
||||
{ id: 'panel-skills', kind: '面板', label: '设置 · 算法 Skill', hint: 'Skill 管理台(启停/登记/测试/日志)', run: () => openGov('settings.skills') },
|
||||
{ id: 'panel-knowledge', kind: '面板', label: '设置 · 知识平台', hint: '知识资产 / 检索调试 / 工艺模板库 / 索引', run: () => openGov('knowledge.assets') },
|
||||
{ id: 'panel-regen', kind: '面板', label: '设置 · 重生中心', hint: '模块注册表 / 黄金测试', run: () => openGov('regen.modules') },
|
||||
{ id: 'view-gantt', kind: '视图', label: '甘特图', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'gantt' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-load', kind: '视图', label: '负荷热力', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'load' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-util', kind: '视图', label: '资源利用率', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'util' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-plan', kind: '视图', label: '分桶计划', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'plan' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-due', kind: '视图', label: '交期承诺看板', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'due' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-kpi', kind: '视图', label: 'KPI 仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'kpi' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-compare', kind: '视图', label: '方案对比表', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'compare' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-diff', kind: '视图', label: '两个世界对比', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'diff' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-pool', kind: '视图', label: '产能池仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'pool' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-flex', kind: '视图', label: '柔性甘特', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'flex' }, issuedBy: 'USER' }) },
|
||||
];
|
||||
// 面板/视图条目按功能开关过滤(featOn fail-open:未加载时全部可见)
|
||||
const paletteItems: PaletteItem[] = useMemo(() => {
|
||||
const entries: Array<PaletteItem & { feature?: string }> = [
|
||||
...[
|
||||
'多策略方案对比', '试排一版交期优先', '产能均衡重排', '建一个检查点',
|
||||
'回滚到上一个检查点', '查看冲突', '标出超期订单', '查看当前KPI', '发布当前排产版本',
|
||||
].map(q => ({
|
||||
id: `cmd-${q}`, kind: '口令' as const, label: q, hint: '发送到对话',
|
||||
run: () => { chatRef.current?.send(q); },
|
||||
})),
|
||||
{ id: 'panel-orders', kind: '面板', feature: 'orders', label: '订单管理', hint: rightTool === 'orders' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('orders') },
|
||||
{ id: 'panel-master', kind: '面板', feature: 'master', label: '主数据', hint: rightTool === 'master' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('master') },
|
||||
{ id: 'panel-flex', kind: '面板', feature: 'flex', label: '柔性工作台', hint: rightTool === 'flex' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('flex') },
|
||||
{ id: 'panel-timeline', kind: '面板', feature: 'timeline', label: '时间线', hint: rightTool === 'timeline' && rightOpen ? '当前已展开' : '右侧展开', run: () => openRightTool('timeline') },
|
||||
{ id: 'panel-drawing', kind: '面板', feature: 'drawing', label: '图纸识别', hint: rightTool === 'drawing' && rightOpen ? '当前已展开' : 'DXF → 主数据候选', run: () => openRightTool('drawing') },
|
||||
{ id: 'panel-settings', kind: '面板', feature: 'settings', label: '设置', hint: '偏好 / 模型与集成 / 治理', run: () => openGov('settings.personal') },
|
||||
{ id: 'panel-gate', kind: '面板', feature: 'gov', label: '设置 · 门禁待审批', hint: '审批 / 审计链 / 权力矩阵', run: () => openGov('gate.pending') },
|
||||
{ id: 'panel-skills', kind: '面板', feature: 'skills', label: '设置 · 算法 Skill', hint: 'Skill 管理台(启停/登记/测试/日志)', run: () => openGov('settings.skills') },
|
||||
{ id: 'panel-knowledge', kind: '面板', feature: 'knowledge', label: '设置 · 知识平台', hint: '知识资产 / 检索调试 / 工艺模板库 / 索引', run: () => openGov('knowledge.assets') },
|
||||
{ id: 'panel-regen', kind: '面板', feature: 'regen', label: '设置 · 重生中心', hint: '模块注册表 / 黄金测试', run: () => openGov('regen.modules') },
|
||||
{ id: 'view-gantt', kind: '视图', feature: 'visual', label: '甘特图', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'gantt' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-load', kind: '视图', feature: 'visual', label: '负荷热力', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'load' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-util', kind: '视图', feature: 'visual', label: '资源利用率', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'util' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-plan', kind: '视图', feature: 'visual', label: '分桶计划', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'plan' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-due', kind: '视图', feature: 'visual', label: '交期承诺看板', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'due' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-kpi', kind: '视图', feature: 'visual', label: 'KPI 仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'kpi' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-compare', kind: '视图', feature: 'visual', label: '方案对比表', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'compare' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-diff', kind: '视图', feature: 'visual', label: '两个世界对比', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'diff' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-pool', kind: '视图', feature: 'visual', label: '产能池仪表盘', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'pool' }, issuedBy: 'USER' }) },
|
||||
{ id: 'view-flex', kind: '视图', feature: 'flex', label: '柔性甘特', run: () => executeCommand({ cmd: 'viewport.mode', params: { mode: 'flex' }, issuedBy: 'USER' }) },
|
||||
];
|
||||
return entries
|
||||
.filter(item => !item.feature || featOn(item.feature))
|
||||
.map(({ feature: _feature, ...item }) => item);
|
||||
}, [featOn, rightTool, rightOpen, openRightTool, openGov, executeCommand]);
|
||||
|
||||
const onAppMenuAction = useCallback((action: AppMenuAction) => {
|
||||
switch (action) {
|
||||
|
|
@ -524,6 +544,7 @@ export default function App() {
|
|||
<div className={`app-frame${mobileLayout ? ' mobile-layout' : ''}`}>
|
||||
<AppMenuBar
|
||||
sidebarCollapsed={mobileLayout ? !mobileSidebarOpen : sidebarCollapsed}
|
||||
features={featureMap}
|
||||
onToggleSidebar={() => {
|
||||
if (mobileLayout) setMobileSidebarOpen(v => !v);
|
||||
else setSidebarCollapsed(v => !v);
|
||||
|
|
@ -538,6 +559,7 @@ export default function App() {
|
|||
>
|
||||
<ProjectPanel
|
||||
data={projData}
|
||||
features={featureMap}
|
||||
onChange={(next) => {
|
||||
if (mobileLayout && (
|
||||
next.activeProjectId !== projData.activeProjectId
|
||||
|
|
@ -676,6 +698,7 @@ export default function App() {
|
|||
</div>
|
||||
)}
|
||||
<aside className="right-rail" aria-label="右侧工具">
|
||||
{featOn('visual') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'visual' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -688,6 +711,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M3 3v18h18"/><path d="M7 15l4-4 3 3 5-7"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('orders') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'orders' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -696,6 +721,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M7 3h10l3 3v15H4V3z"/><path d="M14 3v5h5"/><path d="M8 13h8M8 17h6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('master') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'master' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -704,6 +731,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><ellipse cx="12" cy="5" rx="8" ry="3"/><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5"/><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('flex') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'flex' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -712,6 +741,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><circle cx="8" cy="8" r="3"/><circle cx="16" cy="16" r="3"/><path d="M10.5 9.5l3 5"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('timeline') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'timeline' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -720,6 +751,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round"><path d="M12 2v20M6 6h8M6 12h12M6 18h6"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('mesh') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'mesh' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -728,6 +761,8 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="5" r="2.4"/><circle cx="5" cy="18" r="2.4"/><circle cx="19" cy="18" r="2.4"/><path d="M12 7.5 6 16M12 7.5 18 16M7.4 18h9.2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
{featOn('drawing') && (
|
||||
<button
|
||||
type="button"
|
||||
className={rightTool === 'drawing' && rightVisible ? 'active' : ''}
|
||||
|
|
@ -736,6 +771,7 @@ export default function App() {
|
|||
>
|
||||
<svg width="19" height="19" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M4 20h16M6 16l4-10 4 6 2-3 2 7"/><circle cx="6" cy="16" r="1.2"/><circle cx="20" cy="16" r="1.2"/></svg>
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -73,6 +73,8 @@ async function getJson<T>(url: string): Promise<T> {
|
|||
|
||||
// KPI 摘要
|
||||
export const fetchSummary = () => getJson<WorldSummary>('/api/world/summary');
|
||||
// 功能开关(P0 只读):文件化配置哪些功能上线可见、哪些隐藏不用
|
||||
export const fetchFeatures = () => getJson<import('../features/flags').FeatureFlagsResponse>('/api/features');
|
||||
// 甘特视图数据
|
||||
export const fetchGantt = () => getJson<GanttData>('/api/world/gantt');
|
||||
// 负荷热力数据
|
||||
|
|
|
|||
|
|
@ -0,0 +1,32 @@
|
|||
// ============================================================
|
||||
// 功能开关(Feature Flags)前端类型与判定(moduleId: web-feature-flags, 可重生 ✅)
|
||||
// 数据源:GET /api/features(文件化配置,server/data/features.json)。
|
||||
// 语义:可用性/可见性开关,不是权限边界;拉取失败或未加载时 fail-open
|
||||
// (全部视为开启),绝不因配置问题把界面锁死。
|
||||
// ============================================================
|
||||
|
||||
export interface FeatureFlagsResponse {
|
||||
version: number;
|
||||
source: 'default' | 'file';
|
||||
path: string;
|
||||
error: string | null;
|
||||
unknown: string[];
|
||||
features: Record<string, { enabled: boolean; label: string }>;
|
||||
}
|
||||
|
||||
/** key → enabled 的扁平映射(null 表示尚未加载,一律视为开启) */
|
||||
export type FeatureEnabledMap = Record<string, boolean>;
|
||||
|
||||
export function toEnabledMap(resp: FeatureFlagsResponse): FeatureEnabledMap {
|
||||
const map: FeatureEnabledMap = {};
|
||||
for (const [key, info] of Object.entries(resp.features ?? {})) {
|
||||
map[key] = info?.enabled !== false;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/** fail-open 判定:未加载 / 键缺失 → 视为开启;仅显式 false 才隐藏 */
|
||||
export function featureOn(map: FeatureEnabledMap | null, key: string): boolean {
|
||||
if (map == null) return true;
|
||||
return map[key] !== false;
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
// ============================================================
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ChatSession, Project, ProjectStoreData } from './types';
|
||||
import { featureOn, type FeatureEnabledMap } from '../features/flags';
|
||||
import {
|
||||
addFile, addProject, addSession, removeFile, removeProject, removeSession,
|
||||
PERSONAL_PROJECT_ID, renameProject, renameSession, selectProject, selectSession,
|
||||
|
|
@ -160,6 +161,7 @@ function NewTaskMenu(props: {
|
|||
|
||||
export default function ProjectPanel(props: {
|
||||
data: ProjectStoreData;
|
||||
features?: FeatureEnabledMap | null;
|
||||
onChange: (next: ProjectStoreData) => void;
|
||||
onOpenOrders: () => void;
|
||||
onOpenMaster: () => void;
|
||||
|
|
@ -305,42 +307,54 @@ export default function ProjectPanel(props: {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{featureOn(props.features ?? null, 'timeline') && (
|
||||
<button type="button" className="sidebar-action" title="已排程" onClick={props.onOpenTimeline}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<path d="M12 2v20M6 6h8M6 12h12M6 18h6" />
|
||||
</svg>
|
||||
<span>已排程</span>
|
||||
</button>
|
||||
)}
|
||||
{featureOn(props.features ?? null, 'orders') && (
|
||||
<button type="button" className="sidebar-action" title="订单管理" onClick={props.onOpenOrders}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M7 3h10l3 3v15H4V3z" /><path d="M14 3v5h5" /><path d="M8 13h8M8 17h6" />
|
||||
</svg>
|
||||
<span>订单管理</span>
|
||||
</button>
|
||||
)}
|
||||
{featureOn(props.features ?? null, 'master') && (
|
||||
<button type="button" className="sidebar-action" title="主数据" onClick={props.onOpenMaster}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<ellipse cx="12" cy="5" rx="8" ry="3" /><path d="M4 5v14c0 1.7 3.6 3 8 3s8-1.3 8-3V5" /><path d="M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3" />
|
||||
</svg>
|
||||
<span>主数据</span>
|
||||
</button>
|
||||
)}
|
||||
{featureOn(props.features ?? null, 'flex') && (
|
||||
<button type="button" className="sidebar-action" title="柔性工作台" onClick={props.onOpenFlex}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||
<circle cx="8" cy="8" r="3" /><circle cx="16" cy="16" r="3" /><path d="M10.5 9.5l3 5" />
|
||||
</svg>
|
||||
<span>柔性工作台</span>
|
||||
</button>
|
||||
)}
|
||||
{featureOn(props.features ?? null, 'knowledge') && (
|
||||
<button type="button" className="sidebar-action" title="知识与规则" onClick={props.onOpenKnowledge}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 19.5V5a2 2 0 0 1 2-2h12v16H6a2 2 0 0 0-2 2" /><path d="M8 7h6M8 11h7" />
|
||||
</svg>
|
||||
<span>知识与规则</span>
|
||||
</button>
|
||||
)}
|
||||
{featureOn(props.features ?? null, 'gov') && (
|
||||
<button type="button" className="sidebar-action" title="审批治理" onClick={props.onOpenGate}>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M12 3 4 6v6c0 5 3.4 8.3 8 9 4.6-.7 8-4 8-9V6l-8-3Z" /><path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span>审批治理</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ---- 项目(内嵌任务) ---- */}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
// 桌面端:自绘标题栏(可拖拽)+ 最小化 / 最大化·还原 / 关闭(必须可见)
|
||||
// ============================================================
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { featureOn, type FeatureEnabledMap } from '../features/flags';
|
||||
|
||||
export type AppMenuAction =
|
||||
| 'new-chat'
|
||||
|
|
@ -88,8 +89,40 @@ function detectDesktopShell() {
|
|||
);
|
||||
}
|
||||
|
||||
/** 菜单动作 → 功能开关键(fail-open:未登记的动作不受开关控制) */
|
||||
const ACTION_FEATURE: Partial<Record<AppMenuAction, string>> = {
|
||||
'open-orders': 'orders',
|
||||
'open-master': 'master',
|
||||
'open-flex': 'flex',
|
||||
'open-timeline': 'timeline',
|
||||
'open-knowledge': 'knowledge',
|
||||
'open-gate': 'gov',
|
||||
'open-settings': 'settings',
|
||||
};
|
||||
|
||||
/** 按功能开关过滤菜单:隐藏禁用项、清理多余分隔线、丢弃空菜单组 */
|
||||
function filterMenus(menus: MenuGroup[], features: FeatureEnabledMap | null | undefined): MenuGroup[] {
|
||||
const groups: MenuGroup[] = [];
|
||||
for (const group of menus) {
|
||||
const kept: MenuItem[] = [];
|
||||
for (const item of group.items) {
|
||||
if (item.kind === 'item') {
|
||||
const key = ACTION_FEATURE[item.action];
|
||||
if (key && !featureOn(features ?? null, key)) continue;
|
||||
kept.push(item);
|
||||
} else if (kept.length > 0 && kept[kept.length - 1].kind !== 'sep') {
|
||||
kept.push(item); // 分隔线仅在两项之间保留
|
||||
}
|
||||
}
|
||||
while (kept.length > 0 && kept[kept.length - 1].kind === 'sep') kept.pop();
|
||||
if (kept.length > 0) groups.push({ ...group, items: kept });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export default function AppMenuBar(props: {
|
||||
sidebarCollapsed: boolean;
|
||||
features?: FeatureEnabledMap | null;
|
||||
onToggleSidebar: () => void;
|
||||
onAction: (action: AppMenuAction) => void;
|
||||
}) {
|
||||
|
|
@ -155,7 +188,7 @@ export default function AppMenuBar(props: {
|
|||
</button>
|
||||
|
||||
<nav className="app-menu-nav" aria-label="主菜单">
|
||||
{MENUS.map((group) => {
|
||||
{filterMenus(MENUS, props.features).map((group) => {
|
||||
const open = openId === group.id;
|
||||
return (
|
||||
<div key={group.id} className={`app-menu-group${open ? ' open' : ''}`}>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,19 @@
|
|||
## 2026-08-26 — FF-01 功能开关(Feature Flags)文件化配置
|
||||
|
||||
- **类型**:feature + test + docs
|
||||
- **做了什么**:用一个 JSON 配置文件控制哪些功能上线可见、哪些隐藏不用。
|
||||
- 后端 `server/agent_core/feature_flags.py`(moduleId: feature-flags):12 键功能目录(visual/orders/master/flex/timeline/drawing/mesh/knowledge/skills/gov/regen/settings,中文标签);配置文件默认 `server/data/features.json`、`APS_FEATURES_PATH` 可覆盖;格式 `{"version": 1, "features": {"drawing": false, ...}}`。
|
||||
- 语义:可用性/可见性开关,**不是权限边界**(P2/P3 权力路径仍由 Harness 独立保证);**fail-open 诚实回退**——文件缺失/损坏/version 非法 → 全部默认开启并显式携带 `source/error`,未知键列入 `unknown`,非 bool 值该键回退默认开启并记录错误,绝不因配置问题锁死界面。
|
||||
- 网关:`GET /api/features`(P0 只读,登录后可读,每次请求实时读文件——改文件刷新浏览器即生效,无需重启)。
|
||||
- 前端 `apps/web/src/features/flags.ts` + 接线:右侧导轨 7 个工具按钮、左侧栏 6 个快捷入口(ProjectPanel)、顶部中文菜单(AppMenuBar,按动作映射过滤+清理分隔线/空菜单组)、Ctrl+K 命令面板(面板/视图条目按开关过滤)四处入口全部按开关隐藏;未加载/拉取失败 fail-open 全部可见;当前展开的工具被禁用时面板自动隐藏。
|
||||
- **验证**:`tests/golden/test_feature_flags.py` **6 passed**(默认全开/配置生效/损坏 fail-open+error/非 bool 回退/env 覆盖/端点契约);端点与文档漂移回归(health/openapi/doc-drift/contract-sync/rebuild)**32 passed**;全量黄金三批 **1071 passed / 1 failed**(`test_preference_features` 为既有失败,与本轮无关);`npm run build`(tsc + vite)passed;ruff 本轮新增文件全绿(app.py 既有基线未扩散)。
|
||||
- **影响分析**:GitNexus MCP 不可用,人工影响分析——feature_flags 新模块零既有调用方;app.py 纯增量端点;前端四处入口为展示层条件渲染,无契约/类型变更(types.ts 未动)。爆炸半径 LOW。
|
||||
- **文档同步**:docs/architecture/harness.md 只读端点备案、本节。
|
||||
- **边界**:开关只隐藏入口,不拦截后端业务端点(如需端点级下线需另行设计,且不得替代 Harness 门禁);桌面端与 Web 端共用同一配置;`server/data/features.json` 为现场数据不入库。
|
||||
- **发布动作**:无 commit/push/merge/publish。
|
||||
|
||||
---
|
||||
|
||||
## 2026-08-20 — Round 67 审批迁移 Fail-Closed 与 Grant Digest 归一化(R67-B1)
|
||||
|
||||
- **类型**:fix + safety + test + docs
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@
|
|||
| `POST /api/multimodal/ingest` | 多模态摄取(低置信出 P2 确认卡,确认前不写世界;dxf_drawing 候选 fail closed 指引 /api/dxf/stage) | 矩阵 101 |
|
||||
| `POST /api/dxf/parse` · `/parse-path` | 工程图纸 DXF 解析(P0 只读:标题栏/位置号/技术说明 + 物料/BOM/工艺路线候选,不写世界) | DXF-01 |
|
||||
| `GET /api/dxf/{drawingId}/svg` · `GET /api/dxf/discover` | 图纸 SVG 预览(解析缓存复用)/ 工程目录 DXF 发现(P0 只读) | DXF-01 |
|
||||
| `GET /api/features` | 功能开关(P0 只读):文件化配置功能上线/隐藏;可用性开关非权限边界,缺失/损坏 fail-open 默认全开并显式 error | FF-01 |
|
||||
| `GET /api/gov/archive/verify` | WORM 归档完整性验证(P0 只读:sha256/merkle 篡改检出) | 矩阵 104/116/112 |
|
||||
| `POST /api/gov/archive/run` | 触发归档密封(P1:archive_old + worm manifest) | 矩阵 104/116/112 |
|
||||
| `POST /api/saga/{id}/retry` | Saga 重试(P1,先按快照回滚步副作用) | 矩阵 78/119 |
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
# ============================================================
|
||||
# 功能开关(Feature Flags)文件化配置(moduleId: feature-flags, 可重生 ✅)
|
||||
# 用一个 JSON 文件控制哪些功能上线可见、哪些功能隐藏不用。
|
||||
#
|
||||
# 语义(务必牢记):
|
||||
# - 这是「可用性/可见性」开关,不是权限边界;隐藏不等于鉴权,
|
||||
# P2/P3 权力路径仍由 Harness 门禁独立保证。
|
||||
# - fail-open 诚实回退:文件缺失/损坏/版本不符 → 全部功能默认开启,
|
||||
# 并在响应里显式携带 source/error,绝不静默吞掉配置错误,
|
||||
# 也绝不因配置损坏把系统锁死。
|
||||
# - 未知键忽略但如实列入 unknown;非 bool 值该键回退默认开启并记录错误。
|
||||
#
|
||||
# 配置文件(默认 server/data/features.json,APS_FEATURES_PATH 可覆盖):
|
||||
# {"version": 1, "features": {"orders": true, "drawing": false, ...}}
|
||||
# ============================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
_SCHEMA_VERSION = 1
|
||||
_PATH_ENV = "APS_FEATURES_PATH"
|
||||
|
||||
# 功能目录:key → 中文标签(顺序即展示顺序;新增功能必须在此登记)
|
||||
FEATURE_CATALOG: dict[str, str] = {
|
||||
"visual": "会话结果视口(甘特/负荷/交期)",
|
||||
"orders": "订单管理",
|
||||
"master": "主数据",
|
||||
"flex": "柔性工作台",
|
||||
"timeline": "排产结构(时间线)",
|
||||
"drawing": "图纸识别(DXF)",
|
||||
"mesh": "多智能体编排",
|
||||
"knowledge": "知识平台",
|
||||
"skills": "算法 Skill 管理",
|
||||
"gov": "审批治理",
|
||||
"regen": "重生中心",
|
||||
"settings": "设置",
|
||||
}
|
||||
|
||||
|
||||
def default_features_path() -> str:
|
||||
configured = os.environ.get(_PATH_ENV)
|
||||
if configured:
|
||||
return configured
|
||||
from server.aps_home import path_under_data
|
||||
|
||||
return str(path_under_data("features.json"))
|
||||
|
||||
|
||||
def _all_enabled() -> dict[str, bool]:
|
||||
return {key: True for key in FEATURE_CATALOG}
|
||||
|
||||
|
||||
def load_feature_flags(file_path: str | None = None) -> dict[str, Any]:
|
||||
"""读取并解析功能开关配置。
|
||||
|
||||
Returns: {"version": 1, "source": "default"|"file", "path": str,
|
||||
"error": str | None, "unknown": [str],
|
||||
"features": {key: {"enabled": bool, "label": str}}}
|
||||
"""
|
||||
path = file_path or default_features_path()
|
||||
enabled = _all_enabled()
|
||||
source = "default"
|
||||
error: str | None = None
|
||||
unknown: list[str] = []
|
||||
|
||||
if not os.path.exists(path):
|
||||
pass # 文件缺失:默认全开
|
||||
else:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as handle:
|
||||
raw = json.load(handle)
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc:
|
||||
error = f"功能开关配置无法解析({type(exc).__name__}),已回退全部默认开启"
|
||||
else:
|
||||
if not isinstance(raw, dict) or raw.get("version") != _SCHEMA_VERSION:
|
||||
error = "功能开关配置顶层结构或 version 非法,已回退全部默认开启"
|
||||
elif not isinstance(raw.get("features"), dict):
|
||||
error = "功能开关配置 features 段不是对象,已回退全部默认开启"
|
||||
else:
|
||||
source = "file"
|
||||
value_errors: list[str] = []
|
||||
for key, value in raw["features"].items():
|
||||
if key not in FEATURE_CATALOG:
|
||||
unknown.append(str(key))
|
||||
continue
|
||||
if not isinstance(value, bool):
|
||||
value_errors.append(key) # 非 bool:该键回退默认开启
|
||||
continue
|
||||
enabled[key] = value
|
||||
if value_errors:
|
||||
error = (
|
||||
"功能开关配置存在非 bool 取值("
|
||||
+ ",".join(sorted(value_errors))
|
||||
+ "),对应功能已回退默认开启"
|
||||
)
|
||||
|
||||
return {
|
||||
"version": _SCHEMA_VERSION,
|
||||
"source": source,
|
||||
"path": str(path),
|
||||
"error": error,
|
||||
"unknown": unknown,
|
||||
"features": {
|
||||
key: {"enabled": enabled[key], "label": label}
|
||||
for key, label in FEATURE_CATALOG.items()
|
||||
},
|
||||
}
|
||||
|
|
@ -822,6 +822,16 @@ def create_app() -> FastAPI:
|
|||
"storage": storage,
|
||||
}
|
||||
|
||||
@app.get("/api/features")
|
||||
async def feature_flags() -> dict:
|
||||
"""功能开关(P0 只读):文件化配置哪些功能上线可见、哪些隐藏不用。
|
||||
|
||||
可用性开关而非权限边界;文件缺失/损坏 fail-open 默认全开并显式携带 error。
|
||||
"""
|
||||
from server.agent_core.feature_flags import load_feature_flags
|
||||
|
||||
return load_feature_flags()
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
async def auth_login(req: AuthLoginRequest, request: Request, response: Response):
|
||||
if is_desktop_request(request):
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
# ============================================================
|
||||
# 功能开关(Feature Flags)黄金测试
|
||||
# 覆盖:默认全开、文件配置生效、损坏/非法回退默认 + 显式 error、
|
||||
# 未知键如实上报、非 bool 值该键回退、env 路径覆盖、/api/features 端点契约。
|
||||
# ============================================================
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from server.agent_core.feature_flags import FEATURE_CATALOG, load_feature_flags
|
||||
from tests.auth_provider import install_test_auth
|
||||
|
||||
|
||||
def _write(path: Path, data: object) -> str:
|
||||
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
||||
return str(path)
|
||||
|
||||
|
||||
def test_missing_file_defaults_all_enabled(tmp_path: Path):
|
||||
"""文件缺失 → 全部默认开启,source=default,无 error。"""
|
||||
result = load_feature_flags(str(tmp_path / "features.json"))
|
||||
assert result["source"] == "default"
|
||||
assert result["error"] is None
|
||||
assert result["unknown"] == []
|
||||
assert all(info["enabled"] for info in result["features"].values())
|
||||
assert set(result["features"]) == set(FEATURE_CATALOG)
|
||||
|
||||
|
||||
def test_file_config_disables_features(tmp_path: Path):
|
||||
"""显式 false 隐藏功能;未列出的键保持默认开启;未知键如实上报。"""
|
||||
path = _write(tmp_path / "features.json", {
|
||||
"version": 1,
|
||||
"features": {"drawing": False, "mesh": False, "unknown-thing": True},
|
||||
})
|
||||
result = load_feature_flags(path)
|
||||
assert result["source"] == "file"
|
||||
assert result["error"] is None
|
||||
assert result["features"]["drawing"]["enabled"] is False
|
||||
assert result["features"]["mesh"]["enabled"] is False
|
||||
assert result["features"]["orders"]["enabled"] is True
|
||||
assert result["unknown"] == ["unknown-thing"]
|
||||
|
||||
|
||||
def test_corrupt_json_fails_open_with_error(tmp_path: Path):
|
||||
"""损坏 JSON → fail-open 全部开启 + 显式 error(不锁死界面,不静默吞错)。"""
|
||||
path = tmp_path / "features.json"
|
||||
path.write_text("{not-json", encoding="utf-8")
|
||||
result = load_feature_flags(str(path))
|
||||
assert result["source"] == "default"
|
||||
assert result["error"] is not None
|
||||
assert all(info["enabled"] for info in result["features"].values())
|
||||
|
||||
|
||||
def test_invalid_shape_and_non_bool_values(tmp_path: Path):
|
||||
"""顶层结构非法 / features 非对象 / 值非 bool → 相应回退默认并显式报告。"""
|
||||
bad_version = load_feature_flags(_write(tmp_path / "v.json", {"version": 2, "features": {}}))
|
||||
assert bad_version["source"] == "default" and bad_version["error"] is not None
|
||||
|
||||
bad_section = load_feature_flags(_write(tmp_path / "s.json", {"version": 1, "features": [1]}))
|
||||
assert bad_section["source"] == "default" and bad_section["error"] is not None
|
||||
|
||||
non_bool = load_feature_flags(_write(tmp_path / "b.json", {
|
||||
"version": 1, "features": {"orders": "no", "drawing": False},
|
||||
}))
|
||||
assert non_bool["source"] == "file"
|
||||
assert non_bool["features"]["orders"]["enabled"] is True # 非 bool 回退默认开启
|
||||
assert non_bool["features"]["drawing"]["enabled"] is False # 合法 false 仍生效
|
||||
assert non_bool["error"] is not None and "orders" in non_bool["error"]
|
||||
|
||||
|
||||
def test_env_path_override(tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""APS_FEATURES_PATH 覆盖默认路径。"""
|
||||
path = _write(tmp_path / "custom.json", {"version": 1, "features": {"gov": False}})
|
||||
monkeypatch.setenv("APS_FEATURES_PATH", path)
|
||||
result = load_feature_flags()
|
||||
assert result["source"] == "file"
|
||||
assert result["path"] == path
|
||||
assert result["features"]["gov"]["enabled"] is False
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def features_app(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("APS_DB_PATH", str(tmp_path / "features.db"))
|
||||
monkeypatch.setenv("APS_WORLD_PATH", str(tmp_path / "world.json"))
|
||||
from server.db.database import reset_engine
|
||||
from server.state import store as world_store
|
||||
|
||||
install_test_auth(monkeypatch, "tenant-a-000000000000000000000000001")
|
||||
world_store._stores.clear()
|
||||
reset_engine()
|
||||
from server.gateway.app import create_app
|
||||
|
||||
app = create_app()
|
||||
yield app, monkeypatch, tmp_path
|
||||
reset_engine()
|
||||
world_store._stores.clear()
|
||||
|
||||
|
||||
def test_features_endpoint_contract(features_app):
|
||||
"""/api/features 端点:默认全开契约 + 配置文件生效 + 中文标签齐全。"""
|
||||
app, monkeypatch, tmp_path = features_app
|
||||
client = TestClient(app)
|
||||
assert client.post("/api/auth/login", json={"username": "planner"}).status_code == 200
|
||||
|
||||
body = client.get("/api/features").json()
|
||||
assert body["version"] == 1
|
||||
assert body["source"] == "default"
|
||||
assert all(info["enabled"] for info in body["features"].values())
|
||||
assert all(info["label"] for info in body["features"].values())
|
||||
|
||||
config = _write(tmp_path / "features.json", {
|
||||
"version": 1, "features": {"drawing": False, "regen": False},
|
||||
})
|
||||
monkeypatch.setenv("APS_FEATURES_PATH", config)
|
||||
body = client.get("/api/features").json()
|
||||
assert body["source"] == "file"
|
||||
assert body["features"]["drawing"]["enabled"] is False
|
||||
assert body["features"]["regen"]["enabled"] is False
|
||||
assert body["features"]["orders"]["enabled"] is True
|
||||
Loading…
Reference in New Issue