diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index ea31733..8d761ac 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -1480,6 +1480,49 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: assert row is not None return _policy_audit_payload(row) + def _policy_audit_record_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + payload = _policy_audit_payload(row) or {} + scope_row = legacy.db.fetch_one("SELECT * FROM agent_policy_scopes WHERE id = ?", (row.get("scope_id", ""),)) if row.get("scope_id") else None + version_row = legacy.db.fetch_one("SELECT * FROM agent_policy_versions WHERE id = ?", (row.get("version_id", ""),)) if row.get("version_id") else None + scope_kind = (scope_row or {}).get("scope_kind", "") or (version_row or {}).get("scope_kind", "") + platform = (scope_row or {}).get("platform", "") or (version_row or {}).get("platform", "") + subject_user_id = (scope_row or {}).get("subject_user_id", "") or (version_row or {}).get("subject_user_id", "") + subject_project_id = (scope_row or {}).get("subject_project_id", "") or (version_row or {}).get("subject_project_id", "") + payload.update( + { + "scope_kind": scope_kind, + "subject_user_id": subject_user_id, + "subject_project_id": subject_project_id, + "platform": platform, + "platform_label": legacy.platform_label(platform) if platform else "", + "scope": _policy_scope_payload( + scope_row, + fallback_kind=scope_kind, + fallback_platform=platform, + fallback_user_id=subject_user_id, + fallback_project_id=subject_project_id, + ), + "version": _policy_version_payload(version_row), + } + ) + return payload + + def _fetch_policy_audit_records(where_sql: str, params: tuple[Any, ...], *, limit: int = 20) -> list[dict[str, Any]]: + rows = legacy.db.fetch_all( + f""" + SELECT audit.* + FROM agent_policy_audit_logs audit + JOIN agent_policy_scopes scope ON scope.id = audit.scope_id + {where_sql} + ORDER BY audit.created_at DESC + LIMIT ? + """, + params + (limit,), + ) + return [_policy_audit_record_payload(row) for row in rows if row] + def _create_policy_version( scope_row: dict[str, Any], *, @@ -4496,6 +4539,26 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: items = _list_policy_versions(scope_row) return {"items": items, "count": len(items)} + @app.get("/v2/oneliner/governance/user/audits") + def list_user_policy_audits( + project_id: str | None = Query(default=None), + platform: str = Query(default=""), + limit: int = Query(default=20, ge=1, le=100), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + normalized_platform = _normalize_policy_platform(platform) + where_clauses = [ + "WHERE scope.subject_user_id = ?", + "AND (scope.subject_project_id = ? OR scope.subject_project_id = '')", + ] + params: list[Any] = [account["id"], project["id"]] + if normalized_platform: + where_clauses.append("AND (scope.platform = '' OR scope.platform = ?)") + params.append(normalized_platform) + items = _fetch_policy_audit_records(" ".join(where_clauses), tuple(params), limit=limit) + return {"items": items, "count": len(items)} + @app.post("/v2/oneliner/governance/user/global/rollback") def rollback_user_global_policy( request: AgentPolicyRollbackRequest, @@ -4823,6 +4886,43 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: _ = admin return _governance_directory_payload() + @app.get("/v2/admin/oneliner/governance/audits") + def list_admin_governance_audits( + target_user_id: str = Query(default=""), + target_project_id: str = Query(default=""), + platform: str = Query(default=""), + include_system: bool = Query(default=False), + limit: int = Query(default=30, ge=1, le=100), + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + normalized_platform = _normalize_policy_platform(platform) + clauses: list[str] = [] + params: list[Any] = [] + + if target_user_id.strip(): + target_parts = ["(scope.subject_user_id = ?"] + params.append(target_user_id.strip()) + if target_project_id.strip(): + target_parts.append("AND (scope.subject_project_id = ? OR scope.subject_project_id = '')") + params.append(target_project_id.strip()) + if normalized_platform: + target_parts.append("AND (scope.platform = '' OR scope.platform = ?)") + params.append(normalized_platform) + target_parts.append(")") + clauses.append(" ".join(target_parts)) + + if include_system: + if normalized_platform: + clauses.append("(scope.scope_kind = 'system_main' OR (scope.scope_kind = 'system_platform' AND scope.platform = ?))") + params.append(normalized_platform) + else: + clauses.append("(scope.scope_kind IN ('system_main', 'system_platform'))") + + where_sql = "WHERE 1 = 1" if not clauses else "WHERE " + " OR ".join(f"({clause})" for clause in clauses) + items = _fetch_policy_audit_records(where_sql, tuple(params), limit=limit) + return {"items": items, "count": len(items)} + @app.get("/v2/admin/oneliner/governance/overrides") def get_admin_override_policy( target_user_id: str = Query(default=""), diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py index 91b7ae0..c589691 100644 --- a/tests/test_main_agent_governance.py +++ b/tests/test_main_agent_governance.py @@ -509,6 +509,85 @@ class MainAgentGovernanceTests(unittest.TestCase): self.assertEqual(versions_after.status_code, 200, versions_after.text) self.assertEqual(versions_after.json()["count"], 3) + def test_user_policy_audits_include_personal_and_admin_layers_for_project(self) -> None: + self.client.put( + "/v2/oneliner/governance/user/global", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Global strategy", + "policy": {"tone": {"style": "analytical"}}, + "reason": "personalize defaults", + }, + ) + self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Admin override", + "policy": {"actions": {"max_cards": 4}}, + "reason": "contain drift", + }, + ) + + response = self.client.get( + "/v2/oneliner/governance/user/audits", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertGreaterEqual(payload["count"], 2) + scope_kinds = [item["scope_kind"] for item in payload["items"]] + self.assertIn("user_global", scope_kinds) + self.assertIn("admin_override", scope_kinds) + first_item = payload["items"][0] + self.assertIn("version", first_item) + self.assertIn("scope", first_item) + + def test_admin_policy_audits_include_target_and_system_layers(self) -> None: + self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "System main", + "policy": {"homepage": {"focus": "ops"}}, + "reason": "seed system baseline", + }, + ) + self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "title": "Admin override", + "policy": {"actions": {"max_cards": 5}}, + "reason": "focus target account", + }, + ) + + response = self.client.get( + "/v2/admin/oneliner/governance/audits", + headers=self.ctx["admin_headers"], + params={ + "target_user_id": self.ctx["member_id"], + "target_project_id": self.ctx["project_id"], + "platform": "douyin", + "include_system": "1", + }, + ) + self.assertEqual(response.status_code, 200, response.text) + payload = response.json() + self.assertGreaterEqual(payload["count"], 2) + scope_kinds = [item["scope_kind"] for item in payload["items"]] + self.assertIn("system_main", scope_kinds) + self.assertIn("admin_override", scope_kinds) + def test_non_admin_cannot_change_system_defaults(self) -> None: response = self.client.put( "/v2/admin/oneliner/governance/system/main-agent", diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index c76a1a2..df53df4 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -30,6 +30,7 @@ const appState = { dashboardOverviewTab: "project_progress", discoveryDetailTab: "overview", playbookDetailTab: "workspace", + strategyDetailTab: "effective", productionDetailTab: "queue", automationDetailTab: "health", adminWorkbenchTab: "integrations", @@ -57,11 +58,13 @@ const appState = { onelinerGovernanceEffective: null, userGlobalPolicy: null, userCurrentPlatformPolicy: null, + userPolicyAudits: [], adminSystemMainPolicy: null, adminSystemPlatformPolicies: [], adminGovernanceDirectory: null, adminOverrideTarget: null, adminOverridePolicy: null, + adminPolicyAudits: [], tenantQuota: null, tenantUsage: null, adminOpsOverview: null, @@ -877,6 +880,9 @@ function openActionModal(config) { submit.disabled = false; submit.hidden = Boolean(config.hideSubmit); modal.classList.remove("hidden"); + if (typeof config.onOpen === "function") { + config.onOpen({ modal, title, description, fields, message, submit }); + } } function closeActionModal() { @@ -1262,11 +1268,13 @@ async function logoutSession() { appState.onelinerGovernanceEffective = null; appState.userGlobalPolicy = null; appState.userCurrentPlatformPolicy = null; + appState.userPolicyAudits = []; appState.adminSystemMainPolicy = null; appState.adminSystemPlatformPolicies = []; appState.adminGovernanceDirectory = null; appState.adminOverrideTarget = null; appState.adminOverridePolicy = null; + appState.adminPolicyAudits = []; appState.tenantQuota = null; appState.tenantUsage = null; appState.adminOpsOverview = null; @@ -1316,16 +1324,18 @@ async function loadAgentControlSurfaces(projectId = "") { const supportsGovernanceEffective = backendSupports("/v2/oneliner/governance/effective"); const supportsUserGlobalPolicy = backendSupports("/v2/oneliner/governance/user/global"); const supportsUserPlatformPolicy = backendSupports("/v2/oneliner/governance/user/platforms/{platform}"); + const supportsUserPolicyAudits = backendSupports("/v2/oneliner/governance/user/audits"); const supportsAdminSystemMainPolicy = backendSupports("/v2/admin/oneliner/governance/system/main-agent"); const supportsAdminSystemPlatformPolicy = backendSupports("/v2/admin/oneliner/governance/system/platforms/{platform}"); const supportsAdminGovernanceDirectory = backendSupports("/v2/admin/oneliner/governance/directory"); const supportsAdminOverridePolicy = backendSupports("/v2/admin/oneliner/governance/overrides"); + const supportsAdminGovernanceAudits = backendSupports("/v2/admin/oneliner/governance/audits"); const supportsAdminOps = backendSupports("/v2/admin/ops/overview"); const supportsAdminFixRuns = backendSupports("/v2/admin/ops/fix-runs"); const supportsTenantQuota = backendSupports("/v2/tenant/quota"); const supportsTenantUsage = backendSupports("/v2/tenant/usage"); - const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ + const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, userPolicyAuditsPayload, adminSystemMainPolicy, adminSystemPlatformPolicies, adminGovernanceDirectory, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ supportsOneLinerProfile ? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1347,6 +1357,9 @@ async function loadAgentControlSurfaces(projectId = "") { supportsUserPlatformPolicy ? storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(governancePlatform)}?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), + supportsUserPolicyAudits + ? storyforgeFetch(`/v2/oneliner/governance/user/audits?project_id=${encodeURIComponent(normalizedProjectId)}&platform=${encodeURIComponent(governancePlatform)}`).catch(() => ({ items: [] })) + : Promise.resolve({ items: [] }), supportsAdminSystemMainPolicy && isSuperAdmin() ? storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent").catch(() => null) : Promise.resolve(null), @@ -1382,15 +1395,23 @@ async function loadAgentControlSurfaces(projectId = "") { appState.onelinerGovernanceEffective = governanceEffective; appState.userGlobalPolicy = userGlobalPolicy; appState.userCurrentPlatformPolicy = userCurrentPlatformPolicy; + appState.userPolicyAudits = safeArray(userPolicyAuditsPayload?.items || userPolicyAuditsPayload); appState.adminSystemMainPolicy = adminSystemMainPolicy; appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies); appState.adminGovernanceDirectory = safeArray(adminGovernanceDirectory?.items || adminGovernanceDirectory); if (isSuperAdmin() && supportsAdminOverridePolicy && appState.adminGovernanceDirectory.length) { const existingTarget = appState.adminOverrideTarget || {}; + const hasExistingProjectTarget = Object.prototype.hasOwnProperty.call(existingTarget, "targetProjectId") + || Object.prototype.hasOwnProperty.call(existingTarget, "target_project_id"); const targetUserId = String(existingTarget.targetUserId || existingTarget.target_user_id || appState.adminGovernanceDirectory[0]?.id || ""); const targetUser = appState.adminGovernanceDirectory.find((item) => item.id === targetUserId) || appState.adminGovernanceDirectory[0] || null; const targetProjects = safeArray(targetUser?.projects); - const targetProjectId = String(existingTarget.targetProjectId || existingTarget.target_project_id || targetProjects[0]?.id || ""); + const requestedProjectId = hasExistingProjectTarget + ? String(existingTarget.targetProjectId ?? existingTarget.target_project_id ?? "") + : String(targetProjects[0]?.id || ""); + const targetProjectId = requestedProjectId === "" + ? "" + : (targetProjects.some((item) => String(item?.id || "") === requestedProjectId) ? requestedProjectId : String(targetProjects[0]?.id || "")); const targetPlatform = normalizePlatformValue(existingTarget.platform || governancePlatform, "douyin"); appState.adminOverrideTarget = { targetUserId, @@ -1398,9 +1419,13 @@ async function loadAgentControlSurfaces(projectId = "") { platform: targetPlatform }; appState.adminOverridePolicy = await storyforgeFetch(`/v2/admin/oneliner/governance/overrides?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}`).catch(() => null); + appState.adminPolicyAudits = supportsAdminGovernanceAudits + ? safeArray((await storyforgeFetch(`/v2/admin/oneliner/governance/audits?target_user_id=${encodeURIComponent(targetUserId)}&target_project_id=${encodeURIComponent(targetProjectId)}&platform=${encodeURIComponent(targetPlatform)}&include_system=1`).catch(() => ({ items: [] })))?.items || []) + : []; } else { appState.adminOverrideTarget = null; appState.adminOverridePolicy = null; + appState.adminPolicyAudits = []; } appState.tenantQuota = tenantQuota; appState.tenantUsage = tenantUsage; @@ -3395,6 +3420,52 @@ function renderAdminGovernanceSummaryPanel() { `; } +function renderAdminGovernanceAuditPanel() { + const targetSummary = getAdminOverrideTargetSummary(); + const audits = safeArray(appState.adminPolicyAudits); + return ` +
${escapeHtml(appState.adminOverridePolicy?.current_version?.summary || "当前目标还没有管理员覆盖版本。")}
+ +管理员对用户策略的代改不会抹掉用户自己的历史,只会形成更高优先级的覆盖层,并在这里留下审计记录。
+${escapeHtml(project?.name || "当前项目")} · ${escapeHtml(platformLabel(platform))}。这里展示系统默认、你的个性化策略和管理员覆盖是如何叠加生效的。
+${escapeHtml(layer.current_version?.summary || layer.scope?.summary || "当前层还没有补充摘要。")}
+ +当前会话还没有拉到治理层信息。
你发布或回滚自己的策略,只会影响你当前账户下的工作方式。
+如果管理员对你当前项目施加了覆盖层,这里会出现对应的记录与摘要。
+${escapeHtml(emptyText)}
${escapeHtml(version.summary || version.reason || "当前记录没有补充摘要。")}
+ +当前目录里有 ${escapeHtml(formatNumber(directoryItems.length))} 位已审核账号。
后端还没有返回可选账号。