Files
Genarrative/scripts/spacetime-repair-editor-canvas-resources.mjs
kdletters 071faa482c 统一 Rust 与 TypeScript 格式化门禁
纳入 AGC Cargo workspace 的统一 rustfmt 检查与格式化入口

完成项目 TypeScript/Prettier 与 Rust 全量格式化

修复 Pingora expected executable 门禁的空白敏感误报

同步开发运维文档与 AGC skill pack 格式化忽略规则
2026-09-01 16:28:34 +08:00

695 lines
21 KiB
JavaScript

#!/usr/bin/env node
import { createHash } from 'node:crypto';
import { lstat, readFile, realpath } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { callSpacetimeProcedureViaCli } from './spacetime-migration-common.mjs';
const PROCEDURE_NAME = 'repair_editor_canvas_resources_and_return';
const PLAN_SCHEMA_VERSION = 1;
const MAX_PLAN_BYTES = 1024 * 1024;
const MAX_CANVAS_COUNT = 100;
const MAX_ACTIONS_PER_CANVAS = 16;
const SHA256_PATTERN = /^[0-9a-f]{64}$/u;
const REPO_ROOT = path.resolve(fileURLToPath(new URL('..', import.meta.url)));
const ACTION_KINDS = new Set([
'reuse_project_resource',
'restore_project_resource',
]);
const RESTORABLE_ASSET_KINDS = new Set(['background-music', 'sound-effect']);
function usage() {
return `用法:
node scripts/spacetime-repair-editor-canvas-resources.mjs \\
--database <name> --server <name-or-url> --plan-file <absolute-0600-json> \\
[--apply --confirm-plan-sha256 <dry-run-plan-sha256>]
默认逐画布调用 ${PROCEDURE_NAME} 执行 dry-run,不修改数据。
只有显式追加 --apply,并用 --confirm-plan-sha256 绑定同一份 dry-run plan,才执行写入。
plan 必须位于仓库外,是当前用户持有、权限严格为 0600 的普通文件,且路径链不能包含符号链接。
必须使用已授权 database migration operator 的 spacetime CLI 登录态,并显式指定 server。`;
}
export function parseOptions(argv) {
const options = {
apply: false,
confirmPlanSha256: '',
database: process.env.GENARRATIVE_SPACETIME_DATABASE || '',
passthrough: [],
planFile: '',
server: process.env.GENARRATIVE_SPACETIME_SERVER || '',
serverUrl: process.env.GENARRATIVE_SPACETIME_SERVER_URL || '',
};
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 === '--plan-file') {
options.planFile = readValue();
} else if (arg === '--confirm-plan-sha256') {
options.confirmPlanSha256 = readValue().toLowerCase();
} else if (arg === '--apply') {
options.apply = true;
} else if (arg === '--no-config') {
options.passthrough.push(arg);
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return options;
}
export async function readRepairPlan(planFile, { repoRoot = REPO_ROOT } = {}) {
assertSafeAbsolutePath(planFile, '--plan-file');
const resolvedPath = path.resolve(planFile);
const resolvedRepoRoot = path.resolve(repoRoot);
let canonicalPath;
let canonicalRepoRoot;
try {
canonicalPath = await realpath(resolvedPath);
canonicalRepoRoot = await realpath(resolvedRepoRoot);
} catch {
throw new Error('--plan-file 无法解析或不存在。');
}
const pathWithoutSystemTempAlias =
await normalizeSystemTempAlias(resolvedPath);
if (canonicalPath !== pathWithoutSystemTempAlias) {
throw new Error('--plan-file 路径链不能包含符号链接。');
}
if (isPathInside(canonicalPath, canonicalRepoRoot)) {
throw new Error('--plan-file 必须位于仓库外,避免真实 ID 进入工作区。');
}
let metadata;
try {
metadata = await lstat(canonicalPath);
} catch {
throw new Error('--plan-file 无法读取元数据。');
}
if (!metadata.isFile() || metadata.isSymbolicLink()) {
throw new Error('--plan-file 必须是普通文件且不能是符号链接。');
}
if ((metadata.mode & 0o777) !== 0o600) {
throw new Error('--plan-file 权限必须严格为 0600。');
}
if (
typeof process.getuid === 'function' &&
metadata.uid !== process.getuid()
) {
throw new Error('--plan-file 必须由当前执行用户持有。');
}
if (metadata.size <= 0 || metadata.size > MAX_PLAN_BYTES) {
throw new Error(`--plan-file 大小必须在 1 到 ${MAX_PLAN_BYTES} 字节之间。`);
}
let bytes;
try {
bytes = await readFile(canonicalPath);
} catch {
throw new Error('--plan-file 无法读取。');
}
const planSha256 = createHash('sha256').update(bytes).digest('hex');
let value;
try {
value = JSON.parse(bytes.toString('utf8'));
} catch {
throw new Error('--plan-file 不是合法 JSON。');
}
return {
plan: validateRepairPlan(value),
planSha256,
};
}
export function validateRepairPlan(value) {
assertPlainObject(value, 'plan');
assertExactKeys(
value,
[
'schema_version',
'expected_canvas_count',
'expected_action_count',
'canvases',
],
'plan',
);
if (value.schema_version !== PLAN_SCHEMA_VERSION) {
throw new Error(`plan.schema_version 必须为 ${PLAN_SCHEMA_VERSION}。`);
}
const expectedCanvasCount = readPositiveInteger(
value.expected_canvas_count,
'plan.expected_canvas_count',
);
const expectedActionCount = readPositiveInteger(
value.expected_action_count,
'plan.expected_action_count',
);
if (!Array.isArray(value.canvases) || value.canvases.length === 0) {
throw new Error('plan.canvases 必须是非空数组。');
}
if (value.canvases.length > MAX_CANVAS_COUNT) {
throw new Error(`plan.canvases 不能超过 ${MAX_CANVAS_COUNT} 项。`);
}
const canvasIds = new Set();
const projectIds = new Set();
let actionCount = 0;
const canvases = value.canvases.map((canvas, canvasIndex) => {
const label = `plan.canvases[${canvasIndex}]`;
assertPlainObject(canvas, label);
assertExactKeys(
canvas,
[
'canvas_id',
'project_id',
'owner_user_id',
'expected_revision',
'expected_canvas_layout_sha256',
'expected_project_layout_sha256',
'actions',
],
label,
);
const normalized = {
canvas_id: readIdentifier(canvas.canvas_id, `${label}.canvas_id`),
project_id: readIdentifier(canvas.project_id, `${label}.project_id`),
owner_user_id: readIdentifier(
canvas.owner_user_id,
`${label}.owner_user_id`,
),
expected_revision: readNonNegativeInteger(
canvas.expected_revision,
`${label}.expected_revision`,
),
expected_canvas_layout_sha256: readSha256(
canvas.expected_canvas_layout_sha256,
`${label}.expected_canvas_layout_sha256`,
),
expected_project_layout_sha256: readSha256(
canvas.expected_project_layout_sha256,
`${label}.expected_project_layout_sha256`,
),
actions: [],
};
if (
canvasIds.has(normalized.canvas_id) ||
projectIds.has(normalized.project_id)
) {
throw new Error(
`${label}.canvas_id 或 project_id 与 plan 内其它画布重复。`,
);
}
canvasIds.add(normalized.canvas_id);
projectIds.add(normalized.project_id);
if (!Array.isArray(canvas.actions) || canvas.actions.length === 0) {
throw new Error(`${label}.actions 必须是非空数组。`);
}
if (canvas.actions.length > MAX_ACTIONS_PER_CANVAS) {
throw new Error(
`${label}.actions 不能超过 ${MAX_ACTIONS_PER_CANVAS} 项。`,
);
}
const layerIds = new Set();
normalized.actions = canvas.actions.map((action, actionIndex) => {
const actionLabel = `${label}.actions[${actionIndex}]`;
const normalizedAction = validateRepairAction(action, actionLabel);
if (layerIds.has(normalizedAction.layer_id)) {
throw new Error(`${actionLabel}.layer_id 在同一画布内重复。`);
}
layerIds.add(normalizedAction.layer_id);
return normalizedAction;
});
actionCount += normalized.actions.length;
return normalized;
});
if (expectedCanvasCount !== canvases.length) {
throw new Error('plan.expected_canvas_count 与 canvases 实际数量不一致。');
}
if (expectedActionCount !== actionCount) {
throw new Error('plan.expected_action_count 与 actions 实际数量不一致。');
}
return {
schema_version: PLAN_SCHEMA_VERSION,
expected_canvas_count: expectedCanvasCount,
expected_action_count: expectedActionCount,
canvases,
};
}
function validateRepairAction(action, label) {
assertPlainObject(action, label);
assertExactKeys(
action,
[
'action_kind',
'layer_id',
'expected_resource_id',
'replacement_resource_id',
'expected_layout_source_resource_id',
'asset_object_id',
'asset_kind',
],
label,
);
const actionKind = readIdentifier(action.action_kind, `${label}.action_kind`);
if (!ACTION_KINDS.has(actionKind)) {
throw new Error(
`${label}.action_kind 只支持 reuse_project_resource 或 restore_project_resource。`,
);
}
const normalized = {
action_kind: actionKind,
layer_id: readIdentifier(action.layer_id, `${label}.layer_id`),
expected_resource_id: readIdentifier(
action.expected_resource_id,
`${label}.expected_resource_id`,
),
replacement_resource_id: readNullableIdentifier(
action.replacement_resource_id,
`${label}.replacement_resource_id`,
),
expected_layout_source_resource_id: readNullableIdentifier(
action.expected_layout_source_resource_id,
`${label}.expected_layout_source_resource_id`,
),
asset_object_id: readNullableIdentifier(
action.asset_object_id,
`${label}.asset_object_id`,
),
asset_kind: readNullableIdentifier(
action.asset_kind,
`${label}.asset_kind`,
),
};
if (actionKind === 'reuse_project_resource') {
if (!normalized.replacement_resource_id) {
throw new Error(
`${label}.replacement_resource_id 为复用资源动作的必填项。`,
);
}
if (!normalized.expected_layout_source_resource_id) {
throw new Error(
`${label}.expected_layout_source_resource_id 为复用资源动作的必填项。`,
);
}
if (
normalized.replacement_resource_id === normalized.expected_resource_id
) {
throw new Error(
`${label}.replacement_resource_id 不能等于 expected_resource_id。`,
);
}
if (normalized.asset_object_id || normalized.asset_kind) {
throw new Error(
`${label} 的复用资源动作不能携带 asset_object_id / asset_kind。`,
);
}
} else {
if (normalized.replacement_resource_id) {
throw new Error(
`${label} 的补资源动作不能携带 replacement_resource_id。`,
);
}
if (normalized.expected_layout_source_resource_id) {
throw new Error(
`${label} 的补资源动作不能携带 expected_layout_source_resource_id。`,
);
}
if (!normalized.asset_object_id || !normalized.asset_kind) {
throw new Error(
`${label}.asset_object_id / asset_kind 为补资源动作的必填项。`,
);
}
if (!RESTORABLE_ASSET_KINDS.has(normalized.asset_kind)) {
throw new Error(
`${label}.asset_kind 只支持 background-music 或 sound-effect。`,
);
}
}
return normalized;
}
export async function executeRepairPlan({
options,
plan,
planSha256,
callProcedure = callSpacetimeProcedureViaCli,
updatedAtMicros = Date.now() * 1_000,
}) {
validateExecutionOptions(options, planSha256);
const summary = {
ok: true,
dry_run: !options.apply,
plan_sha256: planSha256,
canvas_count: plan.expected_canvas_count,
action_count: plan.expected_action_count,
processed_canvas_count: 0,
matched_layer_count: 0,
updated_layer_count: 0,
inserted_resource_count: 0,
already_repaired_canvas_count: 0,
failures: [],
};
for (let index = 0; index < plan.canvases.length; index += 1) {
const canvas = plan.canvases[index];
const ordinal = index + 1;
const input = buildProcedureInput(canvas, updatedAtMicros, !options.apply);
let result;
try {
result = await callProcedure(options, PROCEDURE_NAME, input);
} catch {
throw new Error(
`第 ${ordinal} 个画布调用失败;详细信息已隐藏,且未继续处理后续画布。`,
);
}
const normalizedResult = normalizePublicResult(result, !options.apply);
if (!normalizedResult.ok) {
const failure = {
canvas_ordinal: ordinal,
error_code: normalizedResult.error_code,
};
if (options.apply) {
throw new Error(
`第 ${ordinal} 个画布修复失败,error_code=${failure.error_code};此前成功处理 ${summary.processed_canvas_count} 个画布,已停止。`,
);
}
summary.failures.push(failure);
continue;
}
summary.processed_canvas_count += 1;
summary.matched_layer_count += normalizedResult.matched_layer_count;
summary.updated_layer_count += normalizedResult.updated_layer_count;
summary.inserted_resource_count += normalizedResult.inserted_resource_count;
if (normalizedResult.already_repaired) {
summary.already_repaired_canvas_count += 1;
}
}
summary.ok = summary.failures.length === 0;
return summary;
}
export function buildProcedureInput(canvas, updatedAtMicros, dryRun) {
return {
canvas_id: canvas.canvas_id,
project_id: canvas.project_id,
owner_user_id: canvas.owner_user_id,
expected_revision: canvas.expected_revision,
expected_canvas_layout_sha256: canvas.expected_canvas_layout_sha256,
expected_project_layout_sha256: canvas.expected_project_layout_sha256,
remaps: canvas.actions
.filter((action) => action.action_kind === 'reuse_project_resource')
.map((action) => ({
layer_id: action.layer_id,
expected_missing_resource_id: action.expected_resource_id,
replacement_resource_id: action.replacement_resource_id,
expected_layout_source_resource_id:
action.expected_layout_source_resource_id,
})),
restores: canvas.actions
.filter((action) => action.action_kind === 'restore_project_resource')
.map((action) => ({
layer_id: action.layer_id,
resource_id: action.expected_resource_id,
asset_object_id: action.asset_object_id,
asset_kind: action.asset_kind,
})),
updated_at_micros: updatedAtMicros,
dry_run: dryRun,
};
}
function validateExecutionOptions(options, planSha256) {
validateConnectionOptions(options);
if (options.apply) {
if (!SHA256_PATTERN.test(options.confirmPlanSha256 || '')) {
throw new Error('--apply 必须传入 64 位 --confirm-plan-sha256。');
}
if (options.confirmPlanSha256 !== planSha256) {
throw new Error('--confirm-plan-sha256 与当前 plan 不一致,拒绝 apply。');
}
} else if (options.confirmPlanSha256) {
throw new Error('--confirm-plan-sha256 只能与 --apply 一起使用。');
}
}
function validateConnectionOptions(options) {
if (!options.database) {
throw new Error(
'必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。',
);
}
if (!options.server && !options.serverUrl) {
throw new Error(
'必须显式传入 --server / --server-url,不使用默认 cloud target。',
);
}
}
function normalizePublicResult(result, expectedDryRun) {
if (!result || typeof result !== 'object' || Array.isArray(result)) {
throw new Error('procedure 返回结构无效;详细信息已隐藏。');
}
if (typeof result.ok !== 'boolean') {
throw new Error('procedure 返回的 ok 无效。');
}
if (!result.ok) {
return {
ok: false,
matched_layer_count: 0,
updated_layer_count: 0,
inserted_resource_count: 0,
already_repaired: false,
error_code: 'repair_rejected',
};
}
const repair = result.repair;
if (
!repair ||
typeof repair !== 'object' ||
Array.isArray(repair) ||
repair.dry_run !== expectedDryRun
) {
throw new Error('procedure 返回的 repair / dry_run 与请求不一致。');
}
return {
ok: true,
matched_layer_count: readResultCount(
repair.matched_layer_count,
'matched_layer_count',
),
updated_layer_count: readResultCount(
repair.remapped_layer_count,
'remapped_layer_count',
),
inserted_resource_count: readResultCount(
repair.restored_resource_count,
'restored_resource_count',
),
already_repaired: repair.already_repaired === true,
error_code: null,
};
}
function readResultCount(value, name) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`procedure 返回的 ${name} 无效。`);
}
return value;
}
function assertSafeAbsolutePath(value, name) {
if (typeof value !== 'string' || !value || !path.isAbsolute(value)) {
throw new Error(`${name} 必须是绝对路径。`);
}
if (
value.includes('\0') ||
/[\r\n]/u.test(value) ||
value === path.parse(value).root
) {
throw new Error(`${name} 不是安全的文件路径。`);
}
}
function isPathInside(candidate, root) {
const relative = path.relative(root, candidate);
return (
relative === '' ||
(!relative.startsWith('..') && !path.isAbsolute(relative))
);
}
async function normalizeSystemTempAlias(candidate) {
const resolvedTempRoot = path.resolve(tmpdir());
if (!isPathInside(candidate, resolvedTempRoot)) {
return candidate;
}
const canonicalTempRoot = await realpath(resolvedTempRoot);
return path.join(
canonicalTempRoot,
path.relative(resolvedTempRoot, candidate),
);
}
function assertPlainObject(value, label) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`${label} 必须是对象。`);
}
}
function assertExactKeys(value, expectedKeys, label) {
const expected = new Set(expectedKeys);
const actualKeys = Object.keys(value);
const unexpected = actualKeys.filter((key) => !expected.has(key));
const missing = expectedKeys.filter((key) => !Object.hasOwn(value, key));
if (unexpected.length > 0 || missing.length > 0) {
throw new Error(`${label} 字段不完整或包含未知字段。`);
}
}
function readIdentifier(value, label) {
if (
typeof value !== 'string' ||
value.length === 0 ||
value.length > 512 ||
value !== value.trim() ||
value.includes('\0') ||
/[\r\n]/u.test(value)
) {
throw new Error(`${label} 必须是非空且不含控制字符的字符串。`);
}
return value;
}
function readNullableIdentifier(value, label) {
return value === null ? null : readIdentifier(value, label);
}
function readSha256(value, label) {
if (typeof value !== 'string' || !SHA256_PATTERN.test(value.toLowerCase())) {
throw new Error(`${label} 必须是 64 位十六进制 SHA-256。`);
}
return value.toLowerCase();
}
function readPositiveInteger(value, label) {
if (!Number.isSafeInteger(value) || value <= 0) {
throw new Error(`${label} 必须是正整数。`);
}
return value;
}
function readNonNegativeInteger(value, label) {
if (!Number.isSafeInteger(value) || value < 0) {
throw new Error(`${label} 必须是非负整数。`);
}
return value;
}
export async function runCli(argv, dependencies = {}) {
const options = parseOptions(argv);
if (options.help) {
console.log(usage());
return { ok: true, help: true };
}
if (!options.planFile) {
throw new Error('必须传入 --plan-file。');
}
validateConnectionOptions(options);
const readPlan = dependencies.readPlan || readRepairPlan;
const { plan, planSha256 } = await readPlan(options.planFile);
const summary = await executeRepairPlan({
options,
plan,
planSha256,
callProcedure: dependencies.callProcedure,
updatedAtMicros: dependencies.updatedAtMicros,
});
let verification = null;
if (options.apply && summary.ok) {
const verificationSummary = await executeRepairPlan({
options: {
...options,
apply: false,
confirmPlanSha256: '',
},
plan,
planSha256,
callProcedure: dependencies.callProcedure,
updatedAtMicros: dependencies.updatedAtMicros,
});
if (
!verificationSummary.ok ||
verificationSummary.processed_canvas_count !==
plan.expected_canvas_count ||
verificationSummary.already_repaired_canvas_count !==
plan.expected_canvas_count
) {
throw new Error('apply 后自动 dry-run 复核未全部命中 already_repaired。');
}
verification = {
ok: true,
processed_canvas_count: verificationSummary.processed_canvas_count,
already_repaired_canvas_count:
verificationSummary.already_repaired_canvas_count,
};
}
console.log(JSON.stringify({ ...summary, verification }, null, 2));
if (!options.apply && summary.ok) {
console.log(
`dry-run 已通过;确认匿名计数后,使用 --apply --confirm-plan-sha256 ${summary.plan_sha256} 重跑同一 plan。`,
);
}
if (!summary.ok) {
process.exitCode = 1;
}
return summary;
}
function isCliEntry() {
const entry = process.argv[1];
return entry
? import.meta.url === `file://${entry.replace(/\\/gu, '/')}`
: false;
}
if (isCliEntry()) {
runCli(process.argv.slice(2)).catch((error) => {
console.error(
`[spacetime:editor-canvas-resources:repair] ${
error instanceof Error ? error.message : '操作失败;详细信息已隐藏。'
}`,
);
process.exitCode = 1;
});
}