From cb17fb07603e0fd64ad49ef30d707b7a2bc73e87 Mon Sep 17 00:00:00 2001 From: kris Date: Sun, 29 Mar 2026 16:13:50 +0800 Subject: [PATCH] feat: add main agent governance foundation --- collector-service/app/oneliner_features.py | 1230 +++++++++++++++++ ...-03-29-main-agent-governance-foundation.md | 284 ++++ ...main-agent-governance-foundation-design.md | 283 ++++ scripts/check_repo_baseline.sh | 16 +- tests/test_main_agent_governance.py | 380 +++++ web/storyforge-web-v4/assets/app.js | 341 ++++- .../tests/workbench-pages.test.mjs | 47 + 7 files changed, 2574 insertions(+), 7 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md create mode 100644 docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md create mode 100644 tests/test_main_agent_governance.py diff --git a/collector-service/app/oneliner_features.py b/collector-service/app/oneliner_features.py index a0c55fc..218c8de 100644 --- a/collector-service/app/oneliner_features.py +++ b/collector-service/app/oneliner_features.py @@ -134,6 +134,30 @@ class TenantQuotaRequest(BaseModel): config: dict[str, Any] = Field(default_factory=dict) +class AgentPolicyUpsertRequest(BaseModel): + project_id: str = "" + target_user_id: str = "" + target_project_id: str = "" + platform: str = "" + title: str = "" + summary: str = "" + policy: dict[str, Any] = Field(default_factory=dict) + effect_mode: str = "ongoing" + starts_at: str = "" + ends_at: str = "" + config: dict[str, Any] = Field(default_factory=dict) + reason: str = "" + + +class AgentPolicyRollbackRequest(BaseModel): + project_id: str = "" + target_user_id: str = "" + target_project_id: str = "" + platform: str = "" + version_id: str + reason: str = "" + + INTENT_ACTIONS: dict[str, list[dict[str, Any]]] = { "create_project": [{"key": "goto-intake", "label": "去我的项目", "kind": "navigate"}], "create_assistant": [{"key": "open-create-assistant", "label": "创建 Agent", "kind": "ui_action"}], @@ -331,6 +355,19 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: current = datetime.now(timezone.utc) return current.replace(day=1, hour=0, minute=0, second=0, microsecond=0).isoformat().replace("+00:00", "Z") + def _parse_policy_datetime(value: str | None) -> datetime | None: + text = str(value or "").strip() + if not text: + return None + try: + normalized = text.replace("Z", "+00:00") + parsed = datetime.fromisoformat(normalized) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed.astimezone(timezone.utc) + except ValueError: + return None + def ensure_schema() -> None: schema = """ CREATE TABLE IF NOT EXISTS oneliner_profiles ( @@ -445,6 +482,65 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: FOREIGN KEY(user_id) REFERENCES accounts(id) ON DELETE CASCADE ); + CREATE TABLE IF NOT EXISTS agent_policy_scopes ( + id TEXT PRIMARY KEY, + scope_kind TEXT NOT NULL, + subject_user_id TEXT NOT NULL DEFAULT '', + subject_project_id TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + title TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + current_version_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(scope_kind, subject_user_id, subject_project_id, platform) + ); + + CREATE TABLE IF NOT EXISTS agent_policy_versions ( + id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL, + scope_kind TEXT NOT NULL, + subject_user_id TEXT NOT NULL DEFAULT '', + subject_project_id TEXT NOT NULL DEFAULT '', + platform TEXT NOT NULL DEFAULT '', + version_no INTEGER NOT NULL DEFAULT 1, + title TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + policy_json TEXT NOT NULL DEFAULT '{}', + reason TEXT NOT NULL DEFAULT '', + source_type TEXT NOT NULL DEFAULT 'user', + rollback_from_version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + UNIQUE(scope_id, version_no) + ); + + CREATE TABLE IF NOT EXISTS agent_policy_effectivity ( + id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL, + version_id TEXT NOT NULL, + effect_mode TEXT NOT NULL DEFAULT 'ongoing', + starts_at TEXT NOT NULL DEFAULT '', + ends_at TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'active', + config_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE(version_id) + ); + + CREATE TABLE IF NOT EXISTS agent_policy_audit_logs ( + id TEXT PRIMARY KEY, + scope_id TEXT NOT NULL DEFAULT '', + version_id TEXT NOT NULL DEFAULT '', + actor_user_id TEXT NOT NULL DEFAULT '', + action_key TEXT NOT NULL DEFAULT '', + summary TEXT NOT NULL DEFAULT '', + details_json TEXT NOT NULL DEFAULT '{}', + created_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS admin_ops_incidents ( id TEXT PRIMARY KEY, tenant_user_id TEXT NOT NULL DEFAULT '', @@ -1096,6 +1192,534 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: row = legacy.db.fetch_one("SELECT * FROM agent_skill_versions WHERE id = ?", (version_id,)) return _skill_version_payload(row) + def _normalize_policy_platform(platform: str | None) -> str: + if not str(platform or "").strip(): + return "" + return _safe_platform(platform, fallback="") + + def _deep_merge_policy(base: Any, override: Any) -> Any: + if not isinstance(base, dict) or not isinstance(override, dict): + return override if override is not None else base + merged = {key: value for key, value in base.items()} + for key, value in override.items(): + if key in merged and isinstance(merged[key], dict) and isinstance(value, dict): + merged[key] = _deep_merge_policy(merged[key], value) + else: + merged[key] = value + return merged + + def _policy_scope_default_title(scope_kind: str, *, platform: str = "") -> str: + if scope_kind == "system_main": + return "系统主 Agent 策略" + if scope_kind == "system_platform": + return f"{legacy.platform_label(platform)} 系统平台策略" + if scope_kind == "user_global": + return "用户全局策略" + if scope_kind == "user_platform": + return f"{legacy.platform_label(platform)} 用户平台策略" + if scope_kind == "admin_override": + return "管理员覆盖策略" + return "Agent 策略" + + def _policy_scope_row( + *, + scope_kind: str, + subject_user_id: str = "", + subject_project_id: str = "", + platform: str = "", + ) -> dict[str, Any] | None: + return legacy.db.fetch_one( + """ + SELECT * FROM agent_policy_scopes + WHERE scope_kind = ? AND subject_user_id = ? AND subject_project_id = ? AND platform = ? + """, + (scope_kind, subject_user_id, subject_project_id, platform), + ) + + def _policy_scope_payload(row: dict[str, Any] | None, *, fallback_kind: str = "", fallback_platform: str = "", fallback_user_id: str = "", fallback_project_id: str = "") -> dict[str, Any]: + data = row or {} + scope_kind = data.get("scope_kind") or fallback_kind + platform = data.get("platform") or fallback_platform + return { + "id": data.get("id", ""), + "scope_kind": scope_kind, + "subject_user_id": data.get("subject_user_id", fallback_user_id), + "subject_project_id": data.get("subject_project_id", fallback_project_id), + "platform": platform, + "platform_label": legacy.platform_label(platform) if platform else "", + "status": data.get("status", "active"), + "title": data.get("title") or _policy_scope_default_title(scope_kind, platform=platform), + "summary": data.get("summary", ""), + "current_version_id": data.get("current_version_id", ""), + "created_at": data.get("created_at", ""), + "updated_at": data.get("updated_at", ""), + } + + def _policy_version_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + return { + "id": row["id"], + "scope_id": row.get("scope_id", ""), + "scope_kind": row.get("scope_kind", ""), + "subject_user_id": row.get("subject_user_id", ""), + "subject_project_id": row.get("subject_project_id", ""), + "platform": row.get("platform", ""), + "platform_label": legacy.platform_label(row.get("platform", "")) if row.get("platform") else "", + "version_no": int(row.get("version_no") or 0), + "title": row.get("title", ""), + "summary": row.get("summary", ""), + "policy": _parse_json(row.get("policy_json"), {}), + "reason": row.get("reason", ""), + "source_type": row.get("source_type", ""), + "rollback_from_version_id": row.get("rollback_from_version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "created_at": row.get("created_at", ""), + } + + def _policy_effectivity_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + return { + "id": row["id"], + "scope_id": row.get("scope_id", ""), + "version_id": row.get("version_id", ""), + "effect_mode": row.get("effect_mode", "ongoing"), + "starts_at": row.get("starts_at", ""), + "ends_at": row.get("ends_at", ""), + "status": row.get("status", "active"), + "config": _parse_json(row.get("config_json"), {}), + "created_at": row.get("created_at", ""), + "updated_at": row.get("updated_at", ""), + } + + def _policy_audit_payload(row: dict[str, Any] | None) -> dict[str, Any] | None: + if not row: + return None + return { + "id": row["id"], + "scope_id": row.get("scope_id", ""), + "version_id": row.get("version_id", ""), + "actor_user_id": row.get("actor_user_id", ""), + "action_key": row.get("action_key", ""), + "summary": row.get("summary", ""), + "details": _parse_json(row.get("details_json"), {}), + "created_at": row.get("created_at", ""), + } + + def _ensure_policy_scope( + *, + scope_kind: str, + subject_user_id: str = "", + subject_project_id: str = "", + platform: str = "", + title: str = "", + summary: str = "", + ) -> dict[str, Any]: + normalized_platform = _normalize_policy_platform(platform) + existing = _policy_scope_row( + scope_kind=scope_kind, + subject_user_id=subject_user_id, + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + if existing: + return existing + scope_id = make_id("policy_scope") + timestamp = now() + legacy.db.execute( + """ + INSERT INTO agent_policy_scopes ( + id, scope_kind, subject_user_id, subject_project_id, platform, status, + title, summary, current_version_id, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, 'active', ?, ?, '', ?, ?) + """, + ( + scope_id, + scope_kind, + subject_user_id, + subject_project_id, + normalized_platform, + title.strip() or _policy_scope_default_title(scope_kind, platform=normalized_platform), + summary.strip(), + timestamp, + timestamp, + ), + ) + row = legacy.db.fetch_one("SELECT * FROM agent_policy_scopes WHERE id = ?", (scope_id,)) + assert row is not None + return row + + def _policy_effectivity_is_active( + effectivity_row: dict[str, Any] | None, + *, + current_time: datetime | None = None, + ) -> bool: + if not effectivity_row: + return True + status = str(effectivity_row.get("status") or "active").strip().lower() + if status and status != "active": + return False + effect_mode = str(effectivity_row.get("effect_mode") or "ongoing").strip().lower() + starts_at = _parse_policy_datetime(effectivity_row.get("starts_at")) + ends_at = _parse_policy_datetime(effectivity_row.get("ends_at")) + reference_time = current_time or datetime.now(timezone.utc) + if effect_mode == "scheduled" and not starts_at: + return False + if starts_at and reference_time < starts_at: + return False + if ends_at and reference_time > ends_at: + return False + if effect_mode in {"disabled", "inactive", "draft", "archived"}: + return False + return True + + def _current_policy_version_row( + scope_row: dict[str, Any] | None, + *, + active_only: bool = False, + ) -> dict[str, Any] | None: + if not scope_row: + return None + if scope_row.get("current_version_id") and not active_only: + row = legacy.db.fetch_one( + "SELECT * FROM agent_policy_versions WHERE id = ?", + (scope_row["current_version_id"],), + ) + if row: + return row + rows = legacy.db.fetch_all( + """ + SELECT * FROM agent_policy_versions + WHERE scope_id = ? + ORDER BY version_no DESC, created_at DESC + """, + (scope_row["id"],), + ) + if not rows: + return None + if not active_only: + return rows[0] + reference_time = datetime.now(timezone.utc) + for row in rows: + effectivity_row = _policy_effectivity_row(row["id"]) + if _policy_effectivity_is_active(effectivity_row, current_time=reference_time): + return row + return None + + def _policy_effectivity_row(version_id: str) -> dict[str, Any] | None: + return legacy.db.fetch_one( + "SELECT * FROM agent_policy_effectivity WHERE version_id = ?", + (version_id,), + ) + + def _policy_scope_bundle( + scope_row: dict[str, Any] | None, + *, + fallback_kind: str = "", + fallback_platform: str = "", + fallback_user_id: str = "", + fallback_project_id: str = "", + active_only: bool = False, + ) -> dict[str, Any]: + current_version_row = _current_policy_version_row(scope_row, active_only=active_only) + effectivity_row = _policy_effectivity_row(current_version_row["id"]) if current_version_row else None + return { + "scope": _policy_scope_payload( + scope_row, + fallback_kind=fallback_kind, + fallback_platform=fallback_platform, + fallback_user_id=fallback_user_id, + fallback_project_id=fallback_project_id, + ), + "current_version": _policy_version_payload(current_version_row), + "effectivity": _policy_effectivity_payload(effectivity_row), + } + + def _list_policy_versions(scope_row: dict[str, Any] | None) -> list[dict[str, Any]]: + if not scope_row: + return [] + rows = legacy.db.fetch_all( + """ + SELECT * FROM agent_policy_versions + WHERE scope_id = ? + ORDER BY version_no DESC, created_at DESC + """, + (scope_row["id"],), + ) + return [_policy_version_payload(row) for row in rows] + + def _log_policy_audit( + *, + scope_id: str, + version_id: str, + actor_user_id: str, + action_key: str, + summary: str, + details: dict[str, Any] | None = None, + ) -> dict[str, Any]: + audit_id = make_id("policy_audit") + legacy.db.execute( + """ + INSERT INTO agent_policy_audit_logs ( + id, scope_id, version_id, actor_user_id, action_key, summary, details_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + audit_id, + scope_id, + version_id, + actor_user_id, + action_key, + summary, + _dump(details or {}), + now(), + ), + ) + row = legacy.db.fetch_one("SELECT * FROM agent_policy_audit_logs WHERE id = ?", (audit_id,)) + assert row is not None + return _policy_audit_payload(row) + + def _create_policy_version( + scope_row: dict[str, Any], + *, + actor_user_id: str, + title: str, + summary: str, + policy: dict[str, Any], + effect_mode: str, + starts_at: str, + ends_at: str, + config: dict[str, Any], + reason: str, + source_type: str, + rollback_from_version_id: str = "", + ) -> dict[str, Any]: + current = legacy.db.fetch_one( + "SELECT COALESCE(MAX(version_no), 0) AS max_version FROM agent_policy_versions WHERE scope_id = ?", + (scope_row["id"],), + ) + version_no = int((current or {}).get("max_version") or 0) + 1 + version_id = make_id("policy_ver") + effectivity_id = make_id("policy_eff") + timestamp = now() + legacy.db.execute( + """ + INSERT INTO agent_policy_versions ( + id, scope_id, scope_kind, subject_user_id, subject_project_id, platform, + version_no, title, summary, policy_json, reason, source_type, + rollback_from_version_id, actor_user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + version_id, + scope_row["id"], + scope_row.get("scope_kind", ""), + scope_row.get("subject_user_id", ""), + scope_row.get("subject_project_id", ""), + scope_row.get("platform", ""), + version_no, + title.strip() or scope_row.get("title") or _policy_scope_default_title(scope_row.get("scope_kind", ""), platform=scope_row.get("platform", "")), + summary.strip(), + _dump(policy), + reason.strip(), + source_type, + rollback_from_version_id, + actor_user_id, + timestamp, + ), + ) + legacy.db.execute( + """ + INSERT INTO agent_policy_effectivity ( + id, scope_id, version_id, effect_mode, starts_at, ends_at, status, config_json, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?) + """, + ( + effectivity_id, + scope_row["id"], + version_id, + (effect_mode or "ongoing").strip() or "ongoing", + starts_at.strip(), + ends_at.strip(), + _dump(config), + timestamp, + timestamp, + ), + ) + legacy.db.execute( + """ + UPDATE agent_policy_scopes + SET title = ?, summary = ?, current_version_id = ?, status = 'active', updated_at = ? + WHERE id = ? + """, + ( + title.strip() or scope_row.get("title") or _policy_scope_default_title(scope_row.get("scope_kind", ""), platform=scope_row.get("platform", "")), + summary.strip(), + version_id, + timestamp, + scope_row["id"], + ), + ) + row = legacy.db.fetch_one("SELECT * FROM agent_policy_scopes WHERE id = ?", (scope_row["id"],)) + assert row is not None + return _policy_scope_bundle(row) + + def _rollback_policy_scope( + scope_row: dict[str, Any], + *, + actor_user_id: str, + version_id: str, + reason: str, + source_type: str, + ) -> dict[str, Any]: + target_version = legacy.db.fetch_one( + "SELECT * FROM agent_policy_versions WHERE id = ? AND scope_id = ?", + (version_id, scope_row["id"]), + ) + if not target_version: + raise HTTPException(status_code=404, detail="Policy version not found") + target_effectivity = _policy_effectivity_row(target_version["id"]) or {} + bundle = _create_policy_version( + scope_row, + actor_user_id=actor_user_id, + title=target_version.get("title", ""), + summary=target_version.get("summary", ""), + policy=_parse_json(target_version.get("policy_json"), {}), + effect_mode=target_effectivity.get("effect_mode", "ongoing"), + starts_at=target_effectivity.get("starts_at", ""), + ends_at=target_effectivity.get("ends_at", ""), + config=_parse_json(target_effectivity.get("config_json"), {}), + reason=reason.strip() or f"回滚到版本 {target_version.get('version_no') or version_id}", + source_type=source_type, + rollback_from_version_id=target_version["id"], + ) + return bundle + + def _load_policy_subject_account(user_id: str) -> dict[str, Any]: + row = legacy.db.fetch_one("SELECT * FROM accounts WHERE id = ?", (user_id,)) + if not row: + raise HTTPException(status_code=404, detail="Target account not found") + return row + + def _load_policy_subject_project(*, user_id: str, project_id: str) -> dict[str, Any]: + row = legacy.db.fetch_one("SELECT * FROM projects WHERE id = ?", (project_id,)) + if not row: + raise HTTPException(status_code=404, detail="Target project not found") + if user_id and row.get("user_id") != user_id: + raise HTTPException(status_code=400, detail="Target project does not belong to target user") + return row + + def _effective_policy_payload( + *, + subject_account: dict[str, Any], + subject_project_id: str, + platform: str, + ) -> dict[str, Any]: + normalized_platform = _normalize_policy_platform(platform) + candidate_scopes: list[dict[str, Any]] = [] + seen_scope_ids: set[str] = set() + + def add_candidate(scope_row: dict[str, Any] | None) -> None: + if not scope_row or not scope_row.get("id"): + return + if scope_row["id"] in seen_scope_ids: + return + seen_scope_ids.add(scope_row["id"]) + candidate_scopes.append(scope_row) + + add_candidate(_policy_scope_row(scope_kind="system_main")) + if normalized_platform: + add_candidate(_policy_scope_row(scope_kind="system_platform", platform=normalized_platform)) + add_candidate( + _policy_scope_row(scope_kind="user_global", subject_user_id=subject_account["id"], subject_project_id=subject_project_id) + ) + if normalized_platform: + add_candidate( + _policy_scope_row( + scope_kind="user_platform", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + ) + add_candidate( + _policy_scope_row(scope_kind="admin_override", subject_user_id=subject_account["id"], subject_project_id="", platform="") + ) + if normalized_platform: + add_candidate( + _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id="", + platform=normalized_platform, + ) + ) + if subject_project_id: + add_candidate( + _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform="", + ) + ) + if normalized_platform: + add_candidate( + _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + ) + layers: list[dict[str, Any]] = [] + effective_policy: dict[str, Any] = {} + for scope_row in candidate_scopes: + if not scope_row: + continue + bundle = _policy_scope_bundle(scope_row, active_only=True) + version = bundle.get("current_version") + if not version: + continue + layer_policy = dict(version.get("policy") or {}) + effective_policy = _deep_merge_policy(effective_policy, layer_policy) + layers.append( + { + "scope_kind": bundle["scope"]["scope_kind"], + "scope": bundle["scope"], + "current_version": version, + "effectivity": bundle.get("effectivity"), + } + ) + return { + "user_id": subject_account["id"], + "project_id": subject_project_id, + "platform": normalized_platform, + "platform_label": legacy.platform_label(normalized_platform) if normalized_platform else "", + "layers": layers, + "effective_policy": effective_policy, + } + + def _bundle_with_versions( + scope_row: dict[str, Any] | None, + *, + fallback_kind: str, + fallback_platform: str = "", + fallback_user_id: str = "", + fallback_project_id: str = "", + ) -> dict[str, Any]: + bundle = _policy_scope_bundle( + scope_row, + fallback_kind=fallback_kind, + fallback_platform=fallback_platform, + fallback_user_id=fallback_user_id, + fallback_project_id=fallback_project_id, + ) + versions = _list_policy_versions(scope_row) + bundle["versions"] = {"items": versions, "count": len(versions)} + return bundle + def _fix_run_payload(row: dict[str, Any]) -> dict[str, Any]: return { "id": row["id"], @@ -1923,6 +2547,22 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: """, (account["id"], project["id"], platform), ) if platform else [] + governance_effective = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=platform, + ) + user_global_scope = _policy_scope_row( + scope_kind="user_global", + subject_user_id=account["id"], + subject_project_id=project["id"], + ) + user_platform_scope = _policy_scope_row( + scope_kind="user_platform", + subject_user_id=account["id"], + subject_project_id=project["id"], + platform=_normalize_policy_platform(platform), + ) if platform else None return { "project": legacy.project_payload(project), "oneliner_profile": _profile_payload(profile_row, account=account) if profile_row else None, @@ -1931,6 +2571,22 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: "oneliner_memories": [_memory_payload(row) for row in oneliner_memory_rows], "platform_memories": [_memory_payload(row) for row in platform_memory_rows], "platform_skills": [_skill_payload(row) for row in platform_skill_rows], + "governance": { + "effective": governance_effective, + "user_global": _bundle_with_versions( + user_global_scope, + fallback_kind="user_global", + fallback_user_id=account["id"], + fallback_project_id=project["id"], + ), + "user_platform": _bundle_with_versions( + user_platform_scope, + fallback_kind="user_platform", + fallback_platform=_normalize_policy_platform(platform), + fallback_user_id=account["id"], + fallback_project_id=project["id"], + ) if platform else None, + }, } async def _generate_oneliner_reply( @@ -1942,6 +2598,9 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: ) -> dict[str, Any]: context = _session_context_summary(account, project_id or "", plan.get("platform") or "") platform_agent = context.get("platform_agent") or {} + governance = context.get("governance") or {} + effective_policy = (governance.get("effective") or {}).get("effective_policy") or {} + governance_layers = (governance.get("effective") or {}).get("layers") or [] primary_action = (plan.get("suggested_actions") or [{}])[0] if plan.get("suggested_actions") else None evidence = [] if platform_agent.get("recent_memory"): @@ -1975,6 +2634,8 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: next_steps.append(f"默认调度 {platform_agent['assistant']['name']} 作为执行 Agent。") if evidence: next_steps.append("我会优先参考该平台 Agent 最近沉淀的方法与技能。") + if governance_layers: + next_steps.append(f"当前会话已叠加 {len(governance_layers)} 层策略,我会先按生效策略执行。") summary_lines = [ f"我理解你的目标是:{plan.get('intent_label', '自定义任务')}。", f"建议优先处理的平台:{plan.get('platform_label', '待判断')}。", @@ -1990,6 +2651,8 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: summary_lines.append(f"最近有效经验:{platform_agent['recent_memory'].get('title') or '一条平台记忆'}。") if platform_agent.get("recent_skill"): summary_lines.append(f"最近有效技能:{platform_agent['recent_skill'].get('name') or '一条技能'}。") + if effective_policy.get("guardrails", {}).get("require_admin_review"): + summary_lines.append("当前策略层要求管理员复核,所以我会把高风险动作先压成待确认。") secondary_actions: list[dict[str, Any]] = [] if plan.get("platform"): secondary_actions.append( @@ -3695,6 +4358,573 @@ def register_oneliner_routes(app: Any, legacy: Any) -> None: normalized_platform = _safe_platform(platform) return _rollback_platform_skill(account, platform=normalized_platform, skill_id=skill_id, request=request) + @app.get("/v2/oneliner/governance/effective") + def get_effective_oneliner_governance( + project_id: str | None = Query(default=None), + platform: str | None = Query(default=None), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + return _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=platform or "", + ) + + @app.get("/v2/oneliner/governance/user/global") + def get_user_global_policy( + project_id: str | None = Query(default=None), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + scope_row = _policy_scope_row( + scope_kind="user_global", + subject_user_id=account["id"], + subject_project_id=project["id"], + ) + payload = _bundle_with_versions( + scope_row, + fallback_kind="user_global", + fallback_user_id=account["id"], + fallback_project_id=project["id"], + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform="", + )["effective_policy"] + return payload + + @app.put("/v2/oneliner/governance/user/global") + def put_user_global_policy( + request: AgentPolicyUpsertRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + scope_row = _ensure_policy_scope( + scope_kind="user_global", + subject_user_id=account["id"], + subject_project_id=project["id"], + title=request.title, + summary=request.summary, + ) + payload = _create_policy_version( + scope_row, + actor_user_id=account["id"], + title=request.title, + summary=request.summary, + policy=request.policy, + effect_mode=request.effect_mode, + starts_at=request.starts_at, + ends_at=request.ends_at, + config=request.config, + reason=request.reason, + source_type="user", + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform="", + )["effective_policy"] + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=account["id"], + action_key="publish-user-global-policy", + summary=f"已更新用户全局策略:{payload['scope']['title']}", + details={"project_id": project["id"]}, + ) + return payload + + @app.get("/v2/oneliner/governance/user/global/versions") + def list_user_global_policy_versions( + project_id: str | None = Query(default=None), + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, project_id or None) + scope_row = _policy_scope_row( + scope_kind="user_global", + subject_user_id=account["id"], + subject_project_id=project["id"], + ) + items = _list_policy_versions(scope_row) + return {"items": items, "count": len(items)} + + @app.post("/v2/oneliner/governance/user/global/rollback") + def rollback_user_global_policy( + request: AgentPolicyRollbackRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + scope_row = _policy_scope_row( + scope_kind="user_global", + subject_user_id=account["id"], + subject_project_id=project["id"], + ) + if not scope_row: + raise HTTPException(status_code=404, detail="Policy scope not found") + payload = _rollback_policy_scope( + scope_row, + actor_user_id=account["id"], + version_id=request.version_id, + reason=request.reason, + source_type="user_rollback", + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform="", + )["effective_policy"] + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=account["id"], + action_key="rollback-user-global-policy", + summary=f"已回滚用户全局策略:{payload['scope']['title']}", + details={"project_id": project["id"], "rollback_to_version_id": request.version_id}, + ) + return payload + + @app.get("/v2/oneliner/governance/user/platforms/{platform}") + def get_user_platform_policy( + platform: str, + project_id: str | None = Query(default=None), + 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) + scope_row = _policy_scope_row( + scope_kind="user_platform", + subject_user_id=account["id"], + subject_project_id=project["id"], + platform=normalized_platform, + ) + payload = _bundle_with_versions( + scope_row, + fallback_kind="user_platform", + fallback_platform=normalized_platform, + fallback_user_id=account["id"], + fallback_project_id=project["id"], + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=normalized_platform, + )["effective_policy"] + return payload + + @app.put("/v2/oneliner/governance/user/platforms/{platform}") + def put_user_platform_policy( + platform: str, + request: AgentPolicyUpsertRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + normalized_platform = _normalize_policy_platform(platform) + scope_row = _ensure_policy_scope( + scope_kind="user_platform", + subject_user_id=account["id"], + subject_project_id=project["id"], + platform=normalized_platform, + title=request.title, + summary=request.summary, + ) + payload = _create_policy_version( + scope_row, + actor_user_id=account["id"], + title=request.title, + summary=request.summary, + policy=request.policy, + effect_mode=request.effect_mode, + starts_at=request.starts_at, + ends_at=request.ends_at, + config=request.config, + reason=request.reason, + source_type="user", + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=normalized_platform, + )["effective_policy"] + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=account["id"], + action_key="publish-user-platform-policy", + summary=f"已更新 {legacy.platform_label(normalized_platform)} 用户平台策略", + details={"project_id": project["id"], "platform": normalized_platform}, + ) + return payload + + @app.get("/v2/oneliner/governance/user/platforms/{platform}/versions") + def list_user_platform_policy_versions( + platform: str, + project_id: str | None = Query(default=None), + 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) + scope_row = _policy_scope_row( + scope_kind="user_platform", + subject_user_id=account["id"], + subject_project_id=project["id"], + platform=normalized_platform, + ) + items = _list_policy_versions(scope_row) + return {"items": items, "count": len(items)} + + @app.post("/v2/oneliner/governance/user/platforms/{platform}/rollback") + def rollback_user_platform_policy( + platform: str, + request: AgentPolicyRollbackRequest, + account: dict[str, Any] = Depends(legacy.require_approved), + ) -> dict[str, Any]: + project = _resolve_project(account, request.project_id or None) + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row( + scope_kind="user_platform", + subject_user_id=account["id"], + subject_project_id=project["id"], + platform=normalized_platform, + ) + if not scope_row: + raise HTTPException(status_code=404, detail="Policy scope not found") + payload = _rollback_policy_scope( + scope_row, + actor_user_id=account["id"], + version_id=request.version_id, + reason=request.reason, + source_type="user_rollback", + ) + payload["effective_policy"] = _effective_policy_payload( + subject_account=account, + subject_project_id=project["id"], + platform=normalized_platform, + )["effective_policy"] + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=account["id"], + action_key="rollback-user-platform-policy", + summary=f"已回滚 {legacy.platform_label(normalized_platform)} 用户平台策略", + details={"project_id": project["id"], "platform": normalized_platform, "rollback_to_version_id": request.version_id}, + ) + return payload + + @app.get("/v2/admin/oneliner/governance/system/main-agent") + def get_system_main_policy(admin: dict[str, Any] = Depends(legacy.require_super_admin)) -> dict[str, Any]: + _ = admin + scope_row = _policy_scope_row(scope_kind="system_main") + return _bundle_with_versions(scope_row, fallback_kind="system_main") + + @app.put("/v2/admin/oneliner/governance/system/main-agent") + def put_system_main_policy( + request: AgentPolicyUpsertRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + scope_row = _ensure_policy_scope( + scope_kind="system_main", + title=request.title, + summary=request.summary, + ) + payload = _create_policy_version( + scope_row, + actor_user_id=admin["id"], + title=request.title, + summary=request.summary, + policy=request.policy, + effect_mode=request.effect_mode, + starts_at=request.starts_at, + ends_at=request.ends_at, + config=request.config, + reason=request.reason, + source_type="system", + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="publish-system-main-policy", + summary=f"已发布系统主 Agent 策略:{payload['scope']['title']}", + ) + return payload + + @app.get("/v2/admin/oneliner/governance/system/main-agent/versions") + def list_system_main_policy_versions(admin: dict[str, Any] = Depends(legacy.require_super_admin)) -> dict[str, Any]: + _ = admin + scope_row = _policy_scope_row(scope_kind="system_main") + items = _list_policy_versions(scope_row) + return {"items": items, "count": len(items)} + + @app.post("/v2/admin/oneliner/governance/system/main-agent/rollback") + def rollback_system_main_policy( + request: AgentPolicyRollbackRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + scope_row = _policy_scope_row(scope_kind="system_main") + if not scope_row: + raise HTTPException(status_code=404, detail="Policy scope not found") + payload = _rollback_policy_scope( + scope_row, + actor_user_id=admin["id"], + version_id=request.version_id, + reason=request.reason, + source_type="admin_rollback", + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="rollback-system-main-policy", + summary=f"已回滚系统主 Agent 策略:{payload['scope']['title']}", + details={"rollback_to_version_id": request.version_id}, + ) + return payload + + @app.get("/v2/admin/oneliner/governance/system/platforms/{platform}") + def get_system_platform_policy( + platform: str, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row(scope_kind="system_platform", platform=normalized_platform) + return _bundle_with_versions( + scope_row, + fallback_kind="system_platform", + fallback_platform=normalized_platform, + ) + + @app.put("/v2/admin/oneliner/governance/system/platforms/{platform}") + def put_system_platform_policy( + platform: str, + request: AgentPolicyUpsertRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + normalized_platform = _normalize_policy_platform(platform) + scope_row = _ensure_policy_scope( + scope_kind="system_platform", + platform=normalized_platform, + title=request.title, + summary=request.summary, + ) + payload = _create_policy_version( + scope_row, + actor_user_id=admin["id"], + title=request.title, + summary=request.summary, + policy=request.policy, + effect_mode=request.effect_mode, + starts_at=request.starts_at, + ends_at=request.ends_at, + config=request.config, + reason=request.reason, + source_type="system", + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="publish-system-platform-policy", + summary=f"已发布 {legacy.platform_label(normalized_platform)} 系统平台策略", + details={"platform": normalized_platform}, + ) + return payload + + @app.get("/v2/admin/oneliner/governance/system/platforms/{platform}/versions") + def list_system_platform_policy_versions( + platform: str, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row(scope_kind="system_platform", platform=normalized_platform) + items = _list_policy_versions(scope_row) + return {"items": items, "count": len(items)} + + @app.post("/v2/admin/oneliner/governance/system/platforms/{platform}/rollback") + def rollback_system_platform_policy( + platform: str, + request: AgentPolicyRollbackRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row(scope_kind="system_platform", platform=normalized_platform) + if not scope_row: + raise HTTPException(status_code=404, detail="Policy scope not found") + payload = _rollback_policy_scope( + scope_row, + actor_user_id=admin["id"], + version_id=request.version_id, + reason=request.reason, + source_type="admin_rollback", + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="rollback-system-platform-policy", + summary=f"已回滚 {legacy.platform_label(normalized_platform)} 系统平台策略", + details={"platform": normalized_platform, "rollback_to_version_id": request.version_id}, + ) + return payload + + @app.get("/v2/admin/oneliner/governance/overrides") + def get_admin_override_policy( + target_user_id: str = Query(default=""), + target_project_id: str = Query(default=""), + platform: str | None = Query(default=None), + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + if not target_user_id.strip(): + raise HTTPException(status_code=400, detail="target_user_id is required") + subject_account = _load_policy_subject_account(target_user_id.strip()) + subject_project_id = "" + if target_project_id.strip(): + subject_project_id = _load_policy_subject_project(user_id=subject_account["id"], project_id=target_project_id.strip())["id"] + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + payload = _bundle_with_versions( + scope_row, + fallback_kind="admin_override", + fallback_platform=normalized_platform, + fallback_user_id=subject_account["id"], + fallback_project_id=subject_project_id, + ) + payload.update( + _effective_policy_payload( + subject_account=subject_account, + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + ) + return payload + + @app.post("/v2/admin/oneliner/governance/overrides") + def put_admin_override_policy( + request: AgentPolicyUpsertRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + if not request.target_user_id.strip(): + raise HTTPException(status_code=400, detail="target_user_id is required") + subject_account = _load_policy_subject_account(request.target_user_id.strip()) + subject_project_id = "" + if request.target_project_id.strip(): + subject_project_id = _load_policy_subject_project(user_id=subject_account["id"], project_id=request.target_project_id.strip())["id"] + normalized_platform = _normalize_policy_platform(request.platform) + scope_row = _ensure_policy_scope( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + title=request.title, + summary=request.summary, + ) + payload = _create_policy_version( + scope_row, + actor_user_id=admin["id"], + title=request.title, + summary=request.summary, + policy=request.policy, + effect_mode=request.effect_mode, + starts_at=request.starts_at, + ends_at=request.ends_at, + config=request.config, + reason=request.reason, + source_type="admin_override", + ) + payload.update( + _effective_policy_payload( + subject_account=subject_account, + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="publish-admin-override-policy", + summary=f"已为 {subject_account.get('username') or subject_account['id']} 发布管理员覆盖策略", + details={"target_user_id": subject_account["id"], "target_project_id": subject_project_id, "platform": normalized_platform}, + ) + return payload + + @app.get("/v2/admin/oneliner/governance/overrides/versions") + def list_admin_override_versions( + target_user_id: str = Query(default=""), + target_project_id: str = Query(default=""), + platform: str | None = Query(default=None), + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + _ = admin + if not target_user_id.strip(): + raise HTTPException(status_code=400, detail="target_user_id is required") + subject_account = _load_policy_subject_account(target_user_id.strip()) + subject_project_id = "" + if target_project_id.strip(): + subject_project_id = _load_policy_subject_project(user_id=subject_account["id"], project_id=target_project_id.strip())["id"] + normalized_platform = _normalize_policy_platform(platform) + scope_row = _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + items = _list_policy_versions(scope_row) + return {"items": items, "count": len(items)} + + @app.post("/v2/admin/oneliner/governance/overrides/rollback") + def rollback_admin_override_policy( + request: AgentPolicyRollbackRequest, + admin: dict[str, Any] = Depends(legacy.require_super_admin), + ) -> dict[str, Any]: + if not request.target_user_id.strip(): + raise HTTPException(status_code=400, detail="target_user_id is required") + subject_account = _load_policy_subject_account(request.target_user_id.strip()) + subject_project_id = "" + if request.target_project_id.strip(): + subject_project_id = _load_policy_subject_project(user_id=subject_account["id"], project_id=request.target_project_id.strip())["id"] + normalized_platform = _normalize_policy_platform(request.platform) + scope_row = _policy_scope_row( + scope_kind="admin_override", + subject_user_id=subject_account["id"], + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + if not scope_row: + raise HTTPException(status_code=404, detail="Policy scope not found") + payload = _rollback_policy_scope( + scope_row, + actor_user_id=admin["id"], + version_id=request.version_id, + reason=request.reason, + source_type="admin_rollback", + ) + payload.update( + _effective_policy_payload( + subject_account=subject_account, + subject_project_id=subject_project_id, + platform=normalized_platform, + ) + ) + payload["audit"] = _log_policy_audit( + scope_id=payload["scope"]["id"], + version_id=(payload.get("current_version") or {}).get("id", ""), + actor_user_id=admin["id"], + action_key="rollback-admin-override-policy", + summary=f"已回滚 {subject_account.get('username') or subject_account['id']} 的管理员覆盖策略", + details={"target_user_id": subject_account["id"], "target_project_id": subject_project_id, "platform": normalized_platform, "rollback_to_version_id": request.version_id}, + ) + return payload + @app.get("/v2/tenant/quota") def get_tenant_quota( project_id: str | None = Query(default=None), diff --git a/docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md b/docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md new file mode 100644 index 0000000..6b1f3eb --- /dev/null +++ b/docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md @@ -0,0 +1,284 @@ +# Main Agent Governance Foundation Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the first production-ready governance foundation for StoryForge main-agent policy layers, versioning, admin overrides, rollback, and minimal governance UI. + +**Architecture:** Add dedicated governance tables and endpoints inside `oneliner_features.py`, compute effective policy layers at runtime for OneLiner context, then expose a minimal read/write UI in the existing Agent page and Admin Workbench without redesigning the shell. + +**Tech Stack:** FastAPI, SQLite, existing StoryForge Web V4 vanilla JS, Node test runner, Python unittest + +--- + +### Task 1: Spec + plan docs + +**Files:** +- Create: `docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md` +- Create: `docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md` + +- [ ] **Step 1: Save the approved design** + +Write the governance design into the spec file above. + +- [ ] **Step 2: Save this implementation plan** + +Write this plan file and keep it committed with the implementation. + +- [ ] **Step 3: Commit docs checkpoint** + +```bash +git add docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md +git commit -m "docs: add main agent governance foundation spec" +``` + +### Task 2: Add failing backend governance tests + +**Files:** +- Create: `tests/test_main_agent_governance.py` +- Modify: `tests/test_production_baseline.py` + +- [ ] **Step 1: Write failing tests for scope creation and runtime layering** + +Add tests that verify: +- system default policy can be written and read +- user global policy overrides system default +- user platform policy overrides user global for one platform +- admin override wins over user layers +- rollback creates a new version instead of mutating history + +- [ ] **Step 2: Run the failing test file** + +Run: + +```bash +python3 -m unittest tests.test_main_agent_governance -v +``` + +Expected: failures because governance tables and endpoints do not exist yet. + +### Task 3: Add backend schema and payload helpers + +**Files:** +- Modify: `collector-service/app/oneliner_features.py` + +- [ ] **Step 1: Add schema tables** + +Add table creation SQL for: +- `agent_policy_scopes` +- `agent_policy_versions` +- `agent_policy_effectivity` +- `agent_policy_audit_logs` + +- [ ] **Step 2: Add policy helper functions** + +Implement helpers for: +- scope payload +- version payload +- audit payload +- system scope ensure +- current active version lookup +- effective layer merge + +- [ ] **Step 3: Re-run failing governance tests** + +Run: + +```bash +python3 -m unittest tests.test_main_agent_governance -v +``` + +Expected: some tests still fail because endpoints are missing, but schema-related failures should move forward. + +### Task 4: Add governance write/read endpoints + +**Files:** +- Modify: `collector-service/app/oneliner_features.py` + +- [ ] **Step 1: Add user-side endpoints** + +Implement: +- `GET /v2/oneliner/governance/effective` +- `GET /v2/oneliner/governance/user/global` +- `PUT /v2/oneliner/governance/user/global` +- `GET /v2/oneliner/governance/user/global/versions` +- `POST /v2/oneliner/governance/user/global/rollback` +- `GET /v2/oneliner/governance/user/platforms/{platform}` +- `PUT /v2/oneliner/governance/user/platforms/{platform}` +- `GET /v2/oneliner/governance/user/platforms/{platform}/versions` +- `POST /v2/oneliner/governance/user/platforms/{platform}/rollback` + +- [ ] **Step 2: Add admin-side endpoints** + +Implement: +- `GET /v2/admin/oneliner/governance/system/main-agent` +- `PUT /v2/admin/oneliner/governance/system/main-agent` +- `GET /v2/admin/oneliner/governance/system/main-agent/versions` +- `POST /v2/admin/oneliner/governance/system/main-agent/rollback` +- `GET /v2/admin/oneliner/governance/system/platforms/{platform}` +- `PUT /v2/admin/oneliner/governance/system/platforms/{platform}` +- `GET /v2/admin/oneliner/governance/system/platforms/{platform}/versions` +- `POST /v2/admin/oneliner/governance/system/platforms/{platform}/rollback` +- `GET /v2/admin/oneliner/governance/overrides` +- `POST /v2/admin/oneliner/governance/overrides` +- `GET /v2/admin/oneliner/governance/overrides/versions` +- `POST /v2/admin/oneliner/governance/overrides/rollback` + +- [ ] **Step 3: Add audit logging inside every governance mutation** + +Record actor, target, scope, version, reason, and rollback source where relevant. + +- [ ] **Step 4: Run governance backend tests** + +Run: + +```bash +python3 -m unittest tests.test_main_agent_governance -v +``` + +Expected: backend governance tests pass. + +### Task 5: Connect runtime layering into OneLiner context + +**Files:** +- Modify: `collector-service/app/oneliner_features.py` +- Test: `tests/test_main_agent_governance.py` + +- [ ] **Step 1: Inject runtime policy into session context** + +Extend the OneLiner context builder so the runtime payload includes: +- effective merged policy +- ordered policy layers +- active admin override notice + +- [ ] **Step 2: Make OneLiner reply builder surface active governance context** + +Use the runtime policy payload to explain active strategy layers in the result payload, without rewriting all prompt logic. + +- [ ] **Step 3: Add tests for runtime payload** + +Verify the runtime endpoint and OneLiner context expose the merged policy stack. + +- [ ] **Step 4: Run backend tests** + +Run: + +```bash +python3 -m unittest tests.test_main_agent_governance tests.test_production_baseline -v +``` + +Expected: pass. + +### Task 6: Add minimal governance UI loading and rendering + +**Files:** +- Modify: `web/storyforge-web-v4/assets/app.js` +- Modify: `web/storyforge-web-v4/tests/workbench-pages.test.mjs` + +- [ ] **Step 1: Write failing frontend tests** + +Add assertions that: +- Agent workspace references effective policy summary +- Admin Workbench Agent governance tab references system policy, user overrides, and audit history + +- [ ] **Step 2: Run frontend tests and verify failure** + +Run: + +```bash +node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs +``` + +Expected: fail on missing governance UI text and loaders. + +- [ ] **Step 3: Load governance payloads in app state** + +Add app state fields and data loading for: +- current runtime policy +- current user version history +- admin governance overview + +- [ ] **Step 4: Render minimal governance panels** + +Render: +- user-side policy summary + version list in `Agent -> 当前 Agent 工作台` +- admin-side system default, user override, audit summary in `管理员配置台 -> Agent 治理` + +- [ ] **Step 5: Re-run frontend tests** + +Run: + +```bash +node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs +node --check web/storyforge-web-v4/assets/app.js +``` + +Expected: pass. + +### Task 7: Add minimal edit flows for first batch + +**Files:** +- Modify: `web/storyforge-web-v4/assets/app.js` +- Modify: `web/storyforge-web-v4/tests/workbench-pages.test.mjs` + +- [ ] **Step 1: Add user edit entrypoints** + +Provide modal actions for: +- update user global strategy +- update current platform strategy + +- [ ] **Step 2: Add admin edit entrypoints** + +Provide modal actions for: +- update system default main-agent strategy +- update system default platform strategy +- update admin override strategy for selected user/platform +- rollback selected scope version + +- [ ] **Step 3: Keep first batch UI intentionally small** + +Do not build a full-blown designer. Use the existing modal patterns with JSON textarea + summary/reason fields if needed. + +- [ ] **Step 4: Re-run frontend tests** + +Run: + +```bash +node --test web/storyforge-web-v4/tests/workbench-pages.test.mjs +``` + +Expected: pass. + +### Task 8: Full verification, deploy, and publish + +**Files:** +- Modify as needed from previous tasks only + +- [ ] **Step 1: Run full repo checks** + +```bash +python3 -m unittest tests.test_platform_contracts tests.test_production_baseline tests.test_main_agent_governance -v +node --test web/storyforge-web-v4/tests/dashboard-home.test.mjs web/storyforge-web-v4/tests/workbench-pages.test.mjs +node --check web/storyforge-web-v4/assets/app.js +python3 -m compileall collector-service/app tests +git diff --check +``` + +- [ ] **Step 2: Deploy to fnOS** + +```bash +bash scripts/deploy_fnos_storyforge_lan_stack.sh +``` + +- [ ] **Step 3: Run fnOS smoke** + +```bash +bash scripts/smoke_fnos_storyforge_lan.sh +``` + +- [ ] **Step 4: Commit and push** + +```bash +git add collector-service/app/oneliner_features.py web/storyforge-web-v4/assets/app.js tests/test_main_agent_governance.py tests/test_production_baseline.py web/storyforge-web-v4/tests/workbench-pages.test.mjs docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md docs/superpowers/plans/2026-03-29-main-agent-governance-foundation.md +git commit -m "feat: add main agent governance foundation" +git push gitea codex/storyforge-live-orchestrator-sync-20260323 +``` diff --git a/docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md b/docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md new file mode 100644 index 0000000..dec6b45 --- /dev/null +++ b/docs/superpowers/specs/2026-03-29-main-agent-governance-foundation-design.md @@ -0,0 +1,283 @@ +# StoryForge 主 Agent 治理底座设计 + +- 日期:2026-03-29 +- 状态:已确认,直接进入实现 +- 范围:`collector-service`、`web/storyforge-web-v4` + +## 1. 背景 + +当前仓库已经有 `OneLiner` 主 Agent、平台 Agent、平台记忆、平台技能、额度和管理员运维面板,但真正决定长期生命力的治理层还没有闭环。 + +现状问题: + +- `OneLiner` 能承接对话,但“系统默认策略 / 用户策略 / 管理员覆盖”还没有正式的数据模型。 +- 平台 Agent 已经有 profile、memory、skill,但用户策略变更还不能以“全局 / 单平台 / 覆盖层 / 历史版本”的方式正式管理。 +- 管理员配置台已经有入口,但还没有系统级主 Agent 策略、用户策略审计和覆盖管理的正式控制面。 +- 首页和工作台已经开始围绕“交给主 Agent”组织,但推荐逻辑和执行上下文还缺少一个可审计的策略底座。 + +## 2. 目标 + +- 建立主 Agent 治理底座的数据模型,不再把策略历史继续塞进旧 JSON。 +- 支持三层用户相关策略: + - 用户全局策略 + - 用户平台策略 + - 管理员覆盖策略 +- 保留系统默认策略层: + - 系统默认主 Agent 策略 + - 系统默认平台 Agent 策略 +- 所有策略都具备: + - 版本 + - 生效范围 + - 状态 + - 原因 + - 操作者 + - 回滚来源 +- 主 Agent 运行时可以读取当前用户的有效策略叠加结果。 +- Web 先补最小可用治理 UI: + - 用户侧可查看当前有效策略与版本历史 + - 管理员侧可查看系统默认、用户覆盖、管理员覆盖与回滚记录 + +## 3. 非目标 + +- 本轮不重做主 Agent 全部 UI 体验。 +- 本轮不做完整自然语言“自动解析策略修改”闭环。 +- 本轮不把所有平台专属策略细节全部产品化。 +- 本轮不引入新的数据库或事件总线。 + +## 4. 数据模型 + +### 4.1 新表 + +新增四张表: + +1. `agent_policy_scopes` +- 定义策略作用域 +- 核心字段: + - `id` + - `scope_kind`:`system_main` / `system_platform` / `user_global` / `user_platform` / `admin_override` + - `subject_user_id` + - `subject_project_id` + - `platform` + - `status` + - `title` + - `summary` + - `current_version_id` + - `created_at` + - `updated_at` + +2. `agent_policy_versions` +- 每次修改都生成一个新版本 +- 核心字段: + - `id` + - `scope_id` + - `scope_kind` + - `subject_user_id` + - `subject_project_id` + - `platform` + - `version_no` + - `title` + - `policy_json` + - `summary` + - `reason` + - `source_type` + - `rollback_from_version_id` + - `actor_user_id` + - `created_at` + +3. `agent_policy_effectivity` +- 描述某版本当前是否生效,以及生效范围 +- 核心字段: + - `id` + - `scope_id` + - `version_id` + - `effect_mode`:`ongoing` / `scheduled` + - `starts_at` + - `ends_at` + - `status` + - `config_json` + - `created_at` + - `updated_at` + +4. `agent_policy_audit_logs` +- 记录查看、创建、发布、代改、覆盖、回滚等动作 +- 核心字段: + - `id` + - `scope_id` + - `version_id` + - `action_key` + - `actor_user_id` + - `summary` + - `details_json` + - `created_at` + +### 4.2 运行时叠加顺序 + +运行时有效策略按以下顺序叠加: + +1. 系统默认主 Agent / 平台 Agent 策略 +2. 用户全局策略 +3. 用户平台策略 +4. 管理员覆盖策略 + +后者覆盖前者同名字段。 + +## 5. 权限边界 + +### 5.1 普通用户 + +- 只能读取和更新自己的: + - 用户全局策略 + - 用户平台策略 +- 只能查看自己的版本历史 +- 不能修改系统默认策略 +- 不能查看其他用户策略 + +### 5.2 超级管理员 + +- 可查看和修改系统默认主 Agent 策略 +- 可查看和修改系统默认平台 Agent 策略 +- 可查看任意用户策略 +- 可创建管理员覆盖策略 +- 可对用户策略或管理员覆盖进行回滚 +- 管理员操作不会抹掉用户自己的历史版本,只会形成更高优先级覆盖层 + +### 5.3 Agent 读取边界 + +- 主 Agent 可读取当前用户: + - 用户全局策略 + - 用户平台策略 + - 当前平台 Agent 记忆与技能 +- 平台 Agent 只能读取当前用户且当前平台相关的策略、记忆和技能 + +## 6. 回滚规则 + +- 回滚不是修改旧版本。 +- 回滚操作会生成一个新版本。 +- 新版本记录 `rollback_from_version_id`。 +- 同时写入审计日志。 + +## 7. 后端接口 + +### 7.1 用户侧 + +- `GET /v2/oneliner/governance/effective` + - 返回当前用户在当前项目、当前平台下的有效策略叠加结果 +- `GET /v2/oneliner/governance/user/global` + - 返回当前用户全局策略 bundle 和历史统计 +- `PUT /v2/oneliner/governance/user/global` + - 更新当前用户全局策略 +- `GET /v2/oneliner/governance/user/global/versions` + - 返回当前用户全局策略历史 +- `POST /v2/oneliner/governance/user/global/rollback` + - 回滚当前用户全局策略到历史版本 +- `GET /v2/oneliner/governance/user/platforms/{platform}` + - 返回当前用户单平台策略 bundle 和历史统计 +- `PUT /v2/oneliner/governance/user/platforms/{platform}` + - 更新当前用户单平台策略 +- `GET /v2/oneliner/governance/user/platforms/{platform}/versions` + - 返回当前用户单平台策略历史 +- `POST /v2/oneliner/governance/user/platforms/{platform}/rollback` + - 回滚当前用户单平台策略到历史版本 + +### 7.2 管理员侧 + +- `GET /v2/admin/oneliner/governance/system/main-agent` + - 读取系统主 Agent 策略 bundle +- `PUT /v2/admin/oneliner/governance/system/main-agent` + - 更新系统主 Agent 策略 +- `GET /v2/admin/oneliner/governance/system/main-agent/versions` + - 读取系统主 Agent 历史 +- `POST /v2/admin/oneliner/governance/system/main-agent/rollback` + - 回滚系统主 Agent 策略 +- `GET /v2/admin/oneliner/governance/system/platforms/{platform}` + - 读取系统平台策略 bundle +- `PUT /v2/admin/oneliner/governance/system/platforms/{platform}` + - 更新系统平台策略 +- `GET /v2/admin/oneliner/governance/system/platforms/{platform}/versions` + - 读取系统平台策略历史 +- `POST /v2/admin/oneliner/governance/system/platforms/{platform}/rollback` + - 回滚系统平台策略 +- `GET /v2/admin/oneliner/governance/overrides` + - 读取某个用户/项目/平台的管理员覆盖策略 bundle +- `POST /v2/admin/oneliner/governance/overrides` + - 发布管理员覆盖策略 +- `GET /v2/admin/oneliner/governance/overrides/versions` + - 读取管理员覆盖策略历史 +- `POST /v2/admin/oneliner/governance/overrides/rollback` + - 回滚管理员覆盖策略 + +## 8. 运行时接入 + +### 8.1 主 Agent + +`OneLiner` 生成上下文时,补充: + +- 当前用户有效策略栈 +- 当前命中的管理员覆盖层 +- 当前平台有效策略 + +这些信息进入: + +- `runtime_policy` +- `policy_layers` +- `admin_override_notice` + +用于: + +- 主 Agent 回复中解释“为什么按这个策略执行” +- 后续首页动作推荐引用同一份有效策略 + +### 8.2 前端 + +#### 用户侧 Agent 页面 + +在 `Agent -> 当前 Agent 工作台` 增加: + +- 当前有效主 Agent 策略摘要 +- 当前用户全局策略 +- 当前平台策略入口 +- 最近版本历史 + +#### 管理员配置台 + +在 `管理员配置台 -> Agent 治理` 增加: + +- 系统默认主 Agent 策略 +- 系统默认平台 Agent 策略 +- 当前项目用户策略总览 +- 管理员覆盖层 +- 最近策略审计 + +## 9. 测试 + +### 9.1 后端 + +- schema 初始化测试 +- 用户全局策略写入与读取测试 +- 用户平台策略覆盖测试 +- 管理员覆盖优先级测试 +- 回滚生成新版本测试 +- 审计日志记录测试 +- `runtime` 叠加顺序测试 + +### 9.2 前端 + +- Agent 页包含治理摘要入口 +- 管理员台包含治理区块 +- 版本历史和覆盖标记文案存在 + +## 10. 第一批实现范围 + +本轮只做: + +- 治理底座表结构 +- 核心策略接口 +- 主 Agent 运行时接入 +- 最小治理 UI +- 基本测试 + +不做: + +- 复杂自然语言自动改策略 +- 多项目多阶段高级调度面板 +- 完整的对比 diff 可视化 diff --git a/scripts/check_repo_baseline.sh b/scripts/check_repo_baseline.sh index 4442d17..d42055e 100755 --- a/scripts/check_repo_baseline.sh +++ b/scripts/check_repo_baseline.sh @@ -16,13 +16,19 @@ need_cmd node cd "$ROOT" -echo "[1/5] compile collector-service" +echo "[1/6] compile collector-service" python3 -m compileall collector-service/app >/dev/null -echo "[2/5] validate docker compose" +echo "[2/6] run backend contract tests" +python3 -m unittest \ + tests.test_main_agent_governance \ + tests.test_platform_contracts \ + tests.test_production_baseline >/dev/null + +echo "[3/6] validate docker compose" docker compose config >/dev/null -echo "[3/5] validate n8n workflows" +echo "[4/6] validate n8n workflows" python3 - <<'PY' import json import pathlib @@ -33,13 +39,13 @@ for path in sorted(pathlib.Path("n8n/workflows").glob("*.json")): print(f"workflow ok: {path.name}") PY -echo "[4/5] validate web scripts" +echo "[5/6] validate web scripts" for file in web/storyforge-web-v4/assets/app.js web/storyforge-web-v4/assets/storyforge-*.js; do node --check "$file" done node --check scripts/douyin-browser-capture/control_panel.mjs -echo "[5/5] validate homepage and workbench tests" +echo "[6/6] validate homepage and workbench tests" node --test \ web/storyforge-web-v4/tests/dashboard-home.test.mjs \ web/storyforge-web-v4/tests/workbench-pages.test.mjs diff --git a/tests/test_main_agent_governance.py b/tests/test_main_agent_governance.py new file mode 100644 index 0000000..3ce522e --- /dev/null +++ b/tests/test_main_agent_governance.py @@ -0,0 +1,380 @@ +from __future__ import annotations + +import importlib +import os +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + +from fastapi.testclient import TestClient + + +ROOT = Path(__file__).resolve().parents[1] +APP_ROOT = ROOT / "collector-service" +if str(APP_ROOT) not in sys.path: + sys.path.insert(0, str(APP_ROOT)) + + +class MainAgentGovernanceTests(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.tempdir = tempfile.TemporaryDirectory() + temp_root = Path(cls.tempdir.name) + os.environ["DATA_DIR"] = str(temp_root / "data") + os.environ["DATABASE_PATH"] = str(temp_root / "data" / "storyforge.db") + os.environ["DOWNLOADS_DIR"] = str(temp_root / "downloads") + os.environ["JOBS_DIR"] = str(temp_root / "jobs") + os.environ["MODELS_DIR"] = str(temp_root / "models") + os.environ["ORCHESTRATOR_SHARED_SECRET"] = "test-secret" + os.environ["WEB_AUTOLOGIN_ENABLED"] = "0" + os.environ.setdefault("BOOTSTRAP_SUPERADMIN_USERNAME", "") + os.environ.setdefault("BOOTSTRAP_SUPERADMIN_PASSWORD", "") + + cls.db_module = importlib.reload(importlib.import_module("app.database")) + cls.core = importlib.reload(importlib.import_module("app.core_main")) + cls.app_main = importlib.reload(importlib.import_module("app.main")) + cls.core.db.init_schema() + cls.client = TestClient(cls.app_main.app) + + @classmethod + def tearDownClass(cls) -> None: + cls.client.close() + cls.tempdir.cleanup() + + def setUp(self) -> None: + self._clear_tables() + self.ctx = self._seed_accounts() + + def _clear_tables(self) -> None: + tables = [ + "agent_policy_audit_logs", + "agent_policy_effectivity", + "agent_policy_versions", + "agent_policy_scopes", + "agent_skill_versions", + "agent_skills", + "agent_memories", + "platform_agent_profiles", + "oneliner_messages", + "oneliner_sessions", + "oneliner_profiles", + "auth_tokens", + "projects", + "accounts", + "model_profiles", + ] + for table in tables: + try: + self.core.db.execute(f"DELETE FROM {table}") + except Exception: + continue + + def _seed_accounts(self) -> dict[str, Any]: + now = self.db_module.utc_now() + admin_id = "acct_admin" + member_id = "acct_member" + project_id = "proj_member" + model_id = "model_default" + admin_token = "token_admin" + member_token = "token_member" + + self.core.db.execute( + """ + INSERT INTO accounts ( + id, username, password_hash, password_salt, display_name, role, approval_status, + approved_by, approved_at, preferred_analysis_model_id, created_at, updated_at + ) VALUES (?, ?, 'hash', 'salt', ?, ?, 'approved', ?, ?, ?, ?, ?) + """, + (admin_id, "admin", "Admin", "super_admin", admin_id, now, model_id, now, now), + ) + self.core.db.execute( + """ + INSERT INTO accounts ( + id, username, password_hash, password_salt, display_name, role, approval_status, + approved_by, approved_at, preferred_analysis_model_id, created_at, updated_at + ) VALUES (?, ?, 'hash', 'salt', ?, ?, 'approved', ?, ?, ?, ?, ?) + """, + (member_id, "member", "Member", "operator", admin_id, now, model_id, now, now), + ) + self.core.db.execute( + """ + INSERT INTO projects (id, user_id, name, description, created_at, updated_at) + VALUES (?, ?, ?, '', ?, ?) + """, + (project_id, member_id, "Member Project", now, now), + ) + self.core.db.execute( + """ + INSERT INTO model_profiles ( + id, owner_account_id, name, provider, base_url, api_key, model_name, + is_system, is_default, created_at, updated_at + ) VALUES (?, NULL, 'Default Model', 'openai_compat', 'http://127.0.0.1:8317/v1', '', 'GLM-5', 1, 1, ?, ?) + """, + (model_id, now, now), + ) + self.core.db.execute( + "INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)", + (admin_token, admin_id, now), + ) + self.core.db.execute( + "INSERT INTO auth_tokens (token, account_id, created_at) VALUES (?, ?, ?)", + (member_token, member_id, now), + ) + return { + "admin_id": admin_id, + "member_id": member_id, + "project_id": project_id, + "admin_headers": {"Authorization": f"Bearer {admin_token}"}, + "member_headers": {"Authorization": f"Bearer {member_token}"}, + } + + def test_effective_policy_merges_system_user_global_and_platform_layers(self) -> None: + system_response = self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "System main agent", + "summary": "Default baseline", + "policy": { + "tone": {"style": "default"}, + "homepage": {"focus": "ops"}, + "actions": {"max_cards": 3}, + }, + "reason": "seed system baseline", + }, + ) + self.assertEqual(system_response.status_code, 200, system_response.text) + + global_response = self.client.put( + "/v2/oneliner/governance/user/global", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Member global strategy", + "summary": "Personal operating style", + "policy": { + "tone": {"style": "analytical"}, + "memory": {"default_window": "30d"}, + "actions": {"max_cards": 2}, + }, + "reason": "personalize global defaults", + }, + ) + self.assertEqual(global_response.status_code, 200, global_response.text) + + platform_response = self.client.put( + "/v2/oneliner/governance/user/platforms/douyin", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Douyin strategy", + "summary": "Tighter benchmark workflow", + "policy": { + "actions": {"max_cards": 1}, + "douyin": {"benchmark_mode": "strict"}, + }, + "reason": "tighten douyin execution", + }, + ) + self.assertEqual(platform_response.status_code, 200, platform_response.text) + + effective_response = self.client.get( + "/v2/oneliner/governance/effective", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(effective_response.status_code, 200, effective_response.text) + payload = effective_response.json() + self.assertEqual( + [item["scope_kind"] for item in payload["layers"]], + ["system_main", "user_global", "user_platform"], + ) + self.assertEqual(payload["effective_policy"]["tone"]["style"], "analytical") + self.assertEqual(payload["effective_policy"]["homepage"]["focus"], "ops") + self.assertEqual(payload["effective_policy"]["actions"]["max_cards"], 1) + self.assertEqual(payload["effective_policy"]["douyin"]["benchmark_mode"], "strict") + + def test_admin_override_takes_precedence_in_effective_policy(self) -> None: + self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "System main agent", + "policy": {"actions": {"max_cards": 3}}, + "reason": "seed baseline", + }, + ) + self.client.put( + "/v2/oneliner/governance/user/platforms/douyin", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Douyin strategy", + "policy": {"actions": {"max_cards": 1}}, + "reason": "tighten douyin execution", + }, + ) + + override_response = 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": "Safety override", + "summary": "Require review after recent drift", + "policy": { + "actions": {"max_cards": 5}, + "guardrails": {"require_admin_review": True}, + }, + "reason": "contain unexpected drift", + }, + ) + self.assertEqual(override_response.status_code, 200, override_response.text) + + effective_response = self.client.get( + "/v2/oneliner/governance/effective", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(effective_response.status_code, 200, effective_response.text) + payload = effective_response.json() + self.assertEqual(payload["layers"][-1]["scope_kind"], "admin_override") + self.assertEqual(payload["effective_policy"]["actions"]["max_cards"], 5) + self.assertTrue(payload["effective_policy"]["guardrails"]["require_admin_review"]) + + def test_admin_override_without_target_project_applies_to_member_projects(self) -> None: + override_response = self.client.post( + "/v2/admin/oneliner/governance/overrides", + headers=self.ctx["admin_headers"], + json={ + "target_user_id": self.ctx["member_id"], + "title": "Global safety override", + "summary": "Apply guardrails across every project", + "policy": { + "guardrails": {"require_admin_review": True}, + "actions": {"max_cards": 4}, + }, + "reason": "global containment", + }, + ) + self.assertEqual(override_response.status_code, 200, override_response.text) + + effective_response = self.client.get( + "/v2/oneliner/governance/effective", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(effective_response.status_code, 200, effective_response.text) + payload = effective_response.json() + self.assertEqual(payload["layers"][-1]["scope_kind"], "admin_override") + self.assertEqual(payload["effective_policy"]["actions"]["max_cards"], 4) + self.assertTrue(payload["effective_policy"]["guardrails"]["require_admin_review"]) + + def test_effective_policy_skips_future_scheduled_versions_until_window_opens(self) -> None: + first_response = self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "Current system baseline", + "summary": "Active now", + "policy": {"tone": {"style": "default"}}, + "reason": "baseline", + }, + ) + self.assertEqual(first_response.status_code, 200, first_response.text) + + second_response = self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["admin_headers"], + json={ + "title": "Future strategy", + "summary": "Should not be active yet", + "policy": {"tone": {"style": "future"}}, + "effect_mode": "scheduled", + "starts_at": "2099-01-01T00:00:00Z", + "reason": "future rollout", + }, + ) + self.assertEqual(second_response.status_code, 200, second_response.text) + + effective_response = self.client.get( + "/v2/oneliner/governance/effective", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"], "platform": "douyin"}, + ) + self.assertEqual(effective_response.status_code, 200, effective_response.text) + payload = effective_response.json() + self.assertEqual(payload["effective_policy"]["tone"]["style"], "default") + self.assertEqual(payload["layers"][0]["current_version"]["title"], "Current system baseline") + + def test_user_global_versions_support_rollback_by_creating_new_version(self) -> None: + first_response = self.client.put( + "/v2/oneliner/governance/user/global", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Global strategy v1", + "policy": {"tone": {"style": "analytical"}}, + "reason": "first pass", + }, + ) + self.assertEqual(first_response.status_code, 200, first_response.text) + first_version_id = first_response.json()["current_version"]["id"] + + second_response = self.client.put( + "/v2/oneliner/governance/user/global", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "title": "Global strategy v2", + "policy": {"tone": {"style": "decisive"}}, + "reason": "refine tone", + }, + ) + self.assertEqual(second_response.status_code, 200, second_response.text) + + versions_before = self.client.get( + "/v2/oneliner/governance/user/global/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(versions_before.status_code, 200, versions_before.text) + self.assertEqual(versions_before.json()["count"], 2) + + rollback_response = self.client.post( + "/v2/oneliner/governance/user/global/rollback", + headers=self.ctx["member_headers"], + json={ + "project_id": self.ctx["project_id"], + "version_id": first_version_id, + "reason": "restore best baseline", + }, + ) + self.assertEqual(rollback_response.status_code, 200, rollback_response.text) + rollback_payload = rollback_response.json() + self.assertEqual(rollback_payload["current_version"]["rollback_from_version_id"], first_version_id) + self.assertEqual(rollback_payload["effective_policy"]["tone"]["style"], "analytical") + + versions_after = self.client.get( + "/v2/oneliner/governance/user/global/versions", + headers=self.ctx["member_headers"], + params={"project_id": self.ctx["project_id"]}, + ) + self.assertEqual(versions_after.status_code, 200, versions_after.text) + self.assertEqual(versions_after.json()["count"], 3) + + def test_non_admin_cannot_change_system_defaults(self) -> None: + response = self.client.put( + "/v2/admin/oneliner/governance/system/main-agent", + headers=self.ctx["member_headers"], + json={ + "title": "Not allowed", + "policy": {"tone": {"style": "rogue"}}, + "reason": "should be blocked", + }, + ) + self.assertEqual(response.status_code, 403, response.text) diff --git a/web/storyforge-web-v4/assets/app.js b/web/storyforge-web-v4/assets/app.js index 8cc3ee5..5f39e05 100644 --- a/web/storyforge-web-v4/assets/app.js +++ b/web/storyforge-web-v4/assets/app.js @@ -54,6 +54,11 @@ const appState = { onelinerMessages: [], onelinerActionRegistry: [], platformAgents: [], + onelinerGovernanceEffective: null, + userGlobalPolicy: null, + userCurrentPlatformPolicy: null, + adminSystemMainPolicy: null, + adminSystemPlatformPolicies: [], tenantQuota: null, tenantUsage: null, adminOpsOverview: null, @@ -1038,6 +1043,9 @@ function renderOneLinerUi() { const status = document.querySelector('[data-role="oneliner-status"]'); const input = document.querySelector('[data-role="oneliner-input"]'); const profile = appState.onelinerProfile; + const effective = appState.onelinerGovernanceEffective; + const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || ""); + const layers = safeArray(effective?.layers); if (fab) { fab.hidden = !appState.session; } @@ -1050,6 +1058,11 @@ function renderOneLinerUi() { ${escapeHtml(formatNumber(safeArray(appState.platformAgents).length))} 个平台 Agent
${escapeHtml(profile?.long_term_goal || "当前没有设置长期目标。你可以先在这里说目标,后续再逐步产品化。")}
+
+ ${layers.map((layer) => `${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || effective?.platform || ""))}`).join("") || `还没有策略层`} + ${highlights.map((item) => `${escapeHtml(item)}`).join("")} + 我的策略 +
`; } if (sessions) sessions.innerHTML = renderOneLinerSessionTabs(); @@ -1243,6 +1256,11 @@ async function logoutSession() { appState.onelinerMessages = []; appState.onelinerActionRegistry = []; appState.platformAgents = []; + appState.onelinerGovernanceEffective = null; + appState.userGlobalPolicy = null; + appState.userCurrentPlatformPolicy = null; + appState.adminSystemMainPolicy = null; + appState.adminSystemPlatformPolicies = []; appState.tenantQuota = null; appState.tenantUsage = null; appState.adminOpsOverview = null; @@ -1284,16 +1302,22 @@ async function loadStorageStatus(projectId = "") { async function loadAgentControlSurfaces(projectId = "") { const normalizedProjectId = projectId || getOneLinerProjectId(); + const governancePlatform = normalizePlatformValue(getPreferredPlatform(), "douyin"); const supportsOneLinerProfile = backendSupports("/v2/oneliner/profile"); const supportsOneLinerSessions = backendSupports("/v2/oneliner/sessions"); const supportsActionRegistry = backendSupports("/v2/oneliner/action-registry"); const supportsPlatformAgents = backendSupports("/v2/platform-agents"); + 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 supportsAdminSystemMainPolicy = backendSupports("/v2/admin/oneliner/governance/system/main-agent"); + const supportsAdminSystemPlatformPolicy = backendSupports("/v2/admin/oneliner/governance/system/platforms/{platform}"); 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, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ + const [profile, sessionsPayload, actionRegistryPayload, platformAgentsPayload, governanceEffective, userGlobalPolicy, userCurrentPlatformPolicy, adminSystemMainPolicy, adminSystemPlatformPolicies, tenantQuota, tenantUsage, adminOpsOverview, adminFixRunsPayload] = await Promise.all([ supportsOneLinerProfile ? storyforgeFetch(`/v2/oneliner/profile?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1306,6 +1330,23 @@ async function loadAgentControlSurfaces(projectId = "") { supportsPlatformAgents ? storyforgeFetch(`/v2/platform-agents?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => ({ items: [] })) : Promise.resolve({ items: [] }), + supportsGovernanceEffective + ? storyforgeFetch(`/v2/oneliner/governance/effective?project_id=${encodeURIComponent(normalizedProjectId)}&platform=${encodeURIComponent(governancePlatform)}`).catch(() => null) + : Promise.resolve(null), + supportsUserGlobalPolicy + ? storyforgeFetch(`/v2/oneliner/governance/user/global?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) + : Promise.resolve(null), + supportsUserPlatformPolicy + ? storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(governancePlatform)}?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) + : Promise.resolve(null), + supportsAdminSystemMainPolicy && isSuperAdmin() + ? storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent").catch(() => null) + : Promise.resolve(null), + supportsAdminSystemPlatformPolicy && isSuperAdmin() + ? Promise.all(ACTIVE_PLATFORMS.map((item) => + storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(item.value)}`).catch(() => null) + )) + : Promise.resolve([]), supportsTenantQuota ? storyforgeFetch(`/v2/tenant/quota?project_id=${encodeURIComponent(normalizedProjectId)}`).catch(() => null) : Promise.resolve(null), @@ -1327,6 +1368,11 @@ async function loadAgentControlSurfaces(projectId = "") { appState.selectedOnelinerSessionId = safeArray(appState.onelinerSessions)[0]?.id || ""; } appState.platformAgents = safeArray(platformAgentsPayload?.items || platformAgentsPayload); + appState.onelinerGovernanceEffective = governanceEffective; + appState.userGlobalPolicy = userGlobalPolicy; + appState.userCurrentPlatformPolicy = userCurrentPlatformPolicy; + appState.adminSystemMainPolicy = adminSystemMainPolicy; + appState.adminSystemPlatformPolicies = safeArray(adminSystemPlatformPolicies); appState.tenantQuota = tenantQuota; appState.tenantUsage = tenantUsage; appState.adminOpsOverview = adminOpsOverview; @@ -3138,6 +3184,91 @@ function renderTenantQuotaPanel() { `; } +function policyScopeTagLabel(scopeKind, platform = "") { + if (scopeKind === "system_main") return "系统默认"; + if (scopeKind === "system_platform") return `${platformLabel(platform || "douyin")} 默认`; + if (scopeKind === "user_global") return "我的全局"; + if (scopeKind === "user_platform") return `${platformLabel(platform || "douyin")} 我的策略`; + if (scopeKind === "admin_override") return "管理员覆盖"; + return "策略层"; +} + +function summarizePolicyHighlights(policy = {}, platform = "") { + const items = []; + if (policy?.tone?.style) items.push(`语气 ${policy.tone.style}`); + if (policy?.actions?.max_cards != null) items.push(`首页动作 ${formatNumber(policy.actions.max_cards)} 条`); + if (policy?.memory?.default_window) items.push(`记忆窗口 ${policy.memory.default_window}`); + if (platform && policy?.[platform]?.benchmark_mode) items.push(`${platformLabel(platform)} 对标 ${policy[platform].benchmark_mode}`); + if (policy?.guardrails?.require_admin_review) items.push("需管理员复核"); + return items.slice(0, 4); +} + +function renderGovernanceSummaryCard({ title, subtitle, effective, primaryAction = "", primaryLabel = "编辑策略", secondaryAction = "", secondaryLabel = "", secondaryPlatform = "" }) { + const layers = safeArray(effective?.layers); + const highlights = summarizePolicyHighlights(effective?.effective_policy || {}, effective?.platform || secondaryPlatform || ""); + return ` +
+

${escapeHtml(title)}

+

${escapeHtml(subtitle || "当前还没有策略摘要。")}

+
+ ${layers.map((layer) => `${escapeHtml(policyScopeTagLabel(layer.scope_kind, layer.scope?.platform || effective?.platform || ""))}`).join("") || `尚未发布`} + ${highlights.map((item) => `${escapeHtml(item)}`).join("")} +
+ ${(primaryAction || secondaryAction) ? ` +
+ ${primaryAction ? `${escapeHtml(primaryLabel)}` : ""} + ${secondaryAction ? `${escapeHtml(secondaryLabel)}` : ""} +
+ ` : ""} +
+ `; +} + +function renderAdminGovernanceSummaryPanel() { + const systemMain = appState.adminSystemMainPolicy; + const systemPlatforms = safeArray(appState.adminSystemPlatformPolicies); + const configuredPlatforms = systemPlatforms.filter((item) => item?.current_version); + return ` +
+
+
+

系统级主 Agent 治理

+
先管系统默认主 Agent,再按平台补默认策略,普通用户的个性化覆盖会叠加在这些底座之上。
+
+
+ 系统主 Agent ${escapeHtml(systemMain?.current_version ? "已发布" : "未发布")} + ${escapeHtml(formatNumber(configuredPlatforms.length))} 个平台默认策略 + 编辑系统主 Agent +
+
+
+
+
系统主 Agent
+
${escapeHtml(systemMain?.current_version?.summary || "还没有发布系统默认主 Agent 策略。")}
+
+ ${escapeHtml(systemMain?.current_version ? `版本 ${formatNumber(systemMain.current_version.version_no)}` : "未发布")} + 历史 ${escapeHtml(formatNumber(systemMain?.versions?.count || 0))} +
+
+ ${ACTIVE_PLATFORMS.map((platformItem) => { + const item = systemPlatforms.find((entry) => entry?.scope?.platform === platformItem.value) || null; + return ` +
+
${escapeHtml(platformItem.label)} 默认策略
+
${escapeHtml(item?.current_version?.summary || "还没有平台默认策略,当前会沿用系统主 Agent 默认。")}
+
+ ${escapeHtml(item?.current_version ? `版本 ${formatNumber(item.current_version.version_no)}` : "沿用系统默认")} + 历史 ${escapeHtml(formatNumber(item?.versions?.count || 0))} + 编辑 +
+
+ `; + }).join("")} +
+
+ `; +} + function renderPlatformAgentPanel() { const items = safeArray(appState.platformAgents); if (!items.length) { @@ -3906,7 +4037,7 @@ function renderAdminWorkbenchScreen() { : activeTab === "storage" ? renderStorageStatusPanel() : activeTab === "agents" - ? `${renderPlatformAgentPanel()}
${renderOneLinerActionRegistryPanel()}
` + ? `${renderAdminGovernanceSummaryPanel()}${renderPlatformAgentPanel()}
${renderOneLinerActionRegistryPanel()}
` : renderAdminOpsPanel()} ` @@ -4620,6 +4751,16 @@ function renderPlaybookScreen() { 编辑配置 + ${renderGovernanceSummaryCard({ + title: "我的策略与历史", + subtitle: appState.userGlobalPolicy?.current_version?.summary || "你和主 Agent 的策略对话,会先沉淀成用户全局策略,再按需要下放到单平台。", + effective: appState.onelinerGovernanceEffective, + primaryAction: "open-user-global-policy", + primaryLabel: `编辑全局策略 · 历史 ${formatNumber(appState.userGlobalPolicy?.versions?.count || 0)}`, + secondaryAction: "open-user-platform-policy", + secondaryLabel: `编辑当前平台策略 · 历史 ${formatNumber(appState.userCurrentPlatformPolicy?.versions?.count || 0)}`, + secondaryPlatform: appState.onelinerGovernanceEffective?.platform || appState.onelinerProfile?.default_platform || getPreferredPlatform() + })}
@@ -6221,12 +6362,192 @@ function openOneLinerProfileAction() { } }); appState.onelinerProfile = saved; + await loadAgentControlSurfaces(project.id); rememberAction("OneLiner 已保存", `已更新 OneLiner「${saved.display_name || "OneLiner"}」配置。`, "green", saved); renderAll(); } }); } +function parsePolicyJsonField(rawValue, label = "策略 JSON") { + const text = String(rawValue || "").trim(); + if (!text) return {}; + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new Error(`${label} 必须是 JSON 对象`); + } + return parsed; + } catch (error) { + throw new Error(`${label} 格式不正确:${error.message}`); + } +} + +function renderPolicyVersionSummary(bundle, emptyText) { + if (!bundle?.current_version) { + return ` +
+
+

还没有已发布版本

+

${escapeHtml(emptyText)}

+
+
+ `; + } + return ` +
+
+

${escapeHtml(bundle.current_version.title || bundle.scope?.title || "当前版本")}

+

${escapeHtml(bundle.current_version.summary || "当前版本还没有补摘要。")}

+
+ 版本 ${escapeHtml(formatNumber(bundle.current_version.version_no || 0))} + 历史 ${escapeHtml(formatNumber(bundle.versions?.count || 0))} + ${bundle.effectivity?.effect_mode ? `${escapeHtml(bundle.effectivity.effect_mode)}` : ""} +
+
+
+ `; +} + +function openUserGlobalPolicyAction() { + const project = requireSelectedProject(); + const bundle = appState.userGlobalPolicy || {}; + const current = bundle.current_version || {}; + openActionModal({ + title: "编辑我的全局策略", + description: "这层策略只影响你自己,会优先被主 Agent 读取,再决定是否下发到各个平台 Agent。", + submitLabel: "保存全局策略", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "你还没有发布自己的全局策略,当前会沿用系统默认和平台默认。") }, + { name: "title", label: "策略标题", value: current.title || bundle.scope?.title || "用户全局策略", placeholder: "例如:创业内容增长策略" }, + { name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这层策略主要在约束什么、优化什么" }, + { name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"tone\":{\"style\":\"analytical\"}}" }, + { name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:用户要求首页动作更聚焦,默认走分析型语气" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/oneliner/governance/user/global", { + method: "PUT", + body: { + project_id: project.id, + title: values.title || "用户全局策略", + summary: values.summary || "", + policy: parsePolicyJsonField(values.policyJson, "全局策略 JSON"), + reason: values.reason || "" + } + }); + appState.userGlobalPolicy = saved; + await loadAgentControlSurfaces(project.id); + rememberAction("我的全局策略已保存", `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +function openUserPlatformPolicyAction(platform) { + const normalizedPlatform = normalizePlatformValue(platform || getPreferredPlatform(), "douyin"); + const project = requireSelectedProject(); + const bundle = appState.userCurrentPlatformPolicy || {}; + const current = bundle.current_version || {}; + openActionModal({ + title: `编辑 ${platformLabel(normalizedPlatform)} 平台策略`, + description: "这层策略只作用于你当前项目下的单个平台,会覆盖你的全局策略,但不会影响其他平台。", + submitLabel: "保存平台策略", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "你还没有发布单平台策略,当前会沿用全局策略和系统默认。") }, + { name: "title", label: "策略标题", value: current.title || `${platformLabel(normalizedPlatform)} 用户平台策略`, placeholder: "例如:抖音对标拆解策略" }, + { name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这个平台的特殊规则和工作方式" }, + { name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"actions\":{\"max_cards\":1}}" }, + { name: "reason", label: "变更原因", type: "textarea", rows: 3, value: "", placeholder: "例如:抖音只保留 1 条首页动作,优先高分作品拆解" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch(`/v2/oneliner/governance/user/platforms/${encodeURIComponent(normalizedPlatform)}`, { + method: "PUT", + body: { + project_id: project.id, + title: values.title || `${platformLabel(normalizedPlatform)} 用户平台策略`, + summary: values.summary || "", + policy: parsePolicyJsonField(values.policyJson, "平台策略 JSON"), + reason: values.reason || "" + } + }); + appState.userCurrentPlatformPolicy = saved; + await loadAgentControlSurfaces(project.id); + rememberAction(`${platformLabel(normalizedPlatform)} 平台策略已保存`, `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +function openSystemMainPolicyAction() { + const projectId = getOneLinerProjectId(); + const bundle = appState.adminSystemMainPolicy || {}; + const current = bundle.current_version || {}; + openActionModal({ + title: "编辑系统主 Agent 策略", + description: "这是所有用户共享的系统级主 Agent 基座能力,后续用户层和管理员覆盖都会叠加在它上面。", + submitLabel: "保存系统策略", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, "系统主 Agent 还没有系统默认策略。") }, + { name: "title", label: "策略标题", value: current.title || bundle.scope?.title || "系统主 Agent 策略", placeholder: "例如:StoryForge 主 Agent 默认策略" }, + { name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚当前系统主 Agent 主要服务的方向和约束" }, + { name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"homepage\":{\"focus\":\"ops\"}}" }, + { name: "reason", label: "发布原因", type: "textarea", rows: 3, value: "", placeholder: "例如:更新市场节奏后,需要调整首页推荐和调度逻辑" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch("/v2/admin/oneliner/governance/system/main-agent", { + method: "PUT", + body: { + title: values.title || "系统主 Agent 策略", + summary: values.summary || "", + policy: parsePolicyJsonField(values.policyJson, "系统策略 JSON"), + reason: values.reason || "" + } + }); + appState.adminSystemMainPolicy = saved; + await loadAgentControlSurfaces(projectId); + rememberAction("系统主 Agent 策略已保存", `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + +function openSystemPlatformPolicyAction(platform) { + const normalizedPlatform = normalizePlatformValue(platform, "douyin"); + const projectId = getOneLinerProjectId(); + const bundle = safeArray(appState.adminSystemPlatformPolicies).find((item) => item?.scope?.platform === normalizedPlatform) || {}; + const current = bundle.current_version || {}; + openActionModal({ + title: `编辑 ${platformLabel(normalizedPlatform)} 系统平台策略`, + description: "这是所有用户共享的系统级平台默认策略,用户自己的平台偏好会在这层之上覆盖。", + submitLabel: "保存平台默认策略", + fields: [ + { type: "html", label: "当前版本", html: renderPolicyVersionSummary(bundle, `当前 ${platformLabel(normalizedPlatform)} 还没有系统平台默认策略。`) }, + { name: "title", label: "策略标题", value: current.title || `${platformLabel(normalizedPlatform)} 系统平台策略`, placeholder: "例如:抖音系统平台策略" }, + { name: "summary", label: "摘要", type: "textarea", rows: 3, value: current.summary || "", placeholder: "写清楚这个平台默认遵循的拆解与执行逻辑" }, + { name: "policyJson", label: "策略 JSON", type: "textarea", rows: 8, value: JSON.stringify(current.policy || {}, null, 2), placeholder: "{\"douyin\":{\"benchmark_mode\":\"strict\"}}" }, + { name: "reason", label: "发布原因", type: "textarea", rows: 3, value: "", placeholder: "例如:平台节奏变化,需要调整系统默认方法论" } + ], + onSubmit: async (values) => { + const saved = await storyforgeFetch(`/v2/admin/oneliner/governance/system/platforms/${encodeURIComponent(normalizedPlatform)}`, { + method: "PUT", + body: { + title: values.title || `${platformLabel(normalizedPlatform)} 系统平台策略`, + summary: values.summary || "", + policy: parsePolicyJsonField(values.policyJson, "平台默认策略 JSON"), + reason: values.reason || "" + } + }); + appState.adminSystemPlatformPolicies = safeArray(appState.adminSystemPlatformPolicies) + .filter((item) => item?.scope?.platform !== normalizedPlatform) + .concat(saved) + .sort((a, b) => String(a?.scope?.platform || "").localeCompare(String(b?.scope?.platform || ""))); + await loadAgentControlSurfaces(projectId); + rememberAction(`${platformLabel(normalizedPlatform)} 系统平台策略已保存`, `已发布版本 ${saved.current_version?.version_no || 1}。`, "green", saved); + renderAll(); + } + }); +} + function openPlatformAgentProfileAction(platform) { const project = requireSelectedProject(); const agents = safeArray(appState.platformAgents); @@ -7991,6 +8312,22 @@ document.addEventListener("click", async (event) => { openOneLinerProfileAction(); return; } + if (name === "open-user-global-policy") { + openUserGlobalPolicyAction(); + return; + } + if (name === "open-user-platform-policy") { + openUserPlatformPolicyAction(action.dataset.platform || ""); + return; + } + if (name === "open-system-main-policy") { + openSystemMainPolicyAction(); + return; + } + if (name === "open-system-platform-policy") { + openSystemPlatformPolicyAction(action.dataset.platform || ""); + return; + } if (name === "select-oneliner-session") { appState.selectedOnelinerSessionId = action.dataset.sessionId || ""; await loadOneLinerMessages(appState.selectedOnelinerSessionId); diff --git a/web/storyforge-web-v4/tests/workbench-pages.test.mjs b/web/storyforge-web-v4/tests/workbench-pages.test.mjs index 8f3902f..53ea3e1 100644 --- a/web/storyforge-web-v4/tests/workbench-pages.test.mjs +++ b/web/storyforge-web-v4/tests/workbench-pages.test.mjs @@ -37,6 +37,9 @@ test("agent screen excludes quota and registry panels and uses page tabs", () => assert.doesNotMatch(source, /renderTenantQuotaPanel\(/); assert.doesNotMatch(source, /renderOneLinerActionRegistryPanel\(/); assert.match(source, /renderDetailTabs\("playbookDetailTab"/); + assert.match(source, /renderGovernanceSummaryCard\(/); + assert.match(source, /open-user-global-policy/); + assert.match(source, /open-user-platform-policy/); }); test("discovery, production, and admin screens use page tabs for heavy content", () => { @@ -47,6 +50,7 @@ test("discovery, production, and admin screens use page tabs for heavy content", assert.match(discovery, /renderDetailTabs\("discoveryDetailTab"/); assert.match(production, /renderDetailTabs\("productionDetailTab"/); assert.match(admin, /renderDetailTabs\("adminWorkbenchTab"/); + assert.match(admin, /renderAdminGovernanceSummaryPanel\(/); }); test("projects screen uses an adaptive project grid instead of a fixed three-column squeeze", () => { @@ -93,3 +97,46 @@ test("oneliner submit failures stay inside the app instead of using a browser al assert.doesNotMatch(APP, /alert\("OneLiner 调度失败:/); assert.match(APP, /presentActionFailure\(error,\s*"OneLiner 调度失败"\)/); }); + +test("agent control surfaces load governance endpoints for user and admin summaries", () => { + const source = extractBetween(APP, "async function loadAgentControlSurfaces(projectId = \"\")", "async function loadOneLinerMessages(sessionId)"); + assert.match(source, /\/v2\/oneliner\/governance\/effective/); + assert.match(source, /\/v2\/oneliner\/governance\/user\/global/); + assert.match(source, /\/v2\/oneliner\/governance\/user\/platforms\/\$\{encodeURIComponent\(governancePlatform\)\}/); + assert.match(source, /\/v2\/admin\/oneliner\/governance\/system\/main-agent/); + assert.match(source, /\/v2\/admin\/oneliner\/governance\/system\/platforms\/\$\{encodeURIComponent\(item\.value\)\}/); +}); + +test("oneliner meta and action handlers expose governance entry points", () => { + const meta = extractBetween(APP, "function renderOneLinerUi()", "function openOneLinerPanel()"); + const actions = extractBetween(APP, "document.addEventListener(\"click\", async (event) => {", "document.addEventListener(\"submit\", async (event) => {"); + assert.match(meta, /open-user-global-policy/); + assert.match(meta, /policyScopeTagLabel/); + assert.match(actions, /name === "open-user-global-policy"/); + assert.match(actions, /name === "open-system-main-policy"/); +}); + +test("system governance saves refresh control surfaces after persisting", () => { + const profile = extractBetween(APP, "function openOneLinerProfileAction()", "function parsePolicyJsonField(rawValue, label = \"策略 JSON\")"); + const userGlobal = extractBetween(APP, "function openUserGlobalPolicyAction()", "function openUserPlatformPolicyAction(platform)"); + const userPlatform = extractBetween(APP, "function openUserPlatformPolicyAction(platform)", "function openSystemMainPolicyAction()"); + const main = extractBetween(APP, "function openSystemMainPolicyAction()", "function openSystemPlatformPolicyAction(platform)"); + const platform = extractBetween(APP, "function openSystemPlatformPolicyAction(platform)", "function openPlatformAgentProfileAction(platform)"); + + assert.match(profile, /appState\.onelinerProfile = saved;/); + assert.match(profile, /await loadAgentControlSurfaces\(project\.id\);/); + + assert.match(userGlobal, /appState\.userGlobalPolicy = saved;/); + assert.match(userGlobal, /await loadAgentControlSurfaces\(project\.id\);/); + + assert.match(userPlatform, /appState\.userCurrentPlatformPolicy = saved;/); + assert.match(userPlatform, /await loadAgentControlSurfaces\(project\.id\);/); + + assert.match(main, /const projectId = getOneLinerProjectId\(\);/); + assert.match(main, /appState\.adminSystemMainPolicy = saved;/); + assert.match(main, /await loadAgentControlSurfaces\(projectId\);/); + + assert.match(platform, /const projectId = getOneLinerProjectId\(\);/); + assert.match(platform, /appState\.adminSystemPlatformPolicies = safeArray\(appState\.adminSystemPlatformPolicies\)/); + assert.match(platform, /await loadAgentControlSurfaces\(projectId\);/); +});