diff --git a/CHANGELOG.md b/CHANGELOG.md
index c68a05f..be222f4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -54,3 +54,30 @@
- 新增仓库级 `CHANGELOG.md`,让 Gitea 仓库能直接看到阶段性更新记录。
- 最小回归 workflow 同时落在 `.github/workflows/ci.yml` 和 `.gitea/workflows/ci.yml`,GitHub Actions 与 Gitea Actions 都能跑相同的基线、后端单测和 Web 测试。
+
+## 2026-03-31
+
+### 主 Agent 配置业务流收口
+
+- 管理员配置台里的系统主 Agent、系统平台策略、管理员覆盖这条配置流补上了前端本地权限兜底,非超级管理员不会再直接撞到后端 403。
+- 管理员覆盖目标为空时,前端会明确提示“当前治理目录里还没有可选用户”,不再放出无效保存和回滚动作。
+- 管理员侧三类历史回滚弹层都改成了只读空态:没有历史版本时会隐藏提交按钮,也不会再让空 `version_id` 发起无效回滚请求。
+
+### 配置流回归护栏
+
+- Web 工作台测试新增了主 Agent 配置流空态保护和权限保护覆盖,重点锁住:
+ - 管理员历史回滚空态
+ - 管理员治理动作本地权限 guard
+ - 管理员覆盖目标为空时的编辑/历史保护
+- 当前基线重新验证通过:
+ - 前端测试 `66/66`
+ - 后端单测 `35/35`
+ - `bash scripts/check_repo_baseline.sh`
+ - `bash scripts/smoke_fnos_storyforge_lan.sh`
+
+### NAS 联调发布
+
+- 最新 Web 已重新发布到 fnOS NAS:
+ - Web: `http://192.168.31.188:19192/`
+ - Collector: `http://192.168.31.188:19193/healthz`
+- 主 Agent 配置业务流的这轮修复已经同步到 Gitea,后续可以直接基于当前分支继续收剩余真实能力细节。
diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js
index 9f1be6d..214174b 100644
--- a/web/storyforge-web-v4/assets/app.js
+++ b/web/storyforge-web-v4/assets/app.js
@@ -8645,6 +8645,21 @@ function buildPolicyVersionOptions(history) {
}));
}
+function ensureAdminGovernanceAccess() {
+ if (isSuperAdmin()) return true;
+ rememberAction("需要管理员权限", "当前动作仅超级管理员可用,请切换到管理员账号后再继续。", "orange");
+ renderAll();
+ return false;
+}
+
+function ensureAdminOverrideTargetReady(target) {
+ if (!ensureAdminGovernanceAccess()) return false;
+ if (target?.targetUserId) return true;
+ rememberAction("还没有可治理目标", "当前治理目录里还没有可选用户,暂时无法执行管理员覆盖动作。", "orange");
+ renderAll();
+ return false;
+}
+
function openUserGlobalPolicyAction() {
const project = requireSelectedProject();
const bundle = appState.userGlobalPolicy || {};
@@ -8786,6 +8801,7 @@ async function openUserPlatformPolicyHistoryAction(platform) {
}
function openSystemMainPolicyAction() {
+ if (!ensureAdminGovernanceAccess()) return;
const projectId = getOneLinerProjectId();
const bundle = appState.adminSystemMainPolicy || {};
const current = bundle.current_version || {};
@@ -8819,6 +8835,7 @@ function openSystemMainPolicyAction() {
}
function openSystemPlatformPolicyAction(platform) {
+ if (!ensureAdminGovernanceAccess()) return;
const normalizedPlatform = normalizePlatformValue(platform, "douyin");
const projectId = getOneLinerProjectId();
const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {};
@@ -8856,17 +8873,21 @@ function openSystemPlatformPolicyAction(platform) {
}
async function openAdminOverrideTargetAction() {
+ if (!ensureAdminGovernanceAccess()) return;
const current = getAdminOverrideTargetState();
const directoryItems = getAdminGovernanceDirectoryItems();
openActionModal({
title: "选择管理员覆盖目标",
description: "先选中要覆盖的用户、项目和平台,再去编辑覆盖策略或查看历史。",
submitLabel: "保存目标",
+ hideSubmit: !directoryItems.length,
fields: [
{ type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前目标是 ${formatAdminGovernanceTargetLabel(current)}。`) },
- { name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() },
- { name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] },
- { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() },
+ ...(directoryItems.length ? [
+ { name: "targetUserId", label: "目标用户", type: "select", value: current.targetUserId, options: getAdminGovernanceDirectoryUserOptions() },
+ { name: "targetProjectId", label: "目标项目", type: "select", value: current.targetProjectId, options: [{ value: "", label: "用户全局" }, ...getAdminGovernanceDirectoryProjectOptions(current.targetUserId)] },
+ { name: "platform", label: "平台", type: "select", value: current.platform, options: getPlatformOptions() }
+ ] : []),
{ type: "html", label: "目录提示", html: directoryItems.length ? `
可选目标
当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。
` : `` }
],
onOpen: () => {
@@ -8906,6 +8927,7 @@ function syncAdminOverrideProjectOptions(targetUserId, preferredProjectId = "")
function openAdminOverridePolicyAction() {
const target = getAdminOverrideTargetState();
+ if (!ensureAdminOverrideTargetReady(target)) return;
const bundle = appState.adminOverridePolicy || {};
const current = bundle.current_version || {};
openActionModal({
@@ -8942,17 +8964,21 @@ function openAdminOverridePolicyAction() {
async function openAdminOverrideHistoryAction() {
const target = getAdminOverrideTargetState();
+ if (!ensureAdminOverrideTargetReady(target)) return;
const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/overrides/versions?target_user_id=${encodeURIComponent(target.targetUserId)}&target_project_id=${encodeURIComponent(target.targetProjectId)}&platform=${encodeURIComponent(target.platform)}`);
const selectedVersionId = history.items[0]?.id || "";
openActionModal({
title: "管理员覆盖历史",
description: "查看当前目标的管理员覆盖版本,并从历史里选择一个版本回滚。",
submitLabel: "回滚到所选版本",
+ hideSubmit: !selectedVersionId,
fields: [
{ type: "html", label: "当前目标", html: renderPolicyVersionSummary(appState.adminOverridePolicy || {}, `当前查看的是 ${formatAdminGovernanceTargetLabel(target)} 的覆盖历史。`) },
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "当前目标还没有历史版本。") },
- { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
- { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" }
+ ...(selectedVersionId ? [
+ { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
+ { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:这版覆盖太激进,需要恢复到上一版" }
+ ] : [])
],
onSubmit: async (values) => {
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/overrides/rollback", {
@@ -8974,17 +9000,21 @@ async function openAdminOverrideHistoryAction() {
}
async function openSystemMainPolicyHistoryAction() {
+ if (!ensureAdminGovernanceAccess()) return;
const history = await loadPolicyVersions("/v2/admin/oneliner/governance/system/main-agent/versions");
const selectedVersionId = history.items[0]?.id || "";
openActionModal({
title: "系统主 Agent 历史",
description: "查看系统主 Agent 的历史版本,并选择某个版本回滚。",
submitLabel: "回滚到所选版本",
+ hideSubmit: !selectedVersionId,
fields: [
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(appState.adminSystemMainPolicy || {}, "系统主 Agent 还没有历史版本。") },
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, "系统主 Agent 还没有历史版本。") },
- { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
- { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" }
+ ...(selectedVersionId ? [
+ { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
+ { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版系统主 Agent 策略" }
+ ] : [])
],
onSubmit: async (values) => {
const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent/rollback", {
@@ -9003,6 +9033,7 @@ async function openSystemMainPolicyHistoryAction() {
}
async function openSystemPlatformPolicyHistoryAction(platform) {
+ if (!ensureAdminGovernanceAccess()) return;
const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin");
const history = await loadPolicyVersions(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/versions`);
const selectedVersionId = history.items[0]?.id || "";
@@ -9011,11 +9042,14 @@ async function openSystemPlatformPolicyHistoryAction(platform) {
title: `${platformLabel(normalizedPlatform)} 系统平台历史`,
description: "查看该平台的系统默认策略历史,并选择某个版本回滚。",
submitLabel: "回滚到所选版本",
+ hideSubmit: !selectedVersionId,
fields: [
{ type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台历史版本。`) },
{ type: "html", label: "历史版本", html: renderPolicyVersionsHtml(history.items, `${platformLabel(normalizedPlatform)} 还没有历史版本。`) },
- { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
- { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" }
+ ...(selectedVersionId ? [
+ { name: "versionId", label: "回滚版本", type: "select", value: selectedVersionId, options: safeArray(history.items).map((item) => ({ value: item.id, label: `v${formatNumber(item.version_no || 0)} · ${item.title || brief(item.summary || item.id, 24)}` })) },
+ { name: "reason", label: "回滚原因", type: "textarea", rows: 3, value: "", placeholder: "例如:恢复到上一版平台默认方法论" }
+ ] : [])
],
onSubmit: async (values) => {
const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}/rollback`, {
diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
index 63210fe..5731e03 100644
--- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs
+++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs
@@ -639,6 +639,32 @@ test("governance UI exposes admin override target picker and history rollback en
assert.match(actions, /name === "open-system-platform-policy-history"/);
});
+test("admin governance history actions stay read-only when there are no versions yet", () => {
+ const overrideHistory = extractBetween(APP, "async function openAdminOverrideHistoryAction()", "async function openSystemMainPolicyHistoryAction()");
+ const systemMainHistory = extractBetween(APP, "async function openSystemMainPolicyHistoryAction()", "async function openSystemPlatformPolicyHistoryAction(platform)");
+ const systemPlatformHistory = extractBetween(APP, "async function openSystemPlatformPolicyHistoryAction(platform)", "function openPlatformAgentProfileAction(platform)");
+
+ assert.match(overrideHistory, /hideSubmit:\s*!selectedVersionId/);
+ assert.match(systemMainHistory, /hideSubmit:\s*!selectedVersionId/);
+ assert.match(systemPlatformHistory, /hideSubmit:\s*!selectedVersionId/);
+ assert.match(overrideHistory, /\.\.\.\(selectedVersionId \? \[/);
+ assert.match(systemMainHistory, /\.\.\.\(selectedVersionId \? \[/);
+ assert.match(systemPlatformHistory, /\.\.\.\(selectedVersionId \? \[/);
+});
+
+test("admin governance actions apply local permission and empty-directory guards before mutating", () => {
+ const helpers = extractBetween(APP, "function ensureAdminGovernanceAccess()", "function openUserGlobalPolicyAction()");
+ const targetPicker = extractBetween(APP, "async function openAdminOverrideTargetAction()", "function openAdminOverridePolicyAction()");
+ const systemMain = extractBetween(APP, "function openSystemMainPolicyAction()", "function openSystemPlatformPolicyAction(platform)");
+ const systemPlatform = extractBetween(APP, "function openSystemPlatformPolicyAction(platform)", "async function openAdminOverrideTargetAction()");
+
+ assert.match(helpers, /function ensureAdminGovernanceAccess\(\)/);
+ assert.match(helpers, /function ensureAdminOverrideTargetReady\(target\)/);
+ assert.match(targetPicker, /hideSubmit:\s*!directoryItems\.length/);
+ assert.match(systemMain, /if \(!ensureAdminGovernanceAccess\(\)\) return;/);
+ assert.match(systemPlatform, /if \(!ensureAdminGovernanceAccess\(\)\) return;/);
+});
+
test("user governance UI exposes personal history and rollback entrypoints", () => {
const playbook = extractBetween(APP, "function renderPlaybookScreen()", "function renderProductionScreen()");
const strategy = extractBetween(APP, "function renderStrategyScreen()", "function renderCreditsScreen()");
@@ -660,6 +686,17 @@ test("user governance UI exposes personal history and rollback entrypoints", ()
assert.match(actions, /name === "open-user-platform-policy-history"/);
});
+test("admin override actions guard against missing governance targets", () => {
+ const override = extractBetween(APP, "function openAdminOverridePolicyAction()", "async function openAdminOverrideHistoryAction()");
+ const overrideHistory = extractBetween(APP, "async function openAdminOverrideHistoryAction()", "async function openSystemMainPolicyHistoryAction()");
+ const helpers = extractBetween(APP, "function ensureAdminGovernanceAccess()", "function openUserGlobalPolicyAction()");
+
+ assert.match(helpers, /function ensureAdminOverrideTargetReady\(target\)/);
+ assert.match(helpers, /当前治理目录里还没有可选用户/);
+ assert.match(override, /if \(!ensureAdminOverrideTargetReady\(target\)\) return;/);
+ assert.match(overrideHistory, /if \(!ensureAdminOverrideTargetReady\(target\)\) return;/);
+});
+
test("main agent result rendering offers a direct route back into the recommended screen", () => {
const execution = extractBetween(APP, "function renderOneLinerExecutionPayloadHtml(payload)", "function parseOneLinerActionPayloadValue(value)");
const lastAction = extractBetween(APP, "function renderLastActionCard()", "function getJobRecoveryCategory(job)");