- RuntimeService 新增 list_pending_approvals(三类分桶)与 resolve_approval (kind 路由 tool/plan/question,透传 manager 单次裁决/越权/参数校验语义) - 协议能力 run.*/approval.*/session.* 全部有 RuntimeService 公共方法 - 现状文档记录本轮收口决策:①②③贯通为终点,③↔④全量贯通后置(§7.5-补四件事) - 全量回归:失败恰为 4 项存量,与改造无关
345 lines
15 KiB
Python
345 lines
15 KiB
Python
"""Gateway 独立进程验收检查体(由 test_runtime_standalone_lifecycle 以子进程方式调用)。
|
||
|
||
为什么必须是独立进程:config/paths.py 的 DATA_DIR / DEPLOY_CONFIG_DIR 等是
|
||
import 时固化的模块级常量;在 unittest discover 全量运行时,排在前面的测试模块
|
||
会先 import config 使常量固化,本文件顶部再设环境变量已无效——且被测代码会
|
||
落到用户真实运行态目录执行任务(不可接受)。子进程内 import 顺序完全可控,
|
||
隔离 100% 可靠。
|
||
|
||
用法:python test/runtime_standalone_checks.py <lifecycle|chain>
|
||
退出码 0 = 通过;断言失败/traceback 走 stderr,返回码非 0。
|
||
"""
|
||
import json
|
||
import os
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
|
||
# ---- 隔离环境必须在 import 任何项目模块之前设置(config 在 import 时解析路径)----
|
||
_SMOKE_ROOT = Path(tempfile.mkdtemp(prefix="astrion_gw_smoke_"))
|
||
_SMOKE_WORKSPACE = _SMOKE_ROOT / "workspace"
|
||
_SMOKE_WORKSPACE.mkdir(parents=True, exist_ok=True)
|
||
(_SMOKE_ROOT / "config").mkdir(parents=True, exist_ok=True)
|
||
|
||
os.environ["ASTRION_DATA_ROOT"] = str(_SMOKE_ROOT)
|
||
os.environ["DEPLOY_CONFIG_DIR"] = str(_SMOKE_ROOT / "config")
|
||
os.environ["TERMINAL_SANDBOX_MODE"] = "host"
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||
|
||
(_SMOKE_ROOT / "config" / "host_workspaces.json").write_text(
|
||
json.dumps(
|
||
{
|
||
"default_workspace_id": "gwsmoke",
|
||
"workspaces": [
|
||
{"workspace_id": "gwsmoke", "label": "gwsmoke", "path": str(_SMOKE_WORKSPACE)}
|
||
],
|
||
},
|
||
ensure_ascii=False,
|
||
indent=2,
|
||
),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def _make_ctx(conversation_id=None, message="协议链路验收 ping"):
|
||
from server.runtime import (
|
||
InternalDirectives, RuntimeContext, TaskParams, TrustedPrincipal,
|
||
)
|
||
|
||
return RuntimeContext(
|
||
principal=TrustedPrincipal(
|
||
username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
|
||
host_mode=True, host_workspace_id="gwsmoke",
|
||
),
|
||
params=TaskParams(message=message, conversation_id=conversation_id, run_mode="fast"),
|
||
directives=InternalDirectives(),
|
||
)
|
||
|
||
|
||
def _wait_terminal_state(task_id, timeout=30, cancel_on_event=None):
|
||
"""等待终态。事件流出现指定事件(默认首个事件)后主动取消——
|
||
无网络环境下模型重试链路太长,取消让任务快速到达终态。
|
||
cancel_on_event="api_request_start" 可确保 user 消息已写入历史后再取消。
|
||
"""
|
||
from server.runtime import runtime_service
|
||
|
||
deadline = time.time() + timeout
|
||
cancelled = False
|
||
while time.time() < deadline:
|
||
rec = runtime_service.get_task("gw_smoke_user", task_id)
|
||
if getattr(rec, "status", None) in {"succeeded", "failed", "stopped", "canceled"}:
|
||
return rec
|
||
if not cancelled:
|
||
events, _, _, _meta = runtime_service.get_task_events("gw_smoke_user", task_id, 0)
|
||
if events and (cancel_on_event is None or any(e.get("type") == cancel_on_event for e in events)):
|
||
runtime_service.cancel_task("gw_smoke_user", task_id)
|
||
cancelled = True
|
||
time.sleep(0.5)
|
||
return runtime_service.get_task("gw_smoke_user", task_id)
|
||
|
||
|
||
def check_lifecycle():
|
||
"""第 1 步验收:无 Flask app 的独立 Gateway 生命周期(G4/G13)。"""
|
||
from flask import has_app_context
|
||
|
||
assert not has_app_context(), "验收前提:无 Flask app 上下文"
|
||
|
||
from server.runtime import runtime_service
|
||
|
||
rec = runtime_service.create_task(
|
||
_make_ctx(message="ping(独立生命周期验收,模型调用失败属预期)")
|
||
)
|
||
assert getattr(rec, "task_id", None), "create_task 应返回 task_id"
|
||
task_id = rec.task_id
|
||
|
||
from server.runtime import runtime_service as rs # 单例别名,语义强调
|
||
|
||
deadline = time.time() + 30
|
||
saw_events = False
|
||
final_status = None
|
||
while time.time() < deadline:
|
||
events, _next_offset, err, _meta = rs.get_task_events("gw_smoke_user", task_id, 0)
|
||
assert err is None, f"get_task_events 应可读: {err}"
|
||
if events:
|
||
saw_events = True
|
||
rec_now = rs.get_task("gw_smoke_user", task_id)
|
||
status = getattr(rec_now, "status", None)
|
||
if status in {"succeeded", "failed", "stopped", "canceled", "cancel_requested"}:
|
||
final_status = status
|
||
break
|
||
# 装配完成后主动取消(避免真实模型调用慢等;装配此时已真实发生)
|
||
if saw_events:
|
||
rs.cancel_task("gw_smoke_user", task_id)
|
||
time.sleep(0.5)
|
||
|
||
assert saw_events, "任务线程应真实启动并产生事件(装配真实发生)"
|
||
|
||
deadline = time.time() + 20
|
||
while time.time() < deadline:
|
||
rec_now = rs.get_task("gw_smoke_user", task_id)
|
||
final_status = getattr(rec_now, "status", None)
|
||
if final_status in {"succeeded", "failed", "stopped", "canceled"}:
|
||
break
|
||
time.sleep(0.5)
|
||
assert final_status in {"succeeded", "failed", "stopped", "canceled"}, (
|
||
f"任务应到达终态(模型失败属预期),实际: {final_status}"
|
||
)
|
||
|
||
# 门闸最终释放(对话应可受理下一任务)
|
||
import server.context.resources as resources
|
||
|
||
term_key = "host::gwsmoke::" + str(getattr(rec_now, "conversation_id", "") or "")
|
||
terminal = resources.state.user_terminals.get(term_key)
|
||
if terminal is not None:
|
||
from server.main_task_gate import is_main_task_gate_busy
|
||
|
||
assert not is_main_task_gate_busy(terminal), "任务终态后门闸必须释放(否则下一任务无法受理)"
|
||
|
||
# 全程无 Flask app 初始化(socketio 未绑定 app)
|
||
assert not has_app_context(), "验收结束仍应无 Flask app 上下文"
|
||
import server.extensions as ext
|
||
|
||
assert getattr(ext.socketio, "server", None) is None, "socketio 不应初始化 server(无 Web 应用装配)"
|
||
|
||
|
||
def check_chain():
|
||
"""第 2 步验收:极简协议客户端视角全链路(工作区→会话→运行→停止→历史→审批)。"""
|
||
from server.runtime import runtime_service
|
||
|
||
# 1. 第一个 Run:受理 → 终态(模型失败属预期;等 api_request_start 确保历史已写)
|
||
rec1 = runtime_service.create_task(_make_ctx())
|
||
rec1 = _wait_terminal_state(rec1.task_id, cancel_on_event="api_request_start")
|
||
assert rec1.status in {"succeeded", "failed", "stopped", "canceled"}, f"Run1 应达终态: {rec1.status}"
|
||
conv_id = rec1.conversation_id
|
||
assert conv_id, "Run 应建立会话 id(服务层补建对话,conversation_id 同步受理返回)"
|
||
|
||
# 2. 会话历史已持久化(隔离数据目录下的对话 JSON 含 user 消息)
|
||
from config import DATA_DIR
|
||
|
||
conv_dir = Path(DATA_DIR) / "conversations"
|
||
candidates = list(conv_dir.rglob(f"*{conv_id}*.json")) if conv_dir.exists() else []
|
||
assert candidates, f"会话 JSON 应已持久化({conv_id})"
|
||
content = candidates[0].read_text(encoding="utf-8", errors="replace")
|
||
assert "协议链路验收 ping" in content, "会话历史应包含本次 user 消息"
|
||
|
||
# 3. 事件流 offset 续读:分段读取,idx 连续、无重复;窗口水位可用于缺口检测
|
||
events_all, _, err, meta = runtime_service.get_task_events("gw_smoke_user", rec1.task_id, 0)
|
||
assert err is None
|
||
assert events_all, "Run 应产生事件"
|
||
assert meta and meta.get("window_start") == 0, "未裁剪时窗口水位应为 0"
|
||
assert meta["window_start"] <= events_all[0]["idx"], "水位不超过首个返回事件 idx"
|
||
mid = events_all[len(events_all) // 2]["idx"]
|
||
tail, _, err2, meta2 = runtime_service.get_task_events("gw_smoke_user", rec1.task_id, mid + 1)
|
||
assert err2 is None
|
||
assert meta2 and meta2.get("window_start") == 0
|
||
idxs = [e["idx"] for e in events_all]
|
||
assert idxs == sorted(idxs), "idx 应单调递增"
|
||
assert len(set(idxs)) == len(idxs), "idx 应无重复"
|
||
assert [e["idx"] for e in tail] == [i for i in idxs if i > mid], "offset 续读语义"
|
||
|
||
# 4. 同会话第二个 Run:门闸已释放,可连续受理
|
||
rec2 = runtime_service.create_task(_make_ctx(conversation_id=conv_id, message="第二个 Run"))
|
||
assert rec2.conversation_id == conv_id, "第二个 Run 应复用同一会话"
|
||
rec2 = _wait_terminal_state(rec2.task_id)
|
||
assert rec2.status in {"succeeded", "failed", "stopped", "canceled"}, f"Run2 应达终态: {rec2.status}"
|
||
|
||
# 5.5 会话查询公共入口(CLI/非 Web 调用方不依赖 Web 路由)
|
||
from server.runtime import TrustedPrincipal as _TP
|
||
|
||
listing = runtime_service.list_sessions(
|
||
"gw_smoke_user", "gwsmoke",
|
||
principal=_TP(username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
|
||
host_mode=True, host_workspace_id="gwsmoke"),
|
||
)
|
||
items = listing.get("items") or listing.get("conversations") or []
|
||
assert any((it.get("conversation_id") or it.get("id")) == conv_id for it in items), (
|
||
f"会话列表应含本次会话 {conv_id}(keys={list(listing.keys())})"
|
||
)
|
||
history = runtime_service.get_session_history(
|
||
"gw_smoke_user", "gwsmoke", conv_id,
|
||
principal=_TP(username="gw_smoke_user", workspace_id="gwsmoke", role="admin",
|
||
host_mode=True, host_workspace_id="gwsmoke"),
|
||
)
|
||
assert history and "协议链路验收 ping" in json.dumps(history, ensure_ascii=False), \
|
||
"会话历史公共入口应可读且含 user 消息"
|
||
try:
|
||
runtime_service.get_session_history(
|
||
"gw_smoke_user", "gwsmoke", conv_id,
|
||
principal=_TP(username="mallory", workspace_id="gwsmoke"),
|
||
)
|
||
except PermissionError:
|
||
pass
|
||
else:
|
||
raise AssertionError("principal 与查询目标不一致必须抛 PermissionError")
|
||
|
||
# 6. 审批语义:公共入口 list_pending_approvals / resolve_approval
|
||
# (manager 层语义已有覆盖,这里验收公共入口路由与错误语义透传)
|
||
from server.state import tool_approval_manager
|
||
|
||
item = tool_approval_manager.create_request(
|
||
username="gw_smoke_user", conversation_id=conv_id, task_id=rec2.task_id,
|
||
tool_call_id="tc_smoke", tool_name="run_command",
|
||
arguments={"command": "echo hi"}, preview={},
|
||
)
|
||
pending = runtime_service.list_pending_approvals("gw_smoke_user", conv_id)
|
||
assert any(p.get("approval_id") == item["approval_id"] for p in pending.get("tool", [])), \
|
||
"公共入口审批列表应含该项"
|
||
first = runtime_service.resolve_approval(
|
||
"tool", username="gw_smoke_user", item_id=item["approval_id"], decision="approved"
|
||
)
|
||
assert first.get("status") == "approved"
|
||
second = runtime_service.resolve_approval(
|
||
"tool", username="gw_smoke_user", item_id=item["approval_id"], decision="rejected"
|
||
)
|
||
assert second.get("status") == "approved", "重复裁决必须返回现状(单次裁决)"
|
||
# 越权防护:他人裁决应拒绝
|
||
item2 = tool_approval_manager.create_request(
|
||
username="gw_smoke_user", conversation_id=conv_id, task_id=None,
|
||
tool_call_id="tc_smoke2", tool_name="write_file", arguments={}, preview={},
|
||
)
|
||
try:
|
||
runtime_service.resolve_approval(
|
||
"tool", username="other_user", item_id=item2["approval_id"], decision="approved"
|
||
)
|
||
except PermissionError:
|
||
pass
|
||
else:
|
||
raise AssertionError("越权裁决必须抛 PermissionError")
|
||
# 未知审批类型应拒绝
|
||
try:
|
||
runtime_service.resolve_approval(
|
||
"bogus", username="gw_smoke_user", item_id="x", decision="approved"
|
||
)
|
||
except ValueError:
|
||
pass
|
||
else:
|
||
raise AssertionError("未知审批类型必须抛 ValueError")
|
||
|
||
|
||
def check_fake_exec():
|
||
"""第 4 步验收:Runtime 工具编排层 + 替身执行器可独立测试(无真实副作用)。
|
||
|
||
不经模型调用(外部依赖非验收对象),直接驱动 Runtime 真实工具入口
|
||
handle_tool_call,验证 E1-E4 分支注入语义:
|
||
- 替身收到全部调用;结果结构被编排层正常消费(与真实后端同构);
|
||
- 全程不起子进程、不写真实磁盘(替身内存文件系统)。
|
||
"""
|
||
import asyncio
|
||
|
||
from modules.execution_plane import FakeExecutionBackend
|
||
from server.context import RuntimeIdentity, get_user_resources
|
||
|
||
identity = RuntimeIdentity(
|
||
host_mode=True, host_workspace_id="gwsmoke", is_api_user=False, role="admin"
|
||
)
|
||
terminal, workspace = get_user_resources(
|
||
"gw_smoke_user", workspace_id="gwsmoke", update_session=False, identity=identity
|
||
)
|
||
assert terminal is not None and workspace is not None, "资源装配应成功"
|
||
backend = FakeExecutionBackend()
|
||
terminal.execution_backend = backend
|
||
|
||
def call(tool, args):
|
||
out = asyncio.run(terminal.handle_tool_call(tool, args))
|
||
return json.loads(out) if isinstance(out, str) else out
|
||
|
||
# E1 前台命令:替身接收,不起真实子进程
|
||
r = call("run_command", {"command": "echo hello-fake", "timeout": 5})
|
||
assert r.get("success"), f"替身 run_command 应成功: {r}"
|
||
assert "[fake-exec]" in str(r.get("output", "")), f"输出应来自替身: {r}"
|
||
|
||
# E2 后台命令:替身登记,不起真实后台线程
|
||
r = call("run_command", {"command": "sleep 1", "timeout": 60, "run_in_background": True})
|
||
assert r.get("success"), f"替身后台命令应成功: {r}"
|
||
assert str(r.get("command_id", "")).startswith("fake_bg_"), f"command_id 应来自替身: {r}"
|
||
|
||
# E3 文件写(新文件,避开先读后写拦截):替身内存文件系统接收
|
||
r = call("write_file", {"file_path": "fake_probe.txt", "content": "line-a\n"})
|
||
assert r.get("success"), f"替身 write_file 应成功: {r}"
|
||
assert backend.files.get("fake_probe.txt") == "line-a\n", "写入应落替身内存"
|
||
assert not (_SMOKE_WORKSPACE / "fake_probe.txt").exists(), "替身路径不得写真实磁盘"
|
||
|
||
# E4 文件编辑(write 后已标记已读,guard 放行):替身替换生效
|
||
r = call("edit_file", {"file_path": "fake_probe.txt", "replacements": [
|
||
{"old_string": "line-a", "new_string": "line-b"}
|
||
]})
|
||
assert r.get("success"), f"替身 edit_file 应成功: {r}"
|
||
assert backend.files.get("fake_probe.txt") == "line-b\n", "编辑应作用于替身内存"
|
||
|
||
# 调用记录完整(E1-E4 各一次)
|
||
ops = [c["op"] for c in backend.calls]
|
||
assert ops == ["run_command", "run_command_background", "write_file", "edit_file"], ops
|
||
|
||
# 默认路径回归:新建对话级 terminal 的 execution_backend 应为 None(真实链路)
|
||
cm = terminal.context_manager.conversation_manager
|
||
conv_id2 = cm.create_conversation(
|
||
project_path=str(workspace.project_path), run_mode="fast",
|
||
thinking_mode=False, model_key=None,
|
||
)
|
||
terminal2, _ws2 = get_user_resources(
|
||
"gw_smoke_user", workspace_id="gwsmoke", update_session=False,
|
||
conversation_id=conv_id2, identity=identity,
|
||
)
|
||
assert getattr(terminal2, "execution_backend", "MISSING") is None, "默认应为 None(真实链路)"
|
||
|
||
|
||
def main():
|
||
mode = sys.argv[1] if len(sys.argv) > 1 else ""
|
||
if mode == "lifecycle":
|
||
check_lifecycle()
|
||
elif mode == "chain":
|
||
check_chain()
|
||
elif mode == "fake_exec":
|
||
check_fake_exec()
|
||
else:
|
||
print("usage: runtime_standalone_checks.py <lifecycle|chain|fake_exec>", file=sys.stderr)
|
||
return 2
|
||
print(f"CHECK_OK {mode}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|