zizi ce9085df6b feat(runner-v2): P3 3a+3b-A 派发面 plumbing——便宜模型生成游戏经后端 dispatch 自动入 feed 真玩(零回归验)
3a canary:pong 真 bundle→buildGamePackage→发布→feed 引擎真渲真玩(gameId 9301,brick-2 真值通路 mvn 13/13 验)。3b-A 派发面:执行器对 generic dispatch 外置 worker→worker 回调→buildGamePackage→feed,stub worker e2e 实证(任务129→版本93111→gameId 9302 引擎真渲 CDP 11/11)。

改动:① 点亮 AigcTemplateConstants(List.of(generic))+PromptResourceLoader ② 派发面 AigcGenerateExecutor(dispatchGeneric 分支:仅 dispatchTemplateId=generic 走、其余进程内 LLM 旧路逐行不变=零回归)+WorkerDispatchClient+AigcExecutorProperties/Configuration ③ 内网回调 AdminAigcTaskController @PermitAll /dify/callback-internal(注入系统身份解 updater-null=部署窗口#3 同根因复发,镜像 executeWithSystemIdentity)④ stub worker scripts/wg1_stub_worker.py + 灌库/发布桩 p3_*/p3b_* + config worker-url ⑤ 单测 +3 派发面用例(dispatch/失败/零回归 clicker 不 dispatch)⑥ 蒸馏 ai-generation-pipeline.md §⓪′ 派发面范式+2 红线。

零回归硬约束:AigcGenerateExecutorTest 20/0/0、AigcTaskServiceImplTest 9/0/0,clicker 仍进程内(dispatchSender 零交互)。主笔亲核 gating diff(:342-344 仅 generic)+肉眼复核 feed 真玩截图。🔴 安全红线(留 3b-B):/dify/callback-internal 跳 RBAC 仅恃内网兜底,真 worker/外部接入前必补 HMAC/mTLS 服务间签名。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 14:19:25 +00:00

210 lines
10 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
WG1 stub workerP3 W-G1 派发面·3b-A2026-06-14——证全 plumbing 用的极简桩 worker。
【用途】把 P3 派发面端到端打通:执行器 POST §6.1 job → 本 stub 收 job → 读已知 pong bundle 作 engineBundle →
把 §6.1 result-out 经回调入参 POST 回 /admin-api/aigc/dify/callback-internal@PermitAll 内网回调子路由)→
后端 handleCallback 建版本/组包/落包入 feed。**不调任何 LLM、不打包**——只回固定 pong bundle专证管线。
真 workerL1 run.py裸 openai SDK 写 GameHostFactory → esbuild 打 __GameBundle → 自检)留 3b-B。
【与 §6.1 接缝的关系】本 stub 实现 §6.1 job-in/ result-out两个 JSON 契约:
- 收 job解 job_id/brief/gameId/templateId/callback.target其余字段透传/忽略)。
- 回 result但回调入参形态对齐【后端 DifyCallbackReqVO】traceId/status/templateId/gameConfig/engineBundle
而非直接回 §6.1 result-out 全形态——因为 3b-A 复用 HTTP 回调路worker→/dify/callback-internal
后端回调入参 VO 即落点§6.1 result-out 的 selfCheck/costYuan 等字段是 worker↔pipeline「队列路」语义
HTTP 回调路下由后端从 DifyCallbackReqVO 取 engineBundleresolveEngineBundleText落包二者等价。
【gameConfig 占位§5.2 占位框】engine 路下游戏逻辑全在 engineBundle 内gameConfig 仍不可空GamePackage
required + additionalProperties:false故回最小合法占位 {templateId, title, theme, engineDriven:true}。
【鉴权§6.5 auth 孤儿解法】worker 是内部服务无 RBAC token回调走后端新开的 @PermitAll 内网回调子路由
/admin-api/aigc/dify/callback-internal复用 handleCallback 唯一写入路径)。本 stub 因此【不带 Authorization 头】。
【运行mini-desktop
python3 game-cloud/scripts/wg1_stub_worker.py [--port 9301] \
[--bundle game-runtime/games/_wg1-gen/pong/bundle.iife.js] \
[--callback http://localhost:48080/admin-api/aigc/dify/callback-internal]
执行器 application-staging.yaml 配 aigc.executor.worker-url=http://localhost:9301/wg1/generate 指向本 stub。
@author 造梦AIP3 派发面·stub
"""
import argparse
import json
import os
import sys
import threading
import urllib.request
import urllib.error
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# ---------- 默认常量mini-desktop staging 口径)----------
# pong bundle 固定产物brick-2 已放此路径267KB iife 顶层全局名 __GameBundle
DEFAULT_BUNDLE = "game-runtime/games/_wg1-gen/pong/bundle.iife.js"
# 内网免鉴权回调子路由(后端 AdminAigcTaskController.difyCallbackInternal@PermitAll
DEFAULT_CALLBACK = "http://localhost:48080/admin-api/aigc/dify/callback-internal"
DEFAULT_PORT = 9301
def log(msg):
"""简单带前缀日志stdout便于 nohup 落盘排障)。"""
print(f"[wg1-stub-worker] {msg}", flush=True)
class StubWorkerState:
"""进程级状态bundle 文本一次读入常驻 + 配置)。"""
def __init__(self, bundle_text, callback_url):
self.bundle_text = bundle_text # pong bundle 全文engineBundle 真值)
self.callback_url = callback_url # 回调子路由 URL
def build_callback_payload(job, bundle_text):
"""
据收到的 §6.1 job 组后端回调入参DifyCallbackReqVO 形态)。
:param job: 收到的 §6.1 job-indict
:param bundle_text: pong bundle 全文engineBundle 真值)
:return: 回调入参 dicttraceId/status/templateId/gameConfig/engineBundle
"""
trace_id = job.get("traceId") or job.get("job_id") # traceId 贯穿全链(与 job_id 同值)
template_id = job.get("templateId") or "generic"
brief = job.get("brief") or ""
# gameConfig 最小合法占位§5.2engine 路承载 meta非填参玩法。title 由 brief 派生(截断防超 META_TITLE_MAX
title = (brief[:20] if brief else "弹球(stub)")
game_config = {
"templateId": template_id, # 须与包级 templateId 一致validateSucceededPayload 一致性校验)
"title": title, # 供 meta 段派生 cover/summary
"theme": "stub-pong", # 占位主题
"engineDriven": True, # 标记本款走引擎 bundle 路(非填参 GameConfig
}
return {
"traceId": trace_id,
"status": "succeeded",
"templateId": template_id,
"gameConfig": game_config,
"engineBundle": bundle_text, # 回调入参携 pong bundle 全文 → 后端 resolveEngineBundleText 取值落包
"assets": [],
# qualityScore/failureReason 省略succeeded 路不需要)
}
def post_callback(callback_url, payload):
"""
把回调入参 POST 回后端内网回调子路由(不带 Authorization@PermitAll 内网可达)。
:param callback_url: 回调 URL
:param payload: 回调入参 dict
:return: (http_status, body_text)
"""
data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req = urllib.request.Request(
callback_url, data=data, method="POST",
headers={"Content-Type": "application/json", "tenant-id": "0"}, # tenant-id 头兜底(@TenantIgnore 已忽略,带上无害)
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
body = resp.read().decode("utf-8", "replace")
return resp.status, body
except urllib.error.HTTPError as e:
body = e.read().decode("utf-8", "replace") if e.fp else ""
return e.code, body
except Exception as e: # 网络异常:返回 -1 + 异常文本(不抛,便于日志记真因)
return -1, f"{type(e).__name__}: {e}"
def handle_job_async(state, job):
"""
异步处理一个 job读 bundle → 组回调入参 → POST 回调)。
放后台线程模拟「worker 异步生成、执行器投递握手后即返回、任务留 RUNNING 等回调」的真实时序。
"""
trace_id = job.get("traceId") or job.get("job_id")
game_id = job.get("gameId")
log(f"开始处理 job trace_id={trace_id}, gameId={game_id}, bundleLen={len(state.bundle_text)}")
payload = build_callback_payload(job, state.bundle_text)
status, body = post_callback(state.callback_url, payload)
# 截断 body 防刷屏
body_brief = body[:300] if body else ""
log(f"回调完成 trace_id={trace_id}, callbackHttp={status}, body={body_brief}")
def make_handler(state):
"""工厂:绑定 state 的 HTTP handler 类。"""
class Handler(BaseHTTPRequestHandler):
# 静默默认访问日志(用自定义 log
def log_message(self, fmt, *args):
return
def _send_json(self, code, obj):
data = json.dumps(obj, ensure_ascii=False).encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def do_GET(self):
# 健康探针GET /health → 200
if self.path.rstrip("/") in ("/health", "/wg1/health"):
self._send_json(200, {"ok": True, "bundleLen": len(state.bundle_text)})
else:
self._send_json(404, {"error": "not found"})
def do_POST(self):
# 派发入口POST /wg1/generate ← 执行器投递 §6.1 job
if self.path.rstrip("/") != "/wg1/generate":
self._send_json(404, {"error": "not found", "path": self.path})
return
length = int(self.headers.get("Content-Length", "0") or "0")
raw = self.rfile.read(length) if length > 0 else b""
try:
job = json.loads(raw.decode("utf-8")) if raw else {}
except Exception as e:
log(f"job 解析失败:{e}")
self._send_json(400, {"accepted": False, "error": "bad job json"})
return
trace_id = job.get("traceId") or job.get("job_id")
log(f"收到 job trace_id={trace_id}, templateId={job.get('templateId')}, gameId={job.get('gameId')}")
# 投递握手:立即回 202投递成功后台异步「生成」+回调(模拟真实 worker 异步时序)
threading.Thread(target=handle_job_async, args=(state, job), daemon=True).start()
self._send_json(202, {"accepted": True, "job_id": job.get("job_id"), "traceId": trace_id})
return Handler
def main():
parser = argparse.ArgumentParser(description="WG1 stub workerP3 派发面 3b-A回固定 pong bundle 证管线)")
parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"监听端口(默认 {DEFAULT_PORT}")
parser.add_argument("--bundle", default=DEFAULT_BUNDLE, help="engineBundle 真值文件(默认 pong bundle")
parser.add_argument("--callback", default=DEFAULT_CALLBACK, help="后端内网回调子路由 URL")
args = parser.parse_args()
# 读 bundle相对路径基于 cwd建议在 repo 根跑)
bundle_path = args.bundle
if not os.path.isfile(bundle_path):
log(f"❌ bundle 文件不存在:{bundle_path}cwd={os.getcwd()}")
sys.exit(2)
with open(bundle_path, "r", encoding="utf-8") as f:
bundle_text = f.read()
# 出厂自检bundle 必含 __GameBundle 全局名(宿主据此取工厂;缺则后端落包后宿主取不到 → demo 兜底)
if "__GameBundle" not in bundle_text:
log(f"❌ bundle 缺 __GameBundle 全局名:{bundle_path}(宿主无法取 GameHostFactory")
sys.exit(2)
log(f"bundle 已加载:{bundle_path}{len(bundle_text)} 字符,含 __GameBundle ✓)")
log(f"回调子路由:{args.callback}")
state = StubWorkerState(bundle_text, args.callback)
server = ThreadingHTTPServer(("0.0.0.0", args.port), make_handler(state))
log(f"stub worker 已启动 → http://0.0.0.0:{args.port}/wg1/generateCtrl-C 退出)")
try:
server.serve_forever()
except KeyboardInterrupt:
log("收到中断,退出")
server.shutdown()
if __name__ == "__main__":
main()