from __future__ import annotations import asyncio import copy from pathlib import Path from typing import Any import pytest from server.agent_core import harness from server.agent_core.approval_store import ApprovalStore from server.aps_domain.folder_pack import folder_schedule_world_fingerprint from server.aps_domain.workflow import execute_confirmed, handle_intent from server.auth.context import IdentityContext, bind_identity, reset_identity from server.contracts import IntentResult from server.state.seed import seed_world _SQL = """ CREATE TABLE `pl_order` ( `id` int, `code` varchar(64), `business_code` varchar(64), `customer_business_code` varchar(64), `material_code` varchar(64), `material_name` varchar(64), `parent_code` varchar(64), `craftl_code` varchar(64), `machine_model` varchar(64), `dept` varchar(64), `people` varchar(64), `drawing_code` varchar(64), `quantity` decimal(10,2), `man_quantity` decimal(10,2), `all_quantity` decimal(10,2), `status` varchar(8), `stock_status` varchar(8), `distribution_status` varchar(8), `type` varchar(8), `description` varchar(64), `planned_end_time` datetime, `is_delete` varchar(8), `level` int ) ENGINE=InnoDB; CREATE TABLE `r_production_craftl` ( `id` int, `craftl_code` varchar(64), `craftl_name` varchar(64), `procedure_code` varchar(64), `procedure_name` varchar(64), `output_material_code` varchar(64), `output_material_name` varchar(64), `working_hours` decimal(10,2), `sort_no` int, `is_delete` varchar(8) ) ENGINE=InnoDB; CREATE TABLE `md_equipment` ( `id` int, `code` varchar(64), `name` varchar(64), `is_delete` varchar(8) ) ENGINE=InnoDB; CREATE TABLE `md_material` ( `id` int, `code` varchar(64), `name` varchar(64), `type` varchar(32), `unit` varchar(8), `is_delete` varchar(8) ) ENGINE=InnoDB; INSERT INTO `pl_order` VALUES (1,'ORD1',NULL,'C1','FG001','成品A',NULL,'CRAFT1',NULL,NULL,NULL,NULL,10,NULL,NULL,'0','0','0','S',NULL,'2026-08-30 00:00:00','0',5); INSERT INTO `r_production_craftl` VALUES (1,'CRAFT1','成品A','OP10','粗车','WIP-X','半成品',0.5,10,'0'), (2,'CRAFT1','成品A','OP20','精车','FG001','成品A',0.8,20,'0'); INSERT INTO `md_equipment` VALUES (1,'EQ1','车床','0'); INSERT INTO `md_material` VALUES (1,'FG001','成品A','成品','件','0'); """ class _ProjectStore: def __init__(self, root: Path) -> None: self.root = root def snapshot(self, include_messages: bool = False) -> dict[str, Any]: result = { "projects": [{"id": "p1", "name": "安全目录", "workDir": str(self.root)}], "sessions": [{"id": "s1", "projectId": "p1"}], "files": [], "activeProjectId": "p1", "activeSessionId": "s1", } if include_messages: result["messages"] = {"s1": []} return result def active_world_key(self) -> str: return "p1" def require_active_write(self) -> None: return None def replace_workspace(self, _payload: dict[str, Any]) -> dict[str, Any]: return self.snapshot(include_messages=True) class _CheckpointStore: def __init__(self) -> None: self.pairs: dict[str, dict[str, Any]] = {} def create(self, world: dict[str, Any], **kwargs: Any) -> dict[str, Any]: pair_id = f"pair-folder-{len(self.pairs) + 1}" pair = {"pairId": pair_id, "world": copy.deepcopy(world), **kwargs} self.pairs[pair_id] = pair return pair def get(self, pair_id: str) -> dict[str, Any] | None: return copy.deepcopy(self.pairs.get(pair_id)) def delete(self, pair_id: str) -> bool: return self.pairs.pop(pair_id, None) is not None class _Store: def __init__(self, tenant_uuid: str) -> None: self.data = seed_world() self.tenant_uuid = tenant_uuid self.world_key = "p1" self.path = "memory://folder-security" self._ids: dict[str, int] = {} self.save_count = 0 self.fail_next_save = False def next_id(self, kind: str) -> int: self._ids[kind] = self._ids.get(kind, 10_000) + 1 return self._ids[kind] def save(self) -> None: self.save_count += 1 if self.fail_next_save: self.fail_next_save = False raise OSError("injected folder save failure") @pytest.fixture def folder_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): sql_path = tmp_path / "mini.sql" sql_path.write_text(_SQL, encoding="utf-8") tenant = "tenant-folder-security" identity = IdentityContext( user_id=2001, username="planner", fullname="Planner", tenant_uuid=tenant, roles=("planner", "approver", "admin"), ) project_store = _ProjectStore(tmp_path) checkpoints = _CheckpointStore() store = _Store(tenant) approval_store = ApprovalStore(str(tmp_path / "approvals.json")) original_approval_store = harness._approval_store harness.configure_approval_store(store=approval_store) import server.aps_domain.workflow as workflow_module from server.agent_core import plan_orchestration monkeypatch.setattr("server.state.projects.get_project_store", lambda: project_store) monkeypatch.setattr(workflow_module, "get_checkpoints", lambda: checkpoints) monkeypatch.setattr(plan_orchestration, "stage_plan_node", lambda **_kwargs: None) monkeypatch.setattr(plan_orchestration, "decide_plan_node", lambda **_kwargs: None) try: yield store, identity, approval_store, checkpoints, sql_path finally: harness.configure_approval_store(store=original_approval_store) def _stage(store: _Store, identity: IdentityContext) -> tuple[str, Any]: token = bind_identity(identity) try: reply = asyncio.run(handle_intent( store, "s1", IntentResult( intent="folder.schedule", params={"query": "根据这些数据排产"}, confidence=1.0, source="RULE_FAST", ), actor="planner", )) finally: reset_identity(token) card = next(block for block in reply.blocks if block.type == "confirm-card") return str(card.props["confirmId"]), reply def _approve(store: _Store, identity: IdentityContext, confirm_id: str) -> str: token = bind_identity(identity) try: return execute_confirmed(store, confirm_id, True, actor="planner") finally: reset_identity(token) def test_sql_folder_schedule_stage_is_read_only_and_always_p2(folder_env): store, identity, approvals, checkpoints, _sql_path = folder_env before = folder_schedule_world_fingerprint(store.data) confirm_id, reply = _stage(store, identity) assert folder_schedule_world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") assert all(event.get("action") != "folder.schedule.sql_direct" for event in store.data["auditEvents"]) pending = approvals.pending[confirm_id] params = pending["params"] assert params["sqlApplied"] is True and params["sqlPayload"] assert params["sourceManifest"] and params["sourceManifestDigest"] assert params["folderWorldFingerprint"] == before assert params["folderPayloadDigest"] assert params["beforeSnapshot"] == pending["beforeSnapshot"] assert checkpoints.get(pending["beforeSnapshot"]) is not None assert { f"folder-source:{params['sourceManifestDigest']}", f"folder-world:{params['folderWorldFingerprint']}", f"folder-payload:{params['folderPayloadDigest']}", f"folder-snapshot:{params['beforeSnapshot']}", } <= set(pending["evidenceRefs"]) assert "请确认" in reply.text def test_folder_schedule_allows_missing_to_empty_context_policy_initialization(folder_env): store, identity, _approvals, _checkpoints, _sql_path = folder_env store.data.pop("contextPolicies", None) confirm_id, _reply = _stage(store, identity) store.data["contextPolicies"] = {} message = _approve(store, identity, confirm_id) assert "证据校验未通过" not in message assert "已按目录数据生成试排方案" in message def test_folder_schedule_rejects_nonempty_context_policy_drift(folder_env): store, identity, _approvals, _checkpoints, _sql_path = folder_env store.data.pop("contextPolicies", None) confirm_id, _reply = _stage(store, identity) store.data["contextPolicies"] = {"session": {"maxTokens": 1}} message = _approve(store, identity, confirm_id) assert "目标世界已变化" in message assert not store.data.get("flexScheduleVersions") def test_folder_schedule_source_drift_is_denied_without_business_write(folder_env): store, identity, _approvals, _checkpoints, sql_path = folder_env confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) sql_path.write_text(_SQL + "\n-- changed after approval stage\n", encoding="utf-8") message = _approve(store, identity, confirm_id) assert "源文件已变化" in message assert harness.world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") assert store.data["auditEvents"][-1]["action"] == "folder.schedule.evidence.denied" @pytest.mark.parametrize( "field", [ "sessionId", "projectId", "targetWorldKey", "boundAction", "folderPayloadDigest", "beforeSnapshot", ], ) def test_folder_schedule_tampered_envelope_is_denied(folder_env, field: str): store, identity, approvals, _checkpoints, _sql_path = folder_env confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) approvals.pending[confirm_id]["params"][field] = f"tampered-{field}" approvals.save() message = _approve(store, identity, confirm_id) assert "证据校验未通过" in message assert harness.world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") def test_folder_schedule_action_substitution_is_denied(folder_env): store, identity, approvals, _checkpoints, _sql_path = folder_env confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) approvals.pending[confirm_id]["action"] = "data.reset" approvals.save() message = _approve(store, identity, confirm_id) assert "确认信封证据校验未通过" in message assert harness.world_fingerprint(store.data) == before assert store.data["auditEvents"][-1]["action"] == "approval.envelope.denied" @pytest.mark.parametrize("failure", ["import", "schedule", "save"]) def test_folder_schedule_execution_failures_are_atomic(folder_env, monkeypatch, failure: str): store, identity, _approvals, _checkpoints, _sql_path = folder_env confirm_id, _reply = _stage(store, identity) before = harness.world_fingerprint(store.data) if failure == "import": def fail_import(*_args, **_kwargs): raise RuntimeError("injected folder import failure") monkeypatch.setattr("server.importers.sql_pack.apply_sql_pack_to_world", fail_import) elif failure == "schedule": def fail_schedule(*_args, **_kwargs): raise RuntimeError("injected folder schedule failure") monkeypatch.setattr("server.aps_domain.flex.run_flex_schedule", fail_schedule) else: store.fail_next_save = True message = _approve(store, identity, confirm_id) assert "失败" in message and ("回滚" in message or "恢复" in message) assert harness.world_fingerprint(store.data) == before assert not store.data.get("flexScheduleVersions") assert store.data["auditEvents"][-1]["result"] == "DENIED" def test_folder_schedule_unchanged_frozen_sql_commits_once(folder_env): store, identity, _approvals, _checkpoints, _sql_path = folder_env confirm_id, _reply = _stage(store, identity) message = _approve(store, identity, confirm_id) assert "工程目录已导入" in message and "已按目录数据生成试排方案" in message assert any(row.get("orderNo") == "ORD1" for row in store.data.get("flexOrders") or []) assert store.data.get("flexScheduleVersions") event = next( row for row in reversed(store.data["auditEvents"]) if row.get("action") == "folder.schedule.import" ) assert event["rationale"]["frozenPayload"] is True assert any(ref.startswith("folder-payload:") for ref in event["evidenceRefs"]) def test_folder_schedule_manifest_and_payload_share_one_immutable_read( folder_env, monkeypatch, ): store, identity, approvals, _checkpoints, sql_path = folder_env from server.aps_domain import folder_pack original_analyze = folder_pack.analyze_work_dir original_sql = sql_path.read_text(encoding="utf-8") swapped_sql = original_sql.replace("'ORD1'", "'ORD2'") def swap_during_analysis(*args, **kwargs): assert kwargs.get("source_snapshots") sql_path.write_text(swapped_sql, encoding="utf-8") try: return original_analyze(*args, **kwargs) finally: sql_path.write_text(original_sql, encoding="utf-8") monkeypatch.setattr(folder_pack, "analyze_work_dir", swap_during_analysis) confirm_id, _reply = _stage(store, identity) frozen_orders = approvals.pending[confirm_id]["params"]["sqlPayload"]["orders"] assert [row["orderNo"] for row in frozen_orders] == ["ORD1"] assert sql_path.read_text(encoding="utf-8") == original_sql message = _approve(store, identity, confirm_id) assert "已按目录数据生成试排方案" in message assert any(row.get("orderNo") == "ORD1" for row in store.data.get("flexOrders") or []) assert not any(row.get("orderNo") == "ORD2" for row in store.data.get("flexOrders") or [])