Merge remote-tracking branch 'web/master' into feat/five_min_design
# Conflicts: # apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs # apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop_tests.rs # docs/project-memory/shared-memory/decision-log.md # docs/project-memory/shared-memory/pitfalls.md
This commit is contained in:
@@ -558,6 +558,7 @@ copy_required_file "${SCRIPT_DIR}/spacetime-export-migration-json.mjs" "${TARGET
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-import-migration-json.mjs" "${TARGET_DIR}/scripts/database-import.mjs" "数据库导入脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-migration-common.mjs" "${TARGET_DIR}/scripts/spacetime-migration-common.mjs" "数据库迁移公共脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-maintain-external-generation-jobs.mjs" "${TARGET_DIR}/scripts/spacetime-maintain-external-generation-jobs.mjs" "外部生成任务维护脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-clean-editor-image-asset-kind.mjs" "${TARGET_DIR}/scripts/spacetime-clean-editor-image-asset-kind.mjs" "普通图片素材类型清理脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-normalize-editor-character-actions.mjs" "${TARGET_DIR}/scripts/spacetime-normalize-editor-character-actions.mjs" "角色动作元数据规范化脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-authorize-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-authorize-migration-operator.mjs" "数据库迁移授权脚本"
|
||||
copy_required_file "${SCRIPT_DIR}/spacetime-revoke-migration-operator.mjs" "${TARGET_DIR}/scripts/spacetime-revoke-migration-operator.mjs" "数据库迁移撤权脚本"
|
||||
|
||||
@@ -2385,11 +2385,13 @@ function assertAiGameCreatorShellUserDevBoundary() {
|
||||
);
|
||||
}
|
||||
if (
|
||||
!aiGameCreatorCargoManifestSource.includes('tauri-plugin-http = "2.5.9"') ||
|
||||
!aiGameCreatorCargoManifestSource.includes(
|
||||
'tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }',
|
||||
) ||
|
||||
!aiGameCreatorShellTauriSource.includes('tauri_plugin_http::init()')
|
||||
) {
|
||||
throw new Error(
|
||||
'AI game creator release must register the native HTTP plugin',
|
||||
'AI game creator release must register the native HTTP plugin without the OS automatic system-proxy feature',
|
||||
);
|
||||
}
|
||||
if (
|
||||
|
||||
@@ -651,6 +651,21 @@ const checks = [
|
||||
includes: "await scanScopes(options, { verifyZero: true })",
|
||||
reason: '角色动作规范化 apply 后必须执行全量零匹配复核。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']",
|
||||
reason: '普通图片错误素材类型必须覆盖三张业务表和持久化画布副本。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: 'expectedBatchSha256: dryRun.batch_sha256',
|
||||
reason: '普通图片素材类型 apply 必须绑定同批 dry-run 返回的摘要。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
includes: "await scanScopes(options, { verifyZero: true })",
|
||||
reason: '普通图片素材类型清理 apply 后必须执行全量零匹配复核。',
|
||||
},
|
||||
{
|
||||
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
|
||||
@@ -686,6 +701,16 @@ const checks = [
|
||||
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
reason: 'Stdb Build 必须归档普通图片素材类型清理脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
|
||||
includes: 'scripts/spacetime-clean-editor-image-asset-kind.mjs',
|
||||
reason: 'Stdb Publish 必须从同一上游制品复制普通图片素材类型清理脚本。',
|
||||
},
|
||||
{
|
||||
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
|
||||
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
|
||||
|
||||
@@ -0,0 +1,356 @@
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import wrap
|
||||
|
||||
try:
|
||||
import matplotlib.font_manager as font_manager
|
||||
import matplotlib.pyplot as plt
|
||||
from matplotlib.patches import FancyArrowPatch, FancyBboxPatch, Polygon, Rectangle
|
||||
except ImportError as error:
|
||||
raise SystemExit(
|
||||
"matplotlib is required. Install it in your local Python environment, "
|
||||
"then rerun this script."
|
||||
) from error
|
||||
|
||||
|
||||
FONT_CANDIDATES = [
|
||||
"Microsoft YaHei",
|
||||
"SimHei",
|
||||
"Noto Sans CJK SC",
|
||||
"Source Han Sans SC",
|
||||
"PingFang SC",
|
||||
"Arial Unicode MS",
|
||||
"DejaVu Sans",
|
||||
]
|
||||
|
||||
|
||||
def configure_font():
|
||||
available = {font.name for font in font_manager.fontManager.ttflist}
|
||||
selected = next((font for font in FONT_CANDIDATES if font in available), "DejaVu Sans")
|
||||
plt.rcParams["font.sans-serif"] = [selected, "DejaVu Sans"]
|
||||
plt.rcParams["axes.unicode_minus"] = False
|
||||
|
||||
|
||||
def wrap_mixed_text(text, width):
|
||||
lines = []
|
||||
for raw_line in text.splitlines():
|
||||
if not raw_line:
|
||||
lines.append("")
|
||||
continue
|
||||
if len(raw_line) <= width:
|
||||
lines.append(raw_line)
|
||||
continue
|
||||
if any(separator in raw_line for separator in ["_", "/", ".", ":"]) and " " not in raw_line:
|
||||
lines.append(raw_line)
|
||||
continue
|
||||
if " " in raw_line:
|
||||
lines.extend(wrap(raw_line, width=width, break_long_words=False, break_on_hyphens=False))
|
||||
continue
|
||||
lines.extend(raw_line[index : index + width] for index in range(0, len(raw_line), width))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def draw_lane(ax, y0, y1, label, color):
|
||||
ax.add_patch(
|
||||
Rectangle(
|
||||
(0.35, y0),
|
||||
17.3,
|
||||
y1 - y0,
|
||||
facecolor=color,
|
||||
edgecolor="none",
|
||||
alpha=0.34,
|
||||
zorder=0,
|
||||
)
|
||||
)
|
||||
ax.text(
|
||||
1.1,
|
||||
(y0 + y1) / 2,
|
||||
wrap_mixed_text(label, 8),
|
||||
fontsize=7.9,
|
||||
color="#475569",
|
||||
va="center",
|
||||
ha="center",
|
||||
weight="bold",
|
||||
linespacing=1.12,
|
||||
)
|
||||
|
||||
|
||||
def add_box(ax, center, size, title, body, facecolor, edgecolor="#334155"):
|
||||
x, y = center
|
||||
width, height = size
|
||||
left = x - width / 2
|
||||
bottom = y - height / 2
|
||||
ax.add_patch(
|
||||
FancyBboxPatch(
|
||||
(left, bottom),
|
||||
width,
|
||||
height,
|
||||
boxstyle="round,pad=0.03,rounding_size=0.12",
|
||||
linewidth=1.3,
|
||||
facecolor=facecolor,
|
||||
edgecolor=edgecolor,
|
||||
zorder=2,
|
||||
)
|
||||
)
|
||||
ax.text(
|
||||
x,
|
||||
y + height * 0.22,
|
||||
wrap_mixed_text(title, 13),
|
||||
fontsize=9.7,
|
||||
weight="bold",
|
||||
color="#0f172a",
|
||||
ha="center",
|
||||
va="center",
|
||||
zorder=3,
|
||||
)
|
||||
ax.text(
|
||||
x,
|
||||
y - height * 0.17,
|
||||
wrap_mixed_text(body, 18),
|
||||
fontsize=8.0,
|
||||
color="#334155",
|
||||
ha="center",
|
||||
va="center",
|
||||
linespacing=1.12,
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
|
||||
def add_diamond(ax, center, size, title, body, facecolor, edgecolor="#334155"):
|
||||
x, y = center
|
||||
width, height = size
|
||||
points = [
|
||||
(x, y + height / 2),
|
||||
(x + width / 2, y),
|
||||
(x, y - height / 2),
|
||||
(x - width / 2, y),
|
||||
]
|
||||
ax.add_patch(
|
||||
Polygon(points, closed=True, facecolor=facecolor, edgecolor=edgecolor, linewidth=1.3, zorder=2)
|
||||
)
|
||||
ax.text(
|
||||
x,
|
||||
y + 0.1,
|
||||
wrap_mixed_text(title, 12),
|
||||
fontsize=9.6,
|
||||
weight="bold",
|
||||
color="#0f172a",
|
||||
ha="center",
|
||||
va="center",
|
||||
zorder=3,
|
||||
)
|
||||
ax.text(
|
||||
x,
|
||||
y - 0.35,
|
||||
wrap_mixed_text(body, 14),
|
||||
fontsize=7.8,
|
||||
color="#334155",
|
||||
ha="center",
|
||||
va="center",
|
||||
linespacing=1.1,
|
||||
zorder=3,
|
||||
)
|
||||
|
||||
|
||||
def add_arrow(ax, start, end, label=None, color="#475569", curve=0.0, dashed=False):
|
||||
arrow = FancyArrowPatch(
|
||||
start,
|
||||
end,
|
||||
arrowstyle="-|>",
|
||||
mutation_scale=15,
|
||||
linewidth=1.45,
|
||||
color=color,
|
||||
linestyle="--" if dashed else "-",
|
||||
connectionstyle=f"arc3,rad={curve}",
|
||||
zorder=1,
|
||||
)
|
||||
ax.add_patch(arrow)
|
||||
if label:
|
||||
mid_x = (start[0] + end[0]) / 2
|
||||
mid_y = (start[1] + end[1]) / 2
|
||||
ax.text(
|
||||
mid_x,
|
||||
mid_y + 0.22,
|
||||
label,
|
||||
fontsize=8,
|
||||
color=color,
|
||||
ha="center",
|
||||
va="center",
|
||||
bbox={"boxstyle": "round,pad=0.18", "facecolor": "#ffffff", "edgecolor": "none", "alpha": 0.9},
|
||||
zorder=4,
|
||||
)
|
||||
|
||||
|
||||
def add_polyline_arrow(ax, points, label=None, color="#475569", dashed=False):
|
||||
if len(points) < 2:
|
||||
return
|
||||
for start, end in zip(points[:-2], points[1:-1]):
|
||||
ax.plot(
|
||||
[start[0], end[0]],
|
||||
[start[1], end[1]],
|
||||
color=color,
|
||||
linewidth=1.45,
|
||||
linestyle="--" if dashed else "-",
|
||||
zorder=1,
|
||||
)
|
||||
add_arrow(ax, points[-2], points[-1], color=color, dashed=dashed)
|
||||
if label:
|
||||
middle = points[len(points) // 2]
|
||||
ax.text(
|
||||
middle[0],
|
||||
middle[1] + 0.18,
|
||||
label,
|
||||
fontsize=8,
|
||||
color=color,
|
||||
ha="center",
|
||||
va="center",
|
||||
bbox={"boxstyle": "round,pad=0.18", "facecolor": "#ffffff", "edgecolor": "none", "alpha": 0.92},
|
||||
zorder=4,
|
||||
)
|
||||
|
||||
|
||||
def draw_agent_loop(output_path, dpi):
|
||||
configure_font()
|
||||
fig, ax = plt.subplots(figsize=(18, 11), dpi=dpi)
|
||||
ax.set_xlim(0, 18)
|
||||
ax.set_ylim(0, 11)
|
||||
ax.axis("off")
|
||||
|
||||
fig.patch.set_facecolor("#f8fafc")
|
||||
ax.set_facecolor("#f8fafc")
|
||||
|
||||
draw_lane(ax, 8.65, 10.2, "用户聊天窗口\n只看聊天、上传、待确认命令", "#dbeafe")
|
||||
draw_lane(ax, 6.75, 8.25, "上下文与规格\nTauri / Rust 本地 Runtime", "#dcfce7")
|
||||
draw_lane(ax, 3.85, 6.35, "Agent Loop\n最多 3 轮,Evaluator 驱动返工", "#fef3c7")
|
||||
draw_lane(ax, 1.05, 3.45, "本地产物、预览\n和可审计证据", "#fce7f3")
|
||||
|
||||
ax.text(
|
||||
8,
|
||||
10.72,
|
||||
"AI 游戏创作 App Agent Loop 结构",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=18,
|
||||
weight="bold",
|
||||
color="#0f172a",
|
||||
)
|
||||
ax.text(
|
||||
8,
|
||||
0.45,
|
||||
"事实源:memory/、assets/、game/、exports/、.agent/manifest.json、.agent/run.latest.json、.agent/passes/pass-N/",
|
||||
ha="center",
|
||||
va="center",
|
||||
fontsize=9,
|
||||
color="#64748b",
|
||||
)
|
||||
|
||||
boxes = {
|
||||
"input": ((3.2, 9.42), (2.25, 0.92), "用户输入", "/project 授权\n普通需求触发生成"),
|
||||
"pending": ((5.9, 9.42), (2.25, 0.92), "Permission Gate", "pending 卡片\nconfirm / cancel 日志"),
|
||||
"tauri": ((8.6, 9.42), (2.35, 0.92), "Tauri Command", "generate_local_game_draft\n只操作授权路径"),
|
||||
"context": ((11.5, 9.42), (2.45, 0.92), "上下文装载", "LLM 配置\n记忆 + manifest\n资产摘要"),
|
||||
"planner": ((3.2, 7.5), (2.25, 0.92), "Planner", "整理规格\n写 .agent/spec.md"),
|
||||
"findings": ((5.9, 7.5), (2.25, 0.92), "Findings Seed", "初始化 Evaluator\n.agent/findings.md"),
|
||||
"orchestrator": ((8.6, 7.5), (2.35, 0.92), "Orchestrator", "agenda.md\ntask-graph.json"),
|
||||
"roles": ((11.5, 7.5), (2.45, 0.92), "6 组角色 Brief", "16 个组内任务\nactive / carry-over"),
|
||||
"generator": ((14.45, 7.5), (2.35, 0.92), "Generator", "结构化草案\nHTML + handoffs"),
|
||||
"repair": ((9.6, 5.0), (2.45, 1.0), "Repair Routes", "结构化问题路由\n扩展下游影响任务"),
|
||||
"failed": ((6.4, 5.0), (2.25, 1.0), "失败终止", "3 轮仍未通过\n只保留 trace 和 pass 快照"),
|
||||
"writer": ((12.7, 2.35), (2.45, 1.0), "Artifact Writer", "写 game/index.html\n设计、数值、资产\n发布草案"),
|
||||
"smoke": ((9.65, 2.35), (2.45, 1.0), "Playtest", "game.static_smoke\n拒绝占位\n拒绝危险 API"),
|
||||
"preview": ((6.6, 2.35), (2.4, 1.0), "本地预览", "127.0.0.1 HTTP\n外部浏览器打开"),
|
||||
"chat": ((3.55, 2.35), (2.25, 1.0), "聊天回显", "展示完成摘要\n/trace 读取 latest"),
|
||||
"trace": ((15.6, 2.35), (2.55, 1.18), "Trace / Evidence", "run.latest.json\nruns/ logs/ artifacts\nstep + toolCalls"),
|
||||
}
|
||||
|
||||
colors = {
|
||||
"input": "#bfdbfe",
|
||||
"pending": "#c7d2fe",
|
||||
"tauri": "#bbf7d0",
|
||||
"context": "#bbf7d0",
|
||||
"planner": "#fde68a",
|
||||
"findings": "#fde68a",
|
||||
"orchestrator": "#fed7aa",
|
||||
"roles": "#fef08a",
|
||||
"generator": "#fde68a",
|
||||
"repair": "#fecaca",
|
||||
"failed": "#fee2e2",
|
||||
"writer": "#fbcfe8",
|
||||
"smoke": "#fbcfe8",
|
||||
"preview": "#fbcfe8",
|
||||
"chat": "#bfdbfe",
|
||||
"trace": "#e2e8f0",
|
||||
}
|
||||
|
||||
for key, (center, size, title, body) in boxes.items():
|
||||
add_box(ax, center, size, title, body, colors[key])
|
||||
|
||||
add_diamond(
|
||||
ax,
|
||||
(14.45, 5.0),
|
||||
(2.2, 1.48),
|
||||
"Evaluator",
|
||||
"质量门禁\n通过?",
|
||||
"#fed7aa",
|
||||
)
|
||||
|
||||
add_arrow(ax, (4.33, 9.42), (4.77, 9.42))
|
||||
add_arrow(ax, (7.03, 9.42), (7.43, 9.42))
|
||||
add_arrow(ax, (9.78, 9.42), (10.28, 9.42))
|
||||
add_polyline_arrow(ax, [(11.5, 8.96), (11.5, 8.38), (3.2, 8.38), (3.2, 7.96)], label="开始 loop")
|
||||
add_arrow(ax, (4.33, 7.5), (4.77, 7.5))
|
||||
add_arrow(ax, (7.03, 7.5), (7.43, 7.5))
|
||||
add_arrow(ax, (9.78, 7.5), (10.27, 7.5))
|
||||
add_arrow(ax, (12.73, 7.5), (13.28, 7.5))
|
||||
add_polyline_arrow(ax, [(14.45, 7.04), (14.45, 6.03), (14.45, 5.74)])
|
||||
add_arrow(ax, (13.35, 5.0), (10.83, 5.0), label="不通过")
|
||||
add_polyline_arrow(ax, [(9.6, 5.5), (9.6, 6.15), (8.6, 6.15), (8.6, 7.04)], label="下一轮")
|
||||
add_arrow(ax, (8.37, 5.0), (7.57, 5.0), label="超过 3 轮")
|
||||
add_polyline_arrow(ax, [(14.45, 4.26), (14.45, 3.28), (12.7, 3.28), (12.7, 2.85)], label="通过")
|
||||
add_arrow(ax, (11.48, 2.35), (10.88, 2.35))
|
||||
add_arrow(ax, (8.43, 2.35), (7.8, 2.35))
|
||||
add_arrow(ax, (5.4, 2.35), (4.68, 2.35))
|
||||
add_arrow(ax, (13.93, 2.35), (14.33, 2.35), color="#64748b")
|
||||
add_polyline_arrow(
|
||||
ax,
|
||||
[(15.3, 7.08), (17.15, 7.08), (17.15, 2.95), (16.89, 2.7)],
|
||||
color="#64748b",
|
||||
dashed=True,
|
||||
label="持续写入 trace",
|
||||
)
|
||||
add_polyline_arrow(
|
||||
ax,
|
||||
[(15.6, 1.76), (15.6, 1.35), (3.55, 1.35), (3.55, 1.85)],
|
||||
color="#64748b",
|
||||
dashed=True,
|
||||
)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fig.savefig(output_path, bbox_inches="tight", facecolor=fig.get_facecolor())
|
||||
plt.close(fig)
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Draw the AI game creator Agent loop flowchart with matplotlib.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--output",
|
||||
default=".app/agent-loop-flow.png",
|
||||
help="Output image path. The extension controls the format, for example .png, .svg or .pdf.",
|
||||
)
|
||||
parser.add_argument("--dpi", type=int, default=180, help="Output DPI for raster formats.")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main():
|
||||
args = parse_args()
|
||||
output_path = Path(args.output)
|
||||
draw_agent_loop(output_path, args.dpi)
|
||||
print(f"agent loop flowchart written to {output_path.resolve()}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,264 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
import {
|
||||
callSpacetimeProcedure,
|
||||
callSpacetimeProcedureViaCli,
|
||||
encodeSpacetimeCliOption,
|
||||
ensureProcedureOk,
|
||||
parsePositiveInteger,
|
||||
} from './spacetime-migration-common.mjs';
|
||||
|
||||
const PROCEDURE_NAME = 'clean_editor_image_asset_kind_and_return';
|
||||
const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas'];
|
||||
const DEFAULT_CHUNK_SIZE = 25;
|
||||
const CANVAS_MAX_CHUNK_SIZE = 5;
|
||||
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function usage() {
|
||||
return `用法:
|
||||
node scripts/spacetime-clean-editor-image-asset-kind.mjs \\
|
||||
--database <name> --server <name-or-url> [--chunk-size <1-25>] [--apply]
|
||||
|
||||
默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。
|
||||
脚本只把业务 assetKind 精确等于 "image" 的旧值清为空;不会修改 MIME、媒体类型或 asset_object.asset_kind。
|
||||
追加 --apply 后,每批仍会先 dry-run;只有 blocker 为零,才携带该批返回的 SHA-256 立即 apply。
|
||||
apply 完成后脚本会再次从头 dry-run,要求四个 scope 的 matched/blocker 均为零。
|
||||
必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`;
|
||||
}
|
||||
|
||||
export function parseOptions(argv, env = process.env) {
|
||||
const options = {
|
||||
apply: false,
|
||||
chunkSize: DEFAULT_CHUNK_SIZE,
|
||||
database: env.GENARRATIVE_SPACETIME_DATABASE || '',
|
||||
passthrough: [],
|
||||
server: env.GENARRATIVE_SPACETIME_SERVER || '',
|
||||
serverUrl: env.GENARRATIVE_SPACETIME_SERVER_URL || '',
|
||||
token: env.GENARRATIVE_SPACETIME_TOKEN || '',
|
||||
useHttp: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
const readValue = () => {
|
||||
const value = argv[index + 1];
|
||||
if (!value || value.startsWith('--')) {
|
||||
throw new Error(`${arg} 缺少参数值。`);
|
||||
}
|
||||
index += 1;
|
||||
return value.trim();
|
||||
};
|
||||
if (arg === '--database') {
|
||||
options.database = readValue();
|
||||
} else if (arg === '--server') {
|
||||
options.server = readValue();
|
||||
} else if (arg === '--server-url') {
|
||||
options.serverUrl = readValue();
|
||||
} else if (arg === '--token') {
|
||||
options.token = readValue();
|
||||
} else if (arg === '--chunk-size') {
|
||||
options.chunkSize = parsePositiveInteger(readValue(), arg);
|
||||
} else if (arg === '--apply') {
|
||||
options.apply = true;
|
||||
} else if (arg === '--use-http') {
|
||||
options.useHttp = true;
|
||||
} else if (arg === '--no-config' || arg === '--anonymous') {
|
||||
options.passthrough.push(arg);
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
options.help = true;
|
||||
} else {
|
||||
throw new Error(`未知参数: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (options.chunkSize > DEFAULT_CHUNK_SIZE) {
|
||||
throw new Error(`--chunk-size 不能超过 ${DEFAULT_CHUNK_SIZE}。`);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
export function buildCleanupInput({
|
||||
scope,
|
||||
cursor = null,
|
||||
limit,
|
||||
dryRun,
|
||||
expectedBatchSha256 = null,
|
||||
}) {
|
||||
if (!SCOPES.includes(scope)) {
|
||||
throw new Error(`未知普通图片 assetKind 清理 scope: ${scope}`);
|
||||
}
|
||||
if (!Number.isInteger(limit) || limit < 1) {
|
||||
throw new Error('普通图片 assetKind 清理 limit 必须是正整数。');
|
||||
}
|
||||
if (scope === 'canvas' && limit > CANVAS_MAX_CHUNK_SIZE) {
|
||||
throw new Error(`canvas scope limit 不能超过 ${CANVAS_MAX_CHUNK_SIZE}。`);
|
||||
}
|
||||
if (!dryRun && !SHA256_PATTERN.test(expectedBatchSha256 || '')) {
|
||||
throw new Error('apply 必须绑定 dry-run 返回的 64 位 batch SHA-256。');
|
||||
}
|
||||
return {
|
||||
scope,
|
||||
cursor: encodeSpacetimeCliOption(cursor),
|
||||
limit,
|
||||
dry_run: dryRun,
|
||||
expected_batch_sha_256: encodeSpacetimeCliOption(
|
||||
dryRun ? null : expectedBatchSha256,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function scopeLimit(scope, chunkSize) {
|
||||
return scope === 'canvas'
|
||||
? Math.min(chunkSize, CANVAS_MAX_CHUNK_SIZE)
|
||||
: chunkSize;
|
||||
}
|
||||
|
||||
function assertSafeBatch(result, scope) {
|
||||
ensureProcedureOk(result);
|
||||
if (result.scope !== scope) {
|
||||
throw new Error(`procedure 返回 scope ${result.scope},预期为 ${scope}。`);
|
||||
}
|
||||
if (result.blocker_count !== 0 || result.blocker_samples.length !== 0) {
|
||||
throw new Error(`${scope} scope 存在 ${result.blocker_count} 个 blocker。`);
|
||||
}
|
||||
if (!SHA256_PATTERN.test(result.batch_sha256 || '')) {
|
||||
throw new Error(`${scope} scope 未返回有效的 batch SHA-256。`);
|
||||
}
|
||||
}
|
||||
|
||||
async function callBatch(options, input) {
|
||||
return options.useHttp
|
||||
? callSpacetimeProcedure(options, PROCEDURE_NAME, input)
|
||||
: callSpacetimeProcedureViaCli(options, PROCEDURE_NAME, input);
|
||||
}
|
||||
|
||||
export async function scanScopes(
|
||||
options,
|
||||
{ apply = false, verifyZero = false, callProcedure = callBatch } = {},
|
||||
) {
|
||||
const summaries = [];
|
||||
for (const scope of SCOPES) {
|
||||
let cursor = null;
|
||||
const seenCursors = new Set();
|
||||
const summary = {
|
||||
scope,
|
||||
scanned_count: 0,
|
||||
matched_count: 0,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 0,
|
||||
batches: 0,
|
||||
};
|
||||
do {
|
||||
const limit = scopeLimit(scope, options.chunkSize);
|
||||
const dryRun = await callProcedure(
|
||||
options,
|
||||
buildCleanupInput({ scope, cursor, limit, dryRun: true }),
|
||||
);
|
||||
assertSafeBatch(dryRun, scope);
|
||||
summary.scanned_count += dryRun.scanned_count;
|
||||
summary.matched_count += dryRun.matched_count;
|
||||
summary.cleaned_field_count += dryRun.cleaned_field_count;
|
||||
summary.batches += 1;
|
||||
|
||||
if (verifyZero && dryRun.matched_count !== 0) {
|
||||
throw new Error(
|
||||
`${scope} scope apply 后复核仍有 ${dryRun.matched_count} 行待清理。`,
|
||||
);
|
||||
}
|
||||
if (apply && dryRun.matched_count > 0) {
|
||||
const applied = await callProcedure(
|
||||
options,
|
||||
buildCleanupInput({
|
||||
scope,
|
||||
cursor,
|
||||
limit,
|
||||
dryRun: false,
|
||||
expectedBatchSha256: dryRun.batch_sha256,
|
||||
}),
|
||||
);
|
||||
assertSafeBatch(applied, scope);
|
||||
if (applied.batch_sha256 !== dryRun.batch_sha256) {
|
||||
throw new Error(`${scope} scope apply 返回的 batch SHA-256 与 dry-run 不一致。`);
|
||||
}
|
||||
if (applied.updated_count !== dryRun.matched_count) {
|
||||
throw new Error(
|
||||
`${scope} scope apply 更新 ${applied.updated_count} 行,dry-run 匹配 ${dryRun.matched_count} 行。`,
|
||||
);
|
||||
}
|
||||
if (applied.cleaned_field_count !== dryRun.cleaned_field_count) {
|
||||
throw new Error(`${scope} scope apply 返回的清理字段数与 dry-run 不一致。`);
|
||||
}
|
||||
summary.updated_count += applied.updated_count;
|
||||
}
|
||||
const nextCursor = dryRun.has_more ? dryRun.next_cursor : null;
|
||||
if (dryRun.has_more && !nextCursor) {
|
||||
throw new Error(`${scope} scope 声明 has_more 但未返回 next_cursor。`);
|
||||
}
|
||||
if (nextCursor && seenCursors.has(nextCursor)) {
|
||||
throw new Error(
|
||||
`${scope} scope 返回了重复的 next_cursor(SHA-256: ${sha256(nextCursor)})。`,
|
||||
);
|
||||
}
|
||||
if (nextCursor) {
|
||||
seenCursors.add(nextCursor);
|
||||
}
|
||||
cursor = nextCursor;
|
||||
} while (cursor);
|
||||
summaries.push(summary);
|
||||
}
|
||||
return summaries;
|
||||
}
|
||||
|
||||
export async function main(argv = process.argv.slice(2)) {
|
||||
const options = parseOptions(argv);
|
||||
if (options.help) {
|
||||
console.log(usage());
|
||||
return;
|
||||
}
|
||||
if (!options.database) {
|
||||
throw new Error('必须显式传入 --database。');
|
||||
}
|
||||
if (!options.server && !options.serverUrl) {
|
||||
throw new Error('必须显式传入 --server / --server-url,不使用默认 cloud target。');
|
||||
}
|
||||
if (options.useHttp && !options.token) {
|
||||
throw new Error('--use-http 需要通过 --token 或 GENARRATIVE_SPACETIME_TOKEN 提供身份。');
|
||||
}
|
||||
|
||||
const migration = await scanScopes(options, { apply: options.apply });
|
||||
const verification = options.apply
|
||||
? await scanScopes(options, { verifyZero: true })
|
||||
: null;
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
procedure: PROCEDURE_NAME,
|
||||
applied: options.apply,
|
||||
scope_order: SCOPES,
|
||||
migration,
|
||||
verification,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
if (!options.apply) {
|
||||
console.log('全量 dry-run 已通过;确认输出后追加 --apply 重跑。');
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
|
||||
main().catch((error) => {
|
||||
console.error(
|
||||
`[spacetime:editor-image-asset-kind:clean] ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildCleanupInput,
|
||||
parseOptions,
|
||||
scanScopes,
|
||||
} from './spacetime-clean-editor-image-asset-kind.mjs';
|
||||
|
||||
describe('普通图片 assetKind 清理脚本', () => {
|
||||
it('默认 dry-run 并要求调用方显式选择 apply', () => {
|
||||
expect(
|
||||
parseOptions(['--database', 'genarrative-prod', '--server', 'prod'], {}),
|
||||
).toMatchObject({
|
||||
apply: false,
|
||||
chunkSize: 25,
|
||||
database: 'genarrative-prod',
|
||||
server: 'prod',
|
||||
});
|
||||
});
|
||||
|
||||
it('编码 cursor、限制 canvas 批量并要求 apply hash', () => {
|
||||
expect(
|
||||
buildCleanupInput({
|
||||
scope: 'asset',
|
||||
cursor: 'asset-25',
|
||||
limit: 25,
|
||||
dryRun: true,
|
||||
}),
|
||||
).toEqual({
|
||||
scope: 'asset',
|
||||
cursor: [0, 'asset-25'],
|
||||
limit: 25,
|
||||
dry_run: true,
|
||||
expected_batch_sha_256: null,
|
||||
});
|
||||
expect(() =>
|
||||
buildCleanupInput({ scope: 'canvas', limit: 6, dryRun: true }),
|
||||
).toThrow('canvas scope limit');
|
||||
expect(() =>
|
||||
buildCleanupInput({ scope: 'showcase', limit: 25, dryRun: false }),
|
||||
).toThrow('batch SHA-256');
|
||||
});
|
||||
|
||||
it('按固定 scope 顺序执行 hash 绑定 apply 并保留字段计数', async () => {
|
||||
const calls: Array<Record<string, unknown>> = [];
|
||||
const callProcedure = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => {
|
||||
calls.push(input);
|
||||
const dryRun = input.dry_run === true;
|
||||
const scope = String(input.scope);
|
||||
const hashDigit = {
|
||||
asset: 'a',
|
||||
'project-resource': 'b',
|
||||
showcase: 'c',
|
||||
canvas: 'd',
|
||||
}[scope]!;
|
||||
return {
|
||||
ok: true,
|
||||
scope,
|
||||
dry_run: dryRun,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: dryRun ? 0 : 1,
|
||||
cleaned_field_count: scope === 'canvas' ? 3 : 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: hashDigit.repeat(64),
|
||||
error_message: null,
|
||||
};
|
||||
};
|
||||
|
||||
const summaries = await scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ apply: true, callProcedure },
|
||||
);
|
||||
|
||||
expect(summaries.map((summary) => summary.scope)).toEqual([
|
||||
'asset',
|
||||
'project-resource',
|
||||
'showcase',
|
||||
'canvas',
|
||||
]);
|
||||
expect(calls.map((call) => `${call.scope}:${call.dry_run}`)).toEqual([
|
||||
'asset:true',
|
||||
'asset:false',
|
||||
'project-resource:true',
|
||||
'project-resource:false',
|
||||
'showcase:true',
|
||||
'showcase:false',
|
||||
'canvas:true',
|
||||
'canvas:false',
|
||||
]);
|
||||
expect(summaries.at(-1)?.cleaned_field_count).toBe(3);
|
||||
expect(calls.at(-2)?.limit).toBe(5);
|
||||
for (let index = 1; index < calls.length; index += 2) {
|
||||
expect(calls[index]?.expected_batch_sha_256).toEqual([
|
||||
0,
|
||||
String(
|
||||
calls[index - 1]?.scope === 'asset'
|
||||
? 'a'
|
||||
: calls[index - 1]?.scope === 'project-resource'
|
||||
? 'b'
|
||||
: calls[index - 1]?.scope === 'showcase'
|
||||
? 'c'
|
||||
: 'd',
|
||||
).repeat(64),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('拒绝 apply 后计数漂移与复核残留', async () => {
|
||||
const driftingCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: input.dry_run,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: input.dry_run ? 0 : 1,
|
||||
cleaned_field_count: input.dry_run ? 2 : 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: 'a'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
await expect(
|
||||
scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ apply: true, callProcedure: driftingCall },
|
||||
),
|
||||
).rejects.toThrow('清理字段数');
|
||||
|
||||
const residualCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha256: 'b'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
await expect(
|
||||
scanScopes(
|
||||
{ chunkSize: 25 },
|
||||
{ verifyZero: true, callProcedure: residualCall },
|
||||
),
|
||||
).rejects.toThrow('apply 后复核仍有');
|
||||
});
|
||||
|
||||
it('报告游标循环时不泄露原始标识', async () => {
|
||||
const privateCursor = 'private-asset-id';
|
||||
const loopingCall = async (
|
||||
_options: Record<string, unknown>,
|
||||
input: Record<string, unknown>,
|
||||
) => ({
|
||||
ok: true,
|
||||
scope: input.scope,
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 0,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 0,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: privateCursor,
|
||||
has_more: true,
|
||||
batch_sha256: 'c'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
let message = '';
|
||||
try {
|
||||
await scanScopes({ chunkSize: 25 }, { callProcedure: loopingCall });
|
||||
} catch (error) {
|
||||
message = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
expect(message).toContain('SHA-256');
|
||||
expect(message).not.toContain(privateCursor);
|
||||
});
|
||||
});
|
||||
@@ -294,8 +294,10 @@ function normalizeProcedureResult(value, procedureName) {
|
||||
function normalizeSatsObject(value, procedureName) {
|
||||
const normalized = normalizeSatsValue(value);
|
||||
if (
|
||||
procedureName !==
|
||||
'normalize_editor_character_animation_metadata_and_return' ||
|
||||
![
|
||||
'normalize_editor_character_animation_metadata_and_return',
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
].includes(procedureName) ||
|
||||
!normalized ||
|
||||
typeof normalized !== 'object' ||
|
||||
Array.isArray(normalized)
|
||||
@@ -311,6 +313,27 @@ function normalizeSatsObject(value, procedureName) {
|
||||
}
|
||||
|
||||
function normalizeSatsProduct(value, procedureName) {
|
||||
if (
|
||||
procedureName === 'clean_editor_image_asset_kind_and_return' &&
|
||||
value.length === 13
|
||||
) {
|
||||
return {
|
||||
ok: normalizeSatsValue(value[0]),
|
||||
scope: normalizeSatsValue(value[1]),
|
||||
dry_run: normalizeSatsValue(value[2]),
|
||||
scanned_count: normalizeSatsValue(value[3]),
|
||||
matched_count: normalizeSatsValue(value[4]),
|
||||
updated_count: normalizeSatsValue(value[5]),
|
||||
cleaned_field_count: normalizeSatsValue(value[6]),
|
||||
blocker_count: normalizeSatsValue(value[7]),
|
||||
blocker_samples: normalizeSatsValue(value[8]),
|
||||
next_cursor: normalizeSatsOption(value[9]),
|
||||
has_more: normalizeSatsValue(value[10]),
|
||||
batch_sha256: normalizeSatsValue(value[11]),
|
||||
error_message: normalizeSatsOption(value[12]),
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
|
||||
value.length === 19
|
||||
|
||||
@@ -174,4 +174,53 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
|
||||
});
|
||||
expect(result).not.toHaveProperty('batch_sha_256');
|
||||
});
|
||||
|
||||
it('normalizes ordinary image assetKind cleanup results', () => {
|
||||
const tupleResult = parseProcedureResult(
|
||||
JSON.stringify([
|
||||
true,
|
||||
'canvas',
|
||||
true,
|
||||
5,
|
||||
2,
|
||||
0,
|
||||
4,
|
||||
0,
|
||||
[],
|
||||
[0, 'canvas-5'],
|
||||
true,
|
||||
'c'.repeat(64),
|
||||
[1],
|
||||
]),
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
);
|
||||
expect(tupleResult).toMatchObject({
|
||||
scope: 'canvas',
|
||||
cleaned_field_count: 4,
|
||||
next_cursor: 'canvas-5',
|
||||
batch_sha256: 'c'.repeat(64),
|
||||
error_message: null,
|
||||
});
|
||||
|
||||
const objectResult = parseProcedureResult(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
scope: 'asset',
|
||||
dry_run: true,
|
||||
scanned_count: 1,
|
||||
matched_count: 1,
|
||||
updated_count: 0,
|
||||
cleaned_field_count: 1,
|
||||
blocker_count: 0,
|
||||
blocker_samples: [],
|
||||
next_cursor: null,
|
||||
has_more: false,
|
||||
batch_sha_256: 'd'.repeat(64),
|
||||
error_message: null,
|
||||
}),
|
||||
'clean_editor_image_asset_kind_and_return',
|
||||
);
|
||||
expect(objectResult.batch_sha256).toBe('d'.repeat(64));
|
||||
expect(objectResult).not.toHaveProperty('batch_sha_256');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user