合并远端master更新
Project CI / Repository checks (push) Successful in 51s
Project CI / Frontend tests (push) Successful in 3m7s
Project CI / Backend tests (push) Successful in 3m32s
Project CI / Native shell tests (push) Successful in 16m20s

融合移动端提示恢复与远端钱包刷新改动

# Conflicts:
#	src/components/platform-entry/PlatformEntryActiveFlowShell.test.tsx
#	src/components/platform-entry/PlatformEntryActiveFlowShell.tsx
This commit is contained in:
2026-08-08 15:56:47 +08:00
329 changed files with 48030 additions and 6615 deletions
+1
View File
@@ -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-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" "数据库迁移撤权脚本"
copy_required_file "${SCRIPT_DIR}/database-backup-to-oss.mjs" "${TARGET_DIR}/scripts/database-backup-to-oss.mjs" "数据库 OSS 备份脚本"
+17
View File
@@ -36,6 +36,7 @@ verify_cache_lock() {
fi
if [[ "${GENARRATIVE_GITEA_CI_CHECK_RUNTIME:-0}" == '1' ]]; then
printf '%s_cache_lock=partial\n' "${cache_name}"
printf '::warning title=CI dependency cache is partial::%s lock differs from the prebuilt image; refresh the trusted CI image after this lock change lands.\n' "${cache_name}"
return
fi
echo "${cache_name} cache lock does not match the verification checkout." >&2
@@ -43,22 +44,34 @@ verify_cache_lock() {
}
npm_lock_path="${repo_root}/package-lock.json"
agc_npm_lock_path="${repo_root}/apps/ai-game-creator-shell/package-lock.json"
server_rust_lock_path="${repo_root}/server-rs/Cargo.lock"
desktop_rust_lock_path="${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock"
agc_rust_lock_path="${repo_root}/apps/ai-game-creator-shell/src-tauri/Cargo.lock"
if [[ ! -f "${npm_lock_path}" ]]; then
npm_lock_path='/usr/local/share/genarrative-ci/npm/package-lock.json'
fi
if [[ ! -f "${agc_npm_lock_path}" ]]; then
agc_npm_lock_path='/usr/local/share/genarrative-ci/agc-npm/package-lock.json'
fi
if [[ ! -f "${server_rust_lock_path}" ]]; then
server_rust_lock_path='/usr/local/share/genarrative-ci/locks/server-rs.Cargo.lock'
fi
if [[ ! -f "${desktop_rust_lock_path}" ]]; then
desktop_rust_lock_path='/usr/local/share/genarrative-ci/locks/desktop-shell.Cargo.lock'
fi
if [[ ! -f "${agc_rust_lock_path}" ]]; then
agc_rust_lock_path='/usr/local/share/genarrative-ci/locks/ai-game-creator-shell.Cargo.lock'
fi
verify_cache_lock \
npm \
"${GENARRATIVE_GITEA_CI_NPM_LOCK_SHA256:-}" \
"${npm_lock_path}"
verify_cache_lock \
agc_npm \
"${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}" \
"${agc_npm_lock_path}"
verify_cache_lock \
server_rust \
"${GENARRATIVE_GITEA_CI_SERVER_RUST_LOCK_SHA256:-}" \
@@ -67,6 +80,10 @@ verify_cache_lock \
desktop_rust \
"${GENARRATIVE_GITEA_CI_DESKTOP_RUST_LOCK_SHA256:-}" \
"${desktop_rust_lock_path}"
verify_cache_lock \
agc_rust \
"${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}" \
"${agc_rust_lock_path}"
for command_name in \
bwrap \
@@ -614,6 +614,21 @@ const checks = [
includes: 'dry_run: !options.apply',
reason: '图片画布存量迁移必须默认 dry-run,只有显式 --apply 才写入。',
},
{
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
includes: "const SCOPES = ['asset', 'project-resource', 'showcase', 'canvas']",
reason: '角色动作规范化必须按固定依赖顺序执行四个 scope。',
},
{
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
includes: 'expectedBatchSha256: dryRun.batch_sha256',
reason: '角色动作规范化 apply 必须绑定同批 dry-run 返回的摘要。',
},
{
file: 'scripts/spacetime-normalize-editor-character-actions.mjs',
includes: "await scanScopes(options, { verifyZero: true })",
reason: '角色动作规范化 apply 后必须执行全量零匹配复核。',
},
{
file: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
includes: 'buildProcedureInput(canvas, updatedAtMicros, !options.apply)',
@@ -634,11 +649,21 @@ const checks = [
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
reason: 'Stdb Build 必须归档图片画布存量迁移脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
reason: 'Stdb Build 必须归档角色动作元数据规范化脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'scripts/spacetime-migrate-editor-canvas-layout.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制图片画布存量迁移脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-publish',
includes: 'scripts/spacetime-normalize-editor-character-actions.mjs',
reason: 'Stdb Publish 必须从同一上游制品复制角色动作元数据规范化脚本。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'scripts/spacetime-repair-editor-canvas-resources.mjs',
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
set -euo pipefail
max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"
base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"
if [[ ! "${max_attempts}" =~ ^[1-9][0-9]*$ ]]; then
echo 'GENARRATIVE_CI_NPM_CI_ATTEMPTS must be a positive integer.' >&2
exit 2
fi
if [[ ! "${base_delay_seconds}" =~ ^[0-9]+$ ]]; then
echo 'GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS must be a non-negative integer.' >&2
exit 2
fi
for attempt in $(seq 1 "${max_attempts}"); do
if npm ci "$@"; then
exit 0
fi
if [[ "${attempt}" -eq "${max_attempts}" ]]; then
echo "npm ci failed after ${max_attempts} attempts." >&2
exit 1
fi
sleep "$((attempt * base_delay_seconds))"
done
+15 -1
View File
@@ -4,7 +4,7 @@ set -euo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd -P)"
dockerfile_context_path="deploy/container/gitea-ci-job.Dockerfile"
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260723.1}"
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260807.1}"
runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}"
write_build_context_file_list() {
@@ -14,6 +14,10 @@ write_build_context_file_list() {
deploy/container/gitea-ci-checkout.sh \
package.json \
package-lock.json \
apps/ai-game-creator-shell/package.json \
apps/ai-game-creator-shell/package-lock.json \
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
server-rs/Cargo.toml \
server-rs/Cargo.lock \
apps/desktop-shell/src-tauri/Cargo.toml \
@@ -61,6 +65,10 @@ case "${command_name}" in
deploy/container/gitea-ci-checkout.sh \
package.json \
package-lock.json \
apps/ai-game-creator-shell/package.json \
apps/ai-game-creator-shell/package-lock.json \
apps/ai-game-creator-shell/src-tauri/Cargo.toml \
apps/ai-game-creator-shell/src-tauri/Cargo.lock \
server-rs/Cargo.toml \
server-rs/Cargo.lock \
apps/desktop-shell/src-tauri/Cargo.toml \
@@ -74,10 +82,14 @@ case "${command_name}" in
)"
npm_lock_sha256="$(sha256sum "${repo_root}/package-lock.json")"
npm_lock_sha256="${npm_lock_sha256%% *}"
agc_npm_lock_sha256="$(sha256sum "${repo_root}/apps/ai-game-creator-shell/package-lock.json")"
agc_npm_lock_sha256="${agc_npm_lock_sha256%% *}"
server_rust_lock_sha256="$(sha256sum "${repo_root}/server-rs/Cargo.lock")"
server_rust_lock_sha256="${server_rust_lock_sha256%% *}"
desktop_rust_lock_sha256="$(sha256sum "${repo_root}/apps/desktop-shell/src-tauri/Cargo.lock")"
desktop_rust_lock_sha256="${desktop_rust_lock_sha256%% *}"
agc_rust_lock_sha256="$(sha256sum "${repo_root}/apps/ai-game-creator-shell/src-tauri/Cargo.lock")"
agc_rust_lock_sha256="${agc_rust_lock_sha256%% *}"
(
cd "${repo_root}"
write_build_context_file_list \
@@ -86,8 +98,10 @@ case "${command_name}" in
--pull=false \
--build-arg "IMAGE_REVISION=${image_revision}" \
--build-arg "NPM_LOCK_SHA256=${npm_lock_sha256}" \
--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}" \
--build-arg "SERVER_RUST_LOCK_SHA256=${server_rust_lock_sha256}" \
--build-arg "DESKTOP_RUST_LOCK_SHA256=${desktop_rust_lock_sha256}" \
--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}" \
--file "${dockerfile_context_path}" \
--tag "${image_tag}" \
-
+171
View File
@@ -7,6 +7,61 @@ const workflow = readFileSync(
resolve(process.cwd(), '.gitea/workflows/project-ci.yml'),
'utf8',
);
const imageBuildScript = readFileSync(
resolve(process.cwd(), 'scripts/gitea-ci-job-image.sh'),
'utf8',
);
const imageCheckScript = readFileSync(
resolve(process.cwd(), 'scripts/check-gitea-ci-job-image.sh'),
'utf8',
);
const npmCiRetryScript = readFileSync(
resolve(process.cwd(), 'scripts/ci-npm-ci-with-retry.sh'),
'utf8',
);
const imageDockerfile = readFileSync(
resolve(process.cwd(), 'deploy/container/gitea-ci-job.Dockerfile'),
'utf8',
);
const imageDockerignore = readFileSync(
resolve(
process.cwd(),
'deploy/container/gitea-ci-job.Dockerfile.dockerignore',
),
'utf8',
);
const jobNames = [
'repository-checks',
'frontend-tests',
'backend-tests',
'native-shell-tests',
] as const;
function jobSection(jobName: (typeof jobNames)[number]) {
const jobStart = workflow.indexOf(` ${jobName}:`);
expect(jobStart).toBeGreaterThanOrEqual(0);
const nextJobOffset = workflow
.slice(jobStart + 1)
.search(/^ {2}[a-z][a-z0-9-]+:$/m);
return workflow.slice(
jobStart,
nextJobOffset < 0 ? undefined : jobStart + 1 + nextJobOffset,
);
}
function stepSection(jobName: (typeof jobNames)[number], stepName: string) {
const job = jobSection(jobName);
const stepStart = job.indexOf(` - name: ${stepName}`);
expect(stepStart).toBeGreaterThanOrEqual(0);
const nextStepOffset = job.slice(stepStart + 1).search(/^ {6}- name: /m);
return job.slice(
stepStart,
nextStepOffset < 0 ? undefined : stepStart + 1 + nextStepOffset,
);
}
function backendStepIndex(stepName: string) {
const backendJobStart = workflow.indexOf(' backend-tests:');
@@ -20,6 +75,122 @@ function backendStepIndex(stepName: string) {
}
describe('project CI workflow', () => {
it('keeps every job on the isolated preinstalled CI image boundary', () => {
expect(workflow.match(/^ {4}runs-on: genarrative-ci$/gm)).toHaveLength(4);
expect(workflow).not.toContain('actions/checkout');
expect(workflow).not.toContain('actions/setup-node');
expect(workflow).not.toMatch(/^\s+run: .*\b(?:apt|rustup)\b/m);
for (const jobName of jobNames) {
const job = jobSection(jobName);
const checkout = job.indexOf('genarrative-gitea-checkout');
const validateImage = job.indexOf(
'GENARRATIVE_GITEA_CI_CHECK_RUNTIME=1 bash scripts/check-gitea-ci-job-image.sh',
);
const installDependencies = job.indexOf(
'- name: Install npm dependencies',
);
expect(checkout).toBeGreaterThanOrEqual(0);
expect(validateImage).toBeGreaterThan(checkout);
expect(installDependencies).toBeGreaterThan(validateImage);
}
expect(imageDockerfile).toMatch(
/^ARG RUST_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
);
expect(imageDockerfile).toMatch(
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
);
});
it('retries every root and AI game creator npm clean install as a bounded whole command', () => {
for (const jobName of jobNames) {
const install = stepSection(jobName, 'Install npm dependencies');
expect(install).toContain('bash scripts/ci-npm-ci-with-retry.sh');
expect(install).not.toContain('--prefix');
}
for (const jobName of ['frontend-tests', 'native-shell-tests'] as const) {
const install = stepSection(
jobName,
'Install AI game creator dependencies',
);
expect(install).toContain(
'bash scripts/ci-npm-ci-with-retry.sh --prefix apps/ai-game-creator-shell',
);
}
expect(npmCiRetryScript).toContain(
'max_attempts="${GENARRATIVE_CI_NPM_CI_ATTEMPTS:-3}"',
);
expect(npmCiRetryScript).toContain(
'base_delay_seconds="${GENARRATIVE_CI_NPM_CI_RETRY_DELAY_SECONDS:-5}"',
);
expect(npmCiRetryScript).toContain(
'for attempt in $(seq 1 "${max_attempts}"); do',
);
expect(npmCiRetryScript).toContain('if npm ci "$@"; then');
expect(npmCiRetryScript).toContain(
'if [[ "${attempt}" -eq "${max_attempts}" ]]; then',
);
});
it('builds and verifies image caches against both AI game creator locks', () => {
const npmManifest = 'apps/ai-game-creator-shell/package.json';
const npmLock = 'apps/ai-game-creator-shell/package-lock.json';
const rustManifest = 'apps/ai-game-creator-shell/src-tauri/Cargo.toml';
const rustLock = 'apps/ai-game-creator-shell/src-tauri/Cargo.lock';
for (const [path, expectedCount] of [
[npmManifest, 2],
[npmLock, 3],
[rustManifest, 2],
[rustLock, 3],
] as const) {
expect(imageBuildScript.split(path)).toHaveLength(expectedCount + 1);
expect(imageDockerignore).toContain(`!${path}`);
}
expect(imageBuildScript).toContain(
'--build-arg "AGC_NPM_LOCK_SHA256=${agc_npm_lock_sha256}"',
);
expect(imageBuildScript).toContain(
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
);
expect(imageDockerfile).toContain('ARG AGC_NPM_LOCK_SHA256');
expect(imageDockerfile).toContain('ARG AGC_RUST_LOCK_SHA256');
expect(imageDockerfile).toContain(
'--prefix /usr/local/share/genarrative-ci/agc-npm',
);
expect(
imageDockerfile.match(
/--manifest-path \/tmp\/genarrative-cargo-cache\/apps\/ai-game-creator-shell\/src-tauri\/Cargo\.toml/g,
),
).toHaveLength(1);
expect(imageDockerfile).toContain(
'cargo_fetch_with_retry /tmp/genarrative-cargo-cache/apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256=${AGC_NPM_LOCK_SHA256}',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256=${AGC_RUST_LOCK_SHA256}',
);
expect(imageCheckScript).toContain(npmLock);
expect(imageCheckScript).toContain(rustLock);
expect(imageCheckScript).toContain(
'${GENARRATIVE_GITEA_CI_AGC_NPM_LOCK_SHA256:-}',
);
expect(imageCheckScript).toContain(
'${GENARRATIVE_GITEA_CI_AGC_RUST_LOCK_SHA256:-}',
);
expect(imageCheckScript).toContain(
'::warning title=CI dependency cache is partial::',
);
});
it('prepares locked server-rs dependencies before the first Cargo build gate', () => {
const prepareDependencies = backendStepIndex(
'Prepare server-rs Rust dependencies',
+47 -1
View File
@@ -281,7 +281,7 @@ export async function assertReadableFile(filePath) {
function normalizeProcedureResult(value, procedureName) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return value;
return normalizeSatsObject(value, procedureName);
}
if (Array.isArray(value)) {
@@ -291,7 +291,53 @@ function normalizeProcedureResult(value, procedureName) {
throw new Error('procedure 返回值不是对象。');
}
function normalizeSatsObject(value, procedureName) {
const normalized = normalizeSatsValue(value);
if (
procedureName !==
'normalize_editor_character_animation_metadata_and_return' ||
!normalized ||
typeof normalized !== 'object' ||
Array.isArray(normalized)
) {
return normalized;
}
const { batch_sha_256: batchSha256, ...result } = normalized;
return {
...result,
batch_sha256: result.batch_sha256 ?? batchSha256,
};
}
function normalizeSatsProduct(value, procedureName) {
if (
procedureName === 'normalize_editor_character_animation_metadata_and_return' &&
value.length === 19
) {
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]),
backfilled_frames_count: normalizeSatsValue(value[6]),
backfilled_duration_count: normalizeSatsValue(value[7]),
reclassified_preview_count: normalizeSatsValue(value[8]),
cleaned_generation_inputs_count: normalizeSatsValue(value[9]),
cleaned_frame_index_count: normalizeSatsValue(value[10]),
cleaned_canvas_layer_count: normalizeSatsValue(value[11]),
materialized_resource_count: normalizeSatsValue(value[12]),
blocker_count: normalizeSatsValue(value[13]),
blocker_ids: normalizeSatsValue(value[14]),
next_cursor: normalizeSatsOption(value[15]),
has_more: normalizeSatsValue(value[16]),
batch_sha256: normalizeSatsValue(value[17]),
error_message: normalizeSatsOption(value[18]),
};
}
if (
procedureName === 'repair_editor_canvas_resources_and_return' &&
value.length === 3
@@ -99,4 +99,79 @@ describe('SpacetimeDB CLI SATS option encoding', () => {
error_message: null,
});
});
it('normalizes character action metadata normalization results', () => {
const result = parseProcedureResult(
JSON.stringify([
true,
'asset',
true,
25,
3,
0,
2,
1,
0,
3,
2,
0,
0,
1,
['asset-blocked'],
[0, 'asset-25'],
true,
'a'.repeat(64),
[1],
]),
'normalize_editor_character_animation_metadata_and_return',
);
expect(result).toEqual({
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 25,
matched_count: 3,
updated_count: 0,
backfilled_frames_count: 2,
backfilled_duration_count: 1,
reclassified_preview_count: 0,
cleaned_generation_inputs_count: 3,
cleaned_frame_index_count: 2,
cleaned_canvas_layer_count: 0,
materialized_resource_count: 0,
blocker_count: 1,
blocker_ids: ['asset-blocked'],
next_cursor: 'asset-25',
has_more: true,
batch_sha256: 'a'.repeat(64),
error_message: null,
});
});
it('normalizes object-shaped character action procedure results', () => {
const result = parseProcedureResult(
JSON.stringify({
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 25,
matched_count: 3,
updated_count: 0,
blocker_count: 0,
blocker_ids: [],
next_cursor: 'asset-25',
has_more: true,
batch_sha_256: 'b'.repeat(64),
error_message: null,
}),
'normalize_editor_character_animation_metadata_and_return',
);
expect(result).toMatchObject({
batch_sha256: 'b'.repeat(64),
next_cursor: 'asset-25',
});
expect(result).not.toHaveProperty('batch_sha_256');
});
});
@@ -0,0 +1,253 @@
#!/usr/bin/env node
import { pathToFileURL } from 'node:url';
import {
callSpacetimeProcedure,
callSpacetimeProcedureViaCli,
encodeSpacetimeCliOption,
ensureProcedureOk,
parsePositiveInteger,
} from './spacetime-migration-common.mjs';
const PROCEDURE_NAME = 'normalize_editor_character_animation_metadata_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 usage() {
return `用法:
node scripts/spacetime-normalize-editor-character-actions.mjs \\
--database <name> --server <name-or-url> [--chunk-size <1-25>] [--apply]
默认按 asset、project-resource、showcase、canvas 的固定顺序执行全量 dry-run,不修改数据。
追加 --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 buildNormalizationInput({
scope,
cursor = null,
limit,
dryRun,
expectedBatchSha256 = null,
}) {
if (!SCOPES.includes(scope)) {
throw new Error(`未知角色动作规范化 scope: ${scope}`);
}
if (!Number.isInteger(limit) || limit < 1) {
throw new Error('角色动作规范化 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_ids.length !== 0) {
throw new Error(
`${scope} scope 存在 ${result.blocker_count} 个 blocker${result.blocker_ids.join(', ')}`,
);
}
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,
batches: 0,
};
do {
const limit = scopeLimit(scope, options.chunkSize);
const dryRun = await callProcedure(
options,
buildNormalizationInput({ scope, cursor, limit, dryRun: true }),
);
assertSafeBatch(dryRun, scope);
summary.scanned_count += dryRun.scanned_count;
summary.matched_count += dryRun.matched_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,
buildNormalizationInput({
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} 行。`,
);
}
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: ${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-character-actions:normalize] ${
error instanceof Error ? error.message : String(error)
}`,
);
process.exitCode = 1;
});
}
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest';
import {
buildNormalizationInput,
parseOptions,
scanScopes,
} from './spacetime-normalize-editor-character-actions.mjs';
describe('角色动作元数据规范化脚本', () => {
it('默认 dry-run 并要求调用方显式选择 apply', () => {
expect(
parseOptions(
['--database', 'genarrative-prod', '--server', 'prod'],
{},
),
).toMatchObject({
apply: false,
chunkSize: 25,
database: 'genarrative-prod',
server: 'prod',
});
});
it('支持使用环境 token 的 HTTP 调用模式', () => {
expect(
parseOptions(
[
'--database',
'genarrative-dev',
'--server-url',
'http://127.0.0.1:3101',
'--use-http',
],
{ GENARRATIVE_SPACETIME_TOKEN: 'dev-migration-token' },
),
).toMatchObject({
database: 'genarrative-dev',
serverUrl: 'http://127.0.0.1:3101',
token: 'dev-migration-token',
useHttp: true,
});
});
it('编码 dry-run cursor 并禁止 canvas 超过五行', () => {
expect(
buildNormalizationInput({
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(() =>
buildNormalizationInput({
scope: 'canvas',
limit: 6,
dryRun: true,
}),
).toThrow('canvas scope limit');
});
it('apply 必须绑定 dry-run 返回的批次哈希', () => {
expect(() =>
buildNormalizationInput({
scope: 'showcase',
limit: 25,
dryRun: false,
}),
).toThrow('batch SHA-256');
expect(
buildNormalizationInput({
scope: 'showcase',
cursor: null,
limit: 25,
dryRun: false,
expectedBatchSha256: 'a'.repeat(64),
}),
).toEqual({
scope: 'showcase',
cursor: null,
limit: 25,
dry_run: false,
expected_batch_sha_256: [0, 'a'.repeat(64)],
});
});
it('按固定 scope 顺序为每批执行 hash 绑定的 dry-run 与 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]!;
const batchSha256 = hashDigit.repeat(64);
return {
ok: true,
scope,
dry_run: dryRun,
scanned_count: 1,
matched_count: 1,
updated_count: dryRun ? 0 : 1,
blocker_count: 0,
blocker_ids: [],
next_cursor: null,
has_more: false,
batch_sha256: batchSha256,
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',
]);
for (let index = 1; index < calls.length; index += 2) {
const apply = calls[index]!;
const scope = String(apply.scope);
const hashDigit = {
asset: 'a',
'project-resource': 'b',
showcase: 'c',
canvas: 'd',
}[scope]!;
expect(apply.expected_batch_sha_256).toEqual([
0,
hashDigit.repeat(64),
]);
}
expect(calls.at(-2)?.limit).toBe(5);
});
it('拒绝 procedure 返回已访问过的 cursor', async () => {
let callCount = 0;
const callProcedure = async () => {
callCount += 1;
const nextCursor = ['asset-25', 'asset-50', 'asset-25'][callCount - 1];
return {
ok: true,
scope: 'asset',
dry_run: true,
scanned_count: 1,
matched_count: 0,
updated_count: 0,
blocker_count: 0,
blocker_ids: [],
next_cursor: nextCursor,
has_more: true,
batch_sha256: 'a'.repeat(64),
error_message: null,
};
};
await expect(
scanScopes({ chunkSize: 25 }, { callProcedure }),
).rejects.toThrow('重复的 next_cursor: asset-25');
expect(callCount).toBe(3);
});
});
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import {
callSpacetimeProcedure,
callSpacetimeProcedureViaCli,
ensureProcedureOk,
parseArgs,
@@ -15,11 +16,20 @@ try {
const input = {
operator_identity_hex: options.operatorIdentity,
};
const result = await callSpacetimeProcedureViaCli(
options,
'revoke_database_migration_operator',
input,
);
if (options.useHttp && !options.token) {
throw new Error('--use-http 需要同时传入 --token。');
}
const result = options.useHttp
? await callSpacetimeProcedure(
options,
'revoke_database_migration_operator',
input,
)
: await callSpacetimeProcedureViaCli(
options,
'revoke_database_migration_operator',
input,
);
ensureProcedureOk(result);
console.log(
+3 -3
View File
@@ -87,7 +87,7 @@ const tests = [
body: {
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok,不要解释' }],
max_tokens: 10,
max_completion_tokens: 10,
},
},
@@ -112,7 +112,7 @@ const tests = [
body: {
model: 'gpt-5.4-mini',
messages: [{ role: 'user', content: '回复 ok' }],
max_tokens: 10,
max_completion_tokens: 10,
},
},
@@ -127,7 +127,7 @@ const tests = [
{ role: 'system', content: '你是抓大鹅游戏编辑,只返回 JSON。' },
{ role: 'user', content: '题材:水果。请生成 JSON{"gameName":"水果切切乐","items":[{"name":"苹果","itemSize":"中"},{"name":"西瓜","itemSize":"大"}]}' },
],
max_tokens: 200,
max_completion_tokens: 200,
},
},
];