合并 origin/master:Supervisor 永久退役,项目对话收敛为 DirectProject 与 Design Agent
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m56s
Project CI / Backend tests (pull_request) Failing after 12s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m48s
Project CI / Native shell tests (pull_request) Failing after 45s
Project CI / Repository checks (pull_request) Failing after 13s
Project CI / Frontend tests (pull_request) Failing after 1m52s
Project CI / AI game creator shell web tests (pull_request) Failing after 1m42s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Failing after 6m57s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Failing after 8m1s

- 解决 refactor/split-direct-project 与 origin/master 在 App.tsx、立项策划聊天视图、Direct composer/引用输入区、styles.css、Rust direct user item 与 appSurface 用例上的冲突,按「Supervisor 永久退役」口径保留 DirectProject 独立聊天容器与 Design Agent 两条产品路径
- 采纳 master 的策划 Agent V1/V2 退役:删除 GDD 审批卡、策划输入卡、planningLane、planningSessionV2、planningSessionContract、规划展示适配与 Rust planning_*_v2 命令、模块、契约及对应用例,不保留兼容别名或双跑路径
- 把 master「折叠思考显示单行预览」的目的落到当前结构:新增共享表现 chat/components/AgentReasoning/AgentReasoning.tsx(折叠态单行纯文本预览 + 箭头、展开态安全 Markdown),DirectProject 回合与策划回合共用,删掉两处写死的 pre 折叠实现
- 把 master「策划入口可选模型 / 推理档」的目的接到当前策划输入盒:复用 ConversationModelSelect 与 ComposerReasoningEffortSelect,配置写回仍走客户端配置通道
- App.tsx 删除只服务退役 Supervisor / 策划 V2 的 state、ref、effect、回调与死参数,并删除两条读路径都退役后的 workspaceProjectKind;openWorkspace 的工程类型入参保留为未使用契约
- Rust 侧保留本分支 canonical→wire 投影、无审计 Direct 回合与 direct user item 严格校验,并入 master 的 prepare_new_web_project_at 前置复核
- 更新 ADR 与 shared-memory 决策记录:策划当前只有 Design Agent、两条路径的共享表现清单,以及本次合并的口径、代价与验证证据
- 验证:AGC 与仓库 typecheck、check:encoding、check:doc-index、git diff --check、改动文件 eslint 0 error;AGC vitest 168 个文件中除 5 个 jsdom localStorage 环境失败文件与本分支既有 resourceTagStatsRefresh 失败外全绿,appSurface 198 passed / 13 skipped;Rust 定向用例 direct_codex_user_item、skill_pack、sessions 全过(整套分片在本容器受 /sbin -> usr/bin 触发沙箱预检失败,与本合并无关)
This commit is contained in:
2026-09-21 21:09:34 +08:00
874 changed files with 118775 additions and 18421 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+174 -2
View File
@@ -26,8 +26,10 @@ import {
cleanupHistoryCandidates,
collectDirectFileEntries,
createUploadBandwidthLimiter,
describeBackupSpaceRequirement,
discoverDeferredArchiveUploads,
discoverHistoryPlan,
discoverMinimalPlan,
restoreDirectFilesBackup,
restoreDirectFilesLatest,
resumeUploadedHistoryBatch,
@@ -64,6 +66,8 @@ async function main() {
assertDeferredArchiveDiscoveryIsBoundedAndDeterministic();
assertCanonicalQueryAndAuthorizationIncludeMultipartParameters();
assertInsufficientSpaceStopsBeforeServiceChanges();
assertCheckSpaceOnlyUsesFormatSpecificRequirement();
assertMinimalPlanKeepsOnlyRetainedSnapshotAndTrailingCommitlog();
assertStopFailureRetainsRecoveryMarker();
assertArchiveFailureStillRestoresDependentServices();
await assertMultipartUploadRetriesAndVerifiesRemoteLength();
@@ -1198,7 +1202,7 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() {
'999999999999999999',
]);
assertStatus(result, 1, '空间不足必须失败。');
assertStatus(result, 3, '空间不足必须用独立退出码 3 失败。');
assertIncludes(
result.stdout,
'备份空间预检',
@@ -1213,6 +1217,69 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() {
assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。');
}
function assertCheckSpaceOnlyUsesFormatSpecificRequirement() {
const fixture = createFixture('check-space-only');
const filesOk = runBackup(
fixture,
['--check-space-only', '--storage-format', 'files'],
{ GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1M' },
);
assertStatus(filesOk, 0, 'files 模式空间预检应在阈值满足时通过。');
assertIncludes(
filesOk.stdout,
'备份空间预检(files)',
'files 预检必须打印 files 口径的预检结果。',
);
assertIncludes(
filesOk.stdout,
'空间预检通过(check-space-only)',
'check-space-only 通过时必须给出显式成功标记。',
);
assertFileMissing(fixture.systemctlLog, '空间预检不得调用 systemctl。');
assertFileMissing(fixture.tarLog, '空间预检不得调用 tar。');
const filesInsufficient = runBackup(
fixture,
['--check-space-only', '--storage-format', 'files'],
{ GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1000T' },
);
assertStatus(filesInsufficient, 3, 'files 空间不足同样使用退出码 3。');
const archiveInsufficient = runBackup(
fixture,
['--check-space-only', '--storage-format', 'archive'],
{ GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES: '1000T' },
);
assertStatus(archiveInsufficient, 3, 'archive 空间不足必须使用退出码 3。');
const archive = describeBackupSpaceRequirement({
dataDir: fixture.dataDir,
workDir: fixture.workDir,
storageFormat: 'archive',
args: {},
env: { GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES: '1G' },
});
const files = describeBackupSpaceRequirement({
dataDir: fixture.dataDir,
workDir: fixture.workDir,
storageFormat: 'files',
args: {},
env: { GENARRATIVE_DATABASE_BACKUP_FILES_MIN_FREE_BYTES: '1G' },
});
if (
archive.requiredFreeBytes !== 1024n ** 3n ||
files.requiredFreeBytes !== 1024n ** 3n
) {
failures.push(
`空间口径覆盖参数应生效:archive=${archive.requiredFreeBytes} files=${files.requiredFreeBytes}`,
);
}
if (archive.storageFormat !== 'archive' || files.storageFormat !== 'files') {
failures.push('空间预检结果必须回显实际 storage-format。');
}
}
function assertStopFailureRetainsRecoveryMarker() {
const fixture = createFixture('stop-failure-marker');
writeExecutable(
@@ -2500,6 +2567,110 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() {
}
}
function assertMinimalPlanKeepsOnlyRetainedSnapshotAndTrailingCommitlog() {
const fixture = createHistoryFixture('minimal-plan', { nestedData: true });
const replicaDir = path.join(fixture.replicasDir, '2');
const snapshotsDir = path.join(replicaDir, 'snapshots');
const clogDir = path.join(replicaDir, 'clog');
mkdirSync(snapshotsDir, { recursive: true });
mkdirSync(clogDir, { recursive: true });
for (const transaction of ['100', '200', '300']) {
const padded = transaction.padStart(20, '0');
const snapshotDir = path.join(snapshotsDir, `${padded}.snapshot_dir`);
mkdirSync(snapshotDir, { recursive: true });
writeFileSync(
path.join(snapshotDir, `${padded}.snapshot_bsatn`),
'snapshot',
);
}
for (const transaction of ['50', '150', '250', '350']) {
const padded = transaction.padStart(20, '0');
writeFileSync(path.join(clogDir, `${padded}.stdb.log`), 'log');
writeFileSync(path.join(clogDir, `${padded}.stdb.ofs`), 'ofs');
}
for (const relativeDir of [
'config',
'data/control-db',
'data/program-bytes',
]) {
const directory = path.join(fixture.dataDir, relativeDir);
mkdirSync(directory, { recursive: true });
writeFileSync(path.join(directory, 'state.bin'), 'state');
}
writeFileSync(path.join(fixture.dataDir, 'data/config.toml'), 'config');
writeFileSync(path.join(fixture.dataDir, 'data/metadata.toml'), 'metadata');
const plan = discoverMinimalPlan({ dataDir: fixture.dataDir });
const paths = plan.candidates.map((item) => item.path);
const expect = (condition, reason) => {
if (!condition) {
failures.push(reason);
}
};
expect(
plan.retainSnapshots === 2,
`minimal 默认应保留 2 份 snapshot,实际 ${plan.retainSnapshots}`,
);
expect(
paths.includes(
'data/replicas/2/snapshots/00000000000000000200.snapshot_dir',
),
'minimal 必须保留次新 snapshot。',
);
expect(
paths.includes(
'data/replicas/2/snapshots/00000000000000000300.snapshot_dir',
),
'minimal 必须保留最新 snapshot。',
);
expect(
!paths.includes(
'data/replicas/2/snapshots/00000000000000000100.snapshot_dir',
),
'minimal 不得备份更早的 snapshot。',
);
expect(
paths.includes('data/replicas/2/clog/00000000000000000150.stdb.log'),
'minimal 必须保留覆盖最老保留 snapshot 的边界 commitlog 段。',
);
expect(
paths.includes('data/replicas/2/clog/00000000000000000350.stdb.log'),
'minimal 必须保留最新 commitlog 段。',
);
expect(
!paths.includes('data/replicas/2/clog/00000000000000000050.stdb.log'),
'minimal 不得备份更早的 commitlog 段。',
);
for (const staticPath of [
'config',
'data/config.toml',
'data/metadata.toml',
'data/control-db',
'data/program-bytes',
]) {
expect(
paths.includes(staticPath),
`minimal 必须保留状态路径 ${staticPath}`,
);
}
const replica = plan.replicas.find((item) => item.replicaId === '2');
expect(
replica?.retainedSnapshots === 2 && replica?.droppedSnapshots === 1,
'minimal 必须报告保留/丢弃的 snapshot 数量。',
);
expect(
replica?.droppedSegments === 1,
`minimal 必须报告丢弃的 commitlog 段数量,实际 ${replica?.droppedSegments}`,
);
assertThrows(
() => discoverMinimalPlan({ dataDir: fixture.dataDir, retainSnapshots: 0 }),
'--retain-snapshots 必须是 >= 1 的整数',
'minimal 必须校验保留 snapshot 数量。',
);
}
function createHistoryFixture(name, { nestedData }) {
const root = path.join(tmpRoot, name);
const dataDir = path.join(root, 'stdb');
@@ -2695,7 +2866,7 @@ exit 2
return { root, binDir, dataDir, workDir, systemctlLog, tarLog };
}
function runBackup(fixture, extraArgs = []) {
function runBackup(fixture, extraArgs = [], envOverrides = {}) {
return spawnSync(
process.execPath,
[
@@ -2720,6 +2891,7 @@ function runBackup(fixture, extraArgs = []) {
env: {
...process.env,
PATH: `${fixture.binDir}${path.delimiter}${process.env.PATH ?? ''}`,
...envOverrides,
},
},
);
+32 -3
View File
@@ -75,8 +75,8 @@ const aiGameCreatorViteConfigSource = fs.readFileSync(
// - agc-webAI 游戏创作壳的前端门禁(typecheck 与壳内测试,不触碰 Cargo)。
// - agc-rust-cratesAGC 壳依赖的共享 / 平台 crate 测试(server-rs workspace 加两个
// 无锁独立 crate),只需 server-rs 侧的依赖预热。
// - agc-rust-shard-1..4AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,一片一个
// 分组(CI 里就是一个 job),片内仍保持 `--test-threads=1`;每个分片分组都会做一次
// - agc-rust-shard-1..4AGC 壳自身的 Rust bin target 单测,按名单切成 4 片,片内仍保持
// `--test-threads=1`CI 由两条 lane job 各顺序运行两片,每个分片分组都会做一次
// 「片并集等于全集且互斥」的自校验。
// - agc-rust-smoke:会用 `src-tauri/Cargo.toml` spawn `cargo run` 的 agent-run smoke。
// 与分片分开,免得把已经压到 4 分钟级的片 job 拖长。
@@ -2309,7 +2309,7 @@ const steps = [
// AI 游戏创作壳原先一步串完 typecheck、壳内测试、共享 / 平台 crate 测试和
// 串行壳测试,CI 因此只有一条 10 分钟以上的长尾。这里按同一组命令切成
// web、rust-crates、rust 分片、smoke 四段,整体顺序与 `npm run ai-game-creator-shell:check`
// 完全一致;CI 把每段(以及每个 rust 分片)放进不同 job 并行执行,本地全量运行仍然是
// 完全一致;CI 把各组放进 job,Rust 分片由两条 lane 顺序承载,本地全量运行仍然是
// web -> rust(crates -> shards) -> smoke 原顺序。
{
group: 'agc-web',
@@ -2317,12 +2317,41 @@ const steps = [
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:web'],
},
{
group: 'agc-web',
label: 'agc-plugin-entry-tests',
command: npmCommand,
args: ['run', 'agc:plugins:test'],
},
{
group: 'agc-rust-crates',
label: 'ai-game-creator-shell-check-rust-crates',
command: npmCommand,
args: ['run', 'ai-game-creator-shell:check:rust:crates'],
},
{
group: 'agc-rust-crates',
label: 'agc-plugin-native-tests',
command: npmCommand,
args: ['run', 'agc:plugins:native-test'],
},
{
group: 'agc-rust-shard-1',
label: 'ai-game-creator-prompt-source-contracts',
command: 'cargo',
args: [
'test',
'--locked',
'--manifest-path',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'--test',
'runtime_prompt_bundle_build',
'--test',
'prompt_source_boundaries',
'--',
'--test-threads=1',
],
},
{
group: 'agc-rust-shard-1',
label: 'ai-game-creator-shell-check-rust-shard-1',
+6
View File
@@ -15,6 +15,8 @@ export const REQUIRED_WORKSPACES = Object.freeze([
'packages/agc-plugin-sdk',
'packages/shared',
'plugins/agc-cocos-editor',
'plugins/agc-unity-editor',
'plugins/agc-godot-editor',
'tools/spine-json-export-validator',
]);
@@ -29,6 +31,8 @@ const WORKSPACE_NAMES = Object.freeze({
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor',
'tools/spine-json-export-validator':
'@genarrative/spine-json-export-validator',
});
@@ -50,6 +54,8 @@ const REQUIRED_LOCAL_DEPENDENCIES = Object.freeze({
'@genarrative/image-canvas-core',
],
'plugins/agc-cocos-editor/package.json': ['@genarrative/agc-plugin-sdk'],
'plugins/agc-unity-editor/package.json': ['@genarrative/agc-plugin-sdk'],
'plugins/agc-godot-editor/package.json': ['@genarrative/agc-plugin-sdk'],
});
const DEPENDENCY_FIELDS = Object.freeze([
+4
View File
@@ -23,6 +23,8 @@ const workspaceNames = {
'packages/agc-plugin-sdk': '@genarrative/agc-plugin-sdk',
'packages/shared': '@genarrative/shared',
'plugins/agc-cocos-editor': '@genarrative/agc-plugin-cocos-editor',
'plugins/agc-unity-editor': '@genarrative/agc-plugin-unity-editor',
'plugins/agc-godot-editor': '@genarrative/agc-plugin-godot-editor',
'tools/spine-json-export-validator':
'@genarrative/spine-json-export-validator',
};
@@ -37,6 +39,8 @@ const localDependencies = {
'apps/mobile-shell': { '@genarrative/shared': '0.1.0' },
'packages/image-canvas-react': { '@genarrative/image-canvas-core': '0.1.0' },
'plugins/agc-cocos-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
'plugins/agc-unity-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
'plugins/agc-godot-editor': { '@genarrative/agc-plugin-sdk': '0.1.0' },
};
afterEach(() => {
+170 -7
View File
@@ -6,7 +6,7 @@ const checks = [
{
file: 'package.json',
includes:
'"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check"',
'"check:rustfmt": "cargo fmt --all --manifest-path server-rs/Cargo.toml -- --check && cargo fmt --all --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml -- --check && cargo fmt --all --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml -- --check"',
reason: '仓库必须保留统一、只读的 Rust workspace 格式检查入口。',
},
{
@@ -42,6 +42,18 @@ const checks = [
reason:
'Copy Artifact Production 模式下,API Build 必须显式授权 API Deploy 读取归档。',
},
{
file: 'jenkins/Jenkinsfile.agc-global-version-issue',
includes: "copyArtifactPermission('Genarrative-Scheduled-Revision-Trigger,",
reason:
'Copy Artifact Production 模式下,AGC 发号 Job 必须显式授权调度管线读取 agc-global-version.txt,否则整轮调度会在 copyArtifacts 处失败。',
},
{
file: 'jenkins/Jenkinsfile.agc-global-version-issue',
includes: "Genarrative-Manual-Build-And-Deploy')",
reason:
'Copy Artifact Production 模式下,AGC 发号 Job 还必须授权手动发布管线读取总号,否则用户触发的手动发布会停在 copyArtifactsSYSTEM 定时构建不受影响)。',
},
{
file: 'jenkins/Jenkinsfile.production-stdb-module-build',
includes: 'npm run check:rustfmt',
@@ -246,6 +258,50 @@ const checks = [
reason:
'Stdb 先于 API 发布时必须先补齐 api-server env 的 FILE 路径,保证首次 rollout 重启即可读取 secret。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes:
'precheck_backup_space_before_maintenance\n\n"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"',
reason:
'备份空间预检必须先于进入维护模式执行:磁盘不足时不得停服务或把生产留在维护态。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '--check-space-only',
reason:
'生产 Stdb publish 必须复用备份脚本的 --check-space-only 预检,保证空间口径与真实备份一致。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK',
reason:
'archive 冷备份空间不足时必须能自动降级为 files 存储格式(不落地本地归档)。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'timeout waiting for transaction confirmation',
reason:
'publish 客户端等确认超时不能直接判失败:必须重试同版本 publish 以确认模块是否已生效,避免把生产留在维护态。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'restore_runtime_services_before_publish',
reason:
'尚未开始 publish 的失败必须自动恢复运行时服务并退出维护,避免生产停在维护态等人工。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes:
'if [[ "${PUBLISH_STARTED}" -ne 1 && "${AUTO_RECOVER_BEFORE_PUBLISH}" == "1" ]]; then',
reason:
'只有尚未开始 publish 的失败才允许自动恢复并退出维护,半发布状态必须保持维护。',
},
{
file: 'scripts/database-backup-to-oss.mjs',
includes: 'SPACE_INSUFFICIENT_EXIT_CODE',
reason:
'备份空间不足必须使用独立退出码,调用方据此决定降级存储格式而不是一律失败。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: 'stop_runtime_services_for_rollout_gate',
@@ -888,7 +944,7 @@ const checks = [
},
{
file: 'scripts/jenkins-server-provision.sh',
includes: 'archive-full|files-history)',
includes: 'archive-full|files-history|files-minimal)',
reason: 'Server-Provision 必须拒绝未知数据库备份 profile。',
},
{
@@ -913,7 +969,7 @@ const checks = [
{
file: 'jenkins/Jenkinsfile.production-server-provision',
includes:
"choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-history']",
"choice(name: 'DATABASE_BACKUP_PROFILE', choices: ['archive-full', 'files-minimal', 'files-history']",
reason:
'Server-Provision Job 必须显式暴露 archive-first 的数据库备份 profile。',
},
@@ -966,13 +1022,31 @@ const checks = [
},
{
file: 'jenkins/Jenkinsfile.production-server-provision',
includes: 'release 允许 archive-fullfiles-history',
includes: 'release 允许 files-history',
reason:
'release 必须拒绝 files-history,避免逐文件 catalog 扫描再次触发生产内存峰值。',
},
{
file: 'jenkins/Jenkinsfile.production-server-provision',
includes: 'files-minimal',
reason:
'release 定时备份必须提供 files-minimal profile:只备最近 snapshot 与其后 commitlog,热备不停服。',
},
{
file: 'deploy/systemd/genarrative-database-backup-files-minimal.conf',
includes: '--mode full --minimal --retain-snapshots 2 --freeze-dir',
reason:
'files-minimal 定时备份必须使用 minimal 口径并保留上游默认的最近 2 份 snapshot。',
},
{
file: 'scripts/deploy/production-stdb-publish.sh',
includes: '--freeze-dir',
reason: '发布前备份默认使用 minimal 热备,避免 40G 级冷备空间门槛与停服。',
},
{
file: 'scripts/database-backup-to-oss.mjs',
includes: 'assertSufficientWorkDirSpace({dataDir, workDir, args, env})',
includes:
"assertSufficientWorkDirSpace({ dataDir, workDir, args, env, storageFormat: 'archive' })",
normalizeWhitespace: true,
reason: '生产冷备份必须先做工作目录剩余空间预检,避免停库后写满磁盘。',
},
@@ -7643,6 +7717,14 @@ const agcPipelineContent = readFileSync(
'jenkins/Jenkinsfile.ai-game-creator-shell-build',
'utf8',
);
const agcMacosPipelineContent = readFileSync(
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
'utf8',
);
const notifyEmailPipelineContent = readFileSync(
'jenkins/Jenkinsfile.production-notify-email',
'utf8',
);
const scheduledRevisionTriggerContent = readFileSync(
'jenkins/Jenkinsfile.scheduled-revision-trigger',
'utf8',
@@ -7665,6 +7747,49 @@ for (const [file, content] of [
}
}
for (const [file, content] of [
['jenkins/Jenkinsfile.ai-game-creator-shell-build', agcPipelineContent],
[
'jenkins/Jenkinsfile.ai-game-creator-shell-macos-build',
agcMacosPipelineContent,
],
]) {
for (const [snippet, reason] of [
[
"build job: 'Genarrative-Notify-Email'",
'客户端打包管线必须触发统一邮件通知 Job。',
],
[
"string(name: 'OSS_DOWNLOAD_URL', value: ossDownloadUrl)",
'客户端打包管线必须把本次 OSS 首装包链接传给邮件通知 Job。',
],
[
"string(name: 'NOTIFICATION_EMAILS'",
'客户端打包管线必须支持追加邮件收件人。',
],
['latest.json', '客户端打包管线必须从本次生成的渠道清单读取下载链接。'],
]) {
if (!content.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] ${file} ${reason}`);
}
}
}
for (const [snippet, reason] of [
[
"string(name: 'OSS_DOWNLOAD_URL'",
'统一邮件通知 Job 必须接收 OSS 下载链接参数。',
],
['OSS 下载链接:', '统一邮件通知正文必须展示 OSS 下载链接。'],
]) {
if (!notifyEmailPipelineContent.includes(snippet)) {
failed = true;
console.error(
`[check:production-ops] Jenkinsfile.production-notify-email ${reason}`,
);
}
}
for (const [snippet, reason] of [
["cron('H * * * *')", '必须每小时检查一次远端版本'],
['disableConcurrentBuilds()', '必须禁止并发触发,避免同一版本重复触发下游'],
@@ -7697,12 +7822,50 @@ for (const [snippet, reason] of [
const scheduledPinnedParameterCalls = scheduledRevisionTriggerContent.match(
/parameters: pinnedParameters/gu,
);
if ((scheduledPinnedParameterCalls?.length ?? 0) !== 2) {
// AGC 客户端版本号统一由发号 Job 产生:调度管线只能在 pinnedParameters 之上
// 追加 AGC_RELEASE_VERSION,不能另起一份 revision 或自己递增渠道版本。
if (
(scheduledPinnedParameterCalls?.length ?? 0) !== 1 ||
!scheduledRevisionTriggerContent.includes(
'def agcParameters = pinnedParameters + [',
)
) {
failed = true;
console.error(
'[check:production-ops] 调度管线必须用同一份 pinnedParameters 同时触发 Full Build 与 AGC Windows Build。',
'[check:production-ops] 调度管线必须 Full Build 与 AGC Windows Build 共用同一份 pinnedParametersAGC 只允许追加发号 Job 下发的总版本号)。',
);
}
for (const [snippet, reason] of [
[
"AGC_GLOBAL_VERSION_JOB_NAME = 'Genarrative-Agc-Global-Version-Issue'",
'必须把客户端版本号收口到专用发号 Job',
],
[
"string(name: 'AGC_RELEASE_VERSION', value: env.AGC_GLOBAL_VERSION)",
'必须把发号 Job 下发的总版本号透传给 AGC Windows Build',
],
[
"AGC_GLOBAL_VERSION_ARTIFACT = 'agc-global-version.txt'",
'必须从发号 Job 的归档产物读取总版本号',
],
[
"AGC_MACOS_BUILD_JOB_NAME = 'Genarrative-Agc-MacOS-Build'",
'必须把 macOS 渠道构建纳入同一轮调度',
],
[
'build job: env.AGC_MACOS_BUILD_JOB_NAME, wait: false, propagate: false, parameters: agcMacosParameters',
'必须用独立参数列表触发 macOS 渠道构建(pinnedParameters 只允许 Full Build 使用一次)',
],
[
"booleanParam(name: 'SKIP_IF_SUPERSEDED', value: true)",
'必须让节点离线期间排队的 macOS 旧构建自行让位,不发布过期版本',
],
]) {
if (!scheduledRevisionTriggerContent.includes(snippet)) {
failed = true;
console.error(`[check:production-ops] 调度管线${reason}`);
}
}
if (
!scheduledRevisionTriggerJobConfig.includes(
'<scriptPath>jenkins/Jenkinsfile.scheduled-revision-trigger</scriptPath>',
File diff suppressed because it is too large Load Diff
+215 -23
View File
@@ -18,9 +18,18 @@ usage() {
如需强制等待备份完成并在失败时阻断 publish,传入 --backup-mode sync。
发布成功后会补齐生产 API/worker env 的固定 bootstrap secret FILE 路径,再重启并验活重启前 active 的服务。
--keep-maintenance-mode 会在 publish 前停止旧 API/controller/worker,并在成功后保持维护态,交由后续 API deploy 恢复服务。
发布前先做备份空间预检(不进入维护、不停服务);archive 空间不足且未显式关闭自动降级时,
自动改用 files 存储格式(不落地本地归档,改为同步直传 OSS),避免磁盘不足把生产留在维护态。
环境变量:
GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT=archive|files(默认 archive
GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=1|0(默认 1archive 空间不足自动降级 files
GENARRATIVE_STDB_PUBLISH_BACKUP_MINIMAL=1|0(默认 1:发布前备份只保留最近 N 份 snapshot + 其后 commitlog,热备不停服)
GENARRATIVE_STDB_PUBLISH_BACKUP_RETAIN_SNAPSHOTS=N(默认 2,仅在 minimal 模式下生效)
GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE=1|0(默认 1:尚未开始 publish 的失败自动恢复服务并退出维护)
migration bootstrap secret 必须由 Jenkins Secret File credential 或等价的受保护文件提供,不从构建 artifact 读取。
如果 API 重启前为 active,会在退出维护模式前等待本机 /healthz readiness 通过。
失败时保留维护模式。
失败时:尚未开始 publish 的失败会自动恢复运行时服务并退出维护;真正开始 publish 之后的失败保留维护模式。
EOF
}
@@ -55,7 +64,15 @@ API_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_API_ENV_FILE:-/etc/genarrative/api-serv
WORKER_ENV_FILE="${GENARRATIVE_STDB_PUBLISH_WORKER_ENV_FILE:-/etc/genarrative/external-generation-worker.env}"
KEEP_MAINTENANCE_MODE=0
BACKUP_MODE="${GENARRATIVE_STDB_PUBLISH_BACKUP_MODE:-async}"
BACKUP_STORAGE_FORMAT="${GENARRATIVE_STDB_PUBLISH_BACKUP_STORAGE_FORMAT:-archive}"
AUTO_FILES_FALLBACK="${GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK:-1}"
AUTO_RECOVER_BEFORE_PUBLISH="${GENARRATIVE_STDB_PUBLISH_AUTO_RECOVER_ON_PREPUBLISH_FAILURE:-1}"
BACKUP_MINIMAL="${GENARRATIVE_STDB_PUBLISH_BACKUP_MINIMAL:-1}"
BACKUP_RETAIN_SNAPSHOTS="${GENARRATIVE_STDB_PUBLISH_BACKUP_RETAIN_SNAPSHOTS:-2}"
DEPLOY_COMPLETED=0
PUBLISH_STARTED=0
MAINTENANCE_ENTERED=0
STOPPED_RUNTIME_SERVICES=()
PUBLISH_TMP_DIR=""
ASYNC_BACKUP_STATUS_FILE=""
ASYNC_BACKUP_SCRIPT=""
@@ -287,6 +304,114 @@ restart_runtime_services_after_bootstrap_secret_install() {
fi
}
backup_script_path() {
local candidate=""
for candidate in \
"${SCRIPT_DIR}/../database-backup-to-oss.mjs" \
"${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"; do
if [[ -f "${candidate}" ]]; then
printf '%s\n' "${candidate}"
return 0
fi
done
return 1
}
run_backup_space_precheck() {
local storage_format="$1"
local backup_script=""
if ! backup_script="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少数据库备份脚本,无法做备份空间预检" >&2
return 1
fi
node -- "${backup_script}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--storage-format "${storage_format}" \
--check-space-only
}
# 空间预检必须发生在进入维护模式与停服务之前:磁盘不够时不允许再动生产。
precheck_backup_space_before_maintenance() {
if [[ "${BACKUP_MODE}" == "skip" ]]; then
echo "[production-stdb-publish] 已跳过发布前备份空间预检(--backup-mode skip"
return 0
fi
if [[ "${BACKUP_MINIMAL}" == "1" ]]; then
# minimal 备份是热备:只保留最近 N 份 snapshot + 其后 commitlog,不落地归档也不停服务。
BACKUP_STORAGE_FORMAT="files"
if [[ "${BACKUP_MODE}" == "async" ]]; then
echo "[production-stdb-publish] minimal 备份为同步热备(无本地归档),备份模式由 async 调整为 sync。" >&2
BACKUP_MODE="sync"
fi
fi
local status=0
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
if [[ "${status}" -eq 0 ]]; then
echo "[production-stdb-publish] 发布前备份空间预检通过: storage-format=${BACKUP_STORAGE_FORMAT}(尚未进入维护模式、未停服务)"
return 0
fi
if [[ "${status}" -ne 3 ]]; then
echo "[production-stdb-publish] 发布前备份空间预检失败(非空间原因),中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" ]]; then
echo "[production-stdb-publish] files 模式备份空间仍不足,中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
if [[ "${AUTO_FILES_FALLBACK}" != "1" ]]; then
echo "[production-stdb-publish] archive 备份空间不足且已禁用自动降级(GENARRATIVE_STDB_PUBLISH_AUTO_FILES_FALLBACK=${AUTO_FILES_FALLBACK}),中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
echo "[production-stdb-publish] archive 冷备份空间不足:自动降级为 files 存储格式(不落地本地归档,改为文件级 catalog 直传 OSS)。" >&2
BACKUP_STORAGE_FORMAT="files"
if [[ "${BACKUP_MODE}" == "async" ]]; then
echo "[production-stdb-publish] files 模式不支持 --defer-upload,本次备份改为同步执行。" >&2
BACKUP_MODE="sync"
fi
status=0
run_backup_space_precheck "${BACKUP_STORAGE_FORMAT}" || status=$?
if [[ "${status}" -ne 0 ]]; then
echo "[production-stdb-publish] 降级为 files 后空间预检仍失败,中止发布;未进入维护模式、未停服务。" >&2
exit 1
fi
echo "[production-stdb-publish] 已降级为 files 存储格式且空间预检通过。"
}
# 仅在「尚未开始 publish」的失败路径调用:把本次停掉的运行时服务拉回来。
restore_runtime_services_before_publish() {
if [[ "${#STOPPED_RUNTIME_SERVICES[@]}" -eq 0 ]]; then
return 0
fi
local service=""
local state=""
local attempt=0
echo "[production-stdb-publish] 发布尚未开始,恢复本次停掉的运行时服务: ${STOPPED_RUNTIME_SERVICES[*]}"
if ! run_privileged systemctl start "${STOPPED_RUNTIME_SERVICES[@]}"; then
echo "[production-stdb-publish] 启动运行时服务失败: ${STOPPED_RUNTIME_SERVICES[*]}" >&2
return 1
fi
for service in "${STOPPED_RUNTIME_SERVICES[@]}"; do
state=""
for attempt in $(seq 1 15); do
state="$(get_runtime_service_active_state "${service}" 2>/dev/null || true)"
if [[ "${state}" == "active" ]]; then
break
fi
sleep 1
done
if [[ "${state}" != "active" ]]; then
echo "[production-stdb-publish] 运行时服务未恢复 active: ${service}, state=${state}" >&2
return 1
fi
echo "[production-stdb-publish] 运行时服务已恢复 active: ${service}"
done
return 0
}
stop_runtime_services_for_rollout_gate() {
local api_state=""
local controller_state=""
@@ -326,6 +451,7 @@ stop_runtime_services_for_rollout_gate() {
fi
echo "[production-stdb-publish] 停止旧运行时服务并保持维护态: ${services_to_stop[*]}"
STOPPED_RUNTIME_SERVICES=("${services_to_stop[@]}")
run_privileged systemctl stop "${services_to_stop[@]}"
for worker_service in "${services_to_stop[@]}"; do
if [[ "$(get_runtime_service_active_state "${worker_service}")" == "active" ]]; then
@@ -402,6 +528,10 @@ while [[ $# -gt 0 ]]; do
BACKUP_MODE="${2:?缺少 --backup-mode 的值}"
shift 2
;;
--backup-storage-format)
BACKUP_STORAGE_FORMAT="${2:?缺少 --backup-storage-format 的值}"
shift 2
;;
*)
echo "[production-stdb-publish] 未知参数: $1" >&2
usage >&2
@@ -431,6 +561,14 @@ for runtime_env_file in "${API_ENV_FILE}" "${WORKER_ENV_FILE}"; do
fi
done
if [[ ! "${BACKUP_STORAGE_FORMAT}" =~ ^(archive|files)$ ]]; then
echo "[production-stdb-publish] --backup-storage-format 只能是 archive 或 files: ${BACKUP_STORAGE_FORMAT}" >&2
exit 1
fi
if [[ "${BACKUP_STORAGE_FORMAT}" == "files" && "${BACKUP_MODE}" == "async" ]]; then
echo "[production-stdb-publish] files 存储格式不支持 --defer-upload,备份模式由 async 调整为 sync" >&2
BACKUP_MODE="sync"
fi
if [[ ! "${BACKUP_MODE}" =~ ^(async|sync|skip)$ ]]; then
echo "[production-stdb-publish] --backup-mode 只能是 async、sync 或 skip: ${BACKUP_MODE}" >&2
exit 1
@@ -497,7 +635,22 @@ on_exit() {
rm -rf "${PUBLISH_TMP_DIR}"
fi
if [[ "${exit_code}" -ne 0 && "${DEPLOY_COMPLETED}" -ne 1 ]]; then
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
if [[ "${PUBLISH_STARTED}" -ne 1 && "${AUTO_RECOVER_BEFORE_PUBLISH}" == "1" ]]; then
# 尚未开始 publish 就失败(例如备份空间/备份执行失败):本次没有任何发布变更,
# 必须把停掉的运行时服务拉回来并退出维护,避免生产停在维护态等人工救。
if restore_runtime_services_before_publish; then
if [[ "${MAINTENANCE_ENTERED}" -eq 1 ]]; then
if ! "${SCRIPT_DIR}/maintenance-off.sh"; then
echo "[production-stdb-publish] 自动退出维护模式失败,请手工执行 maintenance-off.sh。" >&2
fi
fi
echo "[production-stdb-publish] 发布尚未开始即失败,已自动恢复运行时服务并退出维护模式。"
else
echo "[production-stdb-publish] 自动恢复运行时服务失败,保持维护模式,请手工处理。" >&2
fi
else
echo "[production-stdb-publish] 发布失败,保持维护模式。" >&2
fi
fi
exit "${exit_code}"
}
@@ -506,12 +659,8 @@ trap on_exit EXIT
prepare_async_backup() {
local -a restart_service_args=()
ASYNC_BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
ASYNC_BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${ASYNC_BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少数据库备份脚本: ${ASYNC_BACKUP_SCRIPT}" >&2
if ! ASYNC_BACKUP_SCRIPT="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
exit 1
fi
@@ -527,6 +676,7 @@ prepare_async_backup() {
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--storage-format "${BACKUP_STORAGE_FORMAT}" \
--stop-service spacetimedb.service \
"${restart_service_args[@]}" \
--defer-upload \
@@ -676,7 +826,10 @@ wait_for_api_healthz_ready() {
return 1
}
precheck_backup_space_before_maintenance
"${SCRIPT_DIR}/maintenance-on.sh" "spacetime module publish ${DATABASE}"
MAINTENANCE_ENTERED=1
if [[ "${KEEP_MAINTENANCE_MODE}" -eq 1 ]]; then
stop_runtime_services_for_rollout_gate
fi
@@ -687,25 +840,34 @@ case "${BACKUP_MODE}" in
;;
sync)
SYNC_BACKUP_RESTART_SERVICE_ARGS=()
BACKUP_SCRIPT="${SCRIPT_DIR}/../database-backup-to-oss.mjs"
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
BACKUP_SCRIPT="${SOURCE_DIR}/scripts/database-backup-to-oss.mjs"
fi
if [[ ! -f "${BACKUP_SCRIPT}" ]]; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${BACKUP_SCRIPT}" >&2
if ! BACKUP_SCRIPT="$(backup_script_path)"; then
echo "[production-stdb-publish] 缺少 publish 前数据库备份脚本: ${SOURCE_DIR}/scripts/database-backup-to-oss.mjs" >&2
exit 1
fi
if [[ "${KEEP_MAINTENANCE_MODE}" -ne 1 ]]; then
SYNC_BACKUP_RESTART_SERVICE_ARGS+=(--restart-service-after genarrative-api.service)
fi
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份,失败会阻断发布"
node -- "${BACKUP_SCRIPT}" \
--env-file /etc/genarrative/api-server.env \
--data-dir "${SPACETIME_ROOT_DIR}" \
--database "${DATABASE}" \
--stop-service spacetimedb.service \
"${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}"
SYNC_BACKUP_ARGS=(
--env-file /etc/genarrative/api-server.env
--data-dir "${SPACETIME_ROOT_DIR}"
--database "${DATABASE}"
--storage-format "${BACKUP_STORAGE_FORMAT}"
)
if [[ "${BACKUP_MINIMAL}" == "1" ]]; then
echo "[production-stdb-publish] publish 前执行 minimal 热备(最近 ${BACKUP_RETAIN_SNAPSHOTS} 份 snapshot + 其后 commitlog),不停服务"
SYNC_BACKUP_ARGS+=(
--mode full
--minimal
--retain-snapshots "${BACKUP_RETAIN_SNAPSHOTS}"
--freeze-dir "${GENARRATIVE_STDB_PUBLISH_BACKUP_FREEZE_DIR:-/var/lib/genarrative/database-backups/publish-minimal-freeze}"
)
else
echo "[production-stdb-publish] publish 前同步执行 OSS 冷备份(storage-format=${BACKUP_STORAGE_FORMAT}),失败会阻断发布"
SYNC_BACKUP_ARGS+=(--stop-service spacetimedb.service)
SYNC_BACKUP_ARGS+=("${SYNC_BACKUP_RESTART_SERVICE_ARGS[@]}")
fi
node -- "${BACKUP_SCRIPT}" "${SYNC_BACKUP_ARGS[@]}"
;;
skip)
echo "[production-stdb-publish] 已按参数跳过 publish 前数据库备份"
@@ -742,6 +904,34 @@ else
echo "[production-stdb-publish] 发布 SpacetimeDB module: ${DATABASE} -> ${SERVER_ALIAS}, root=${SPACETIME_ROOT_DIR}"
fi
# 迁移已提交但客户端等确认超时(HTTP 504 / timeout waiting for transaction confirmation
# 时不能直接判失败:重试一次同版本 publish,SpacetimeDB 对已生效的同版本是幂等的 no-op,
# 重试成功即说明目标模块已在位,避免把生产留在维护态。
run_spacetime_publish() {
local attempt=1
local output=""
local status=0
while :; do
output=""
status=0
if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
output="$(runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}" 2>&1)" || status=$?
else
output="$(spacetime "${PUBLISH_ARGS[@]}" 2>&1)" || status=$?
fi
printf '%s\n' "${output}"
if [[ "${status}" -eq 0 ]]; then
return 0
fi
if [[ "${attempt}" -ge 2 || "${output}" != *"timeout waiting for transaction confirmation"* ]]; then
return "${status}"
fi
echo "[production-stdb-publish] publish 客户端等事务确认超时,可能迁移已提交;重试一次同版本 publish 以确认模块状态。" >&2
attempt=$((attempt + 1))
sleep 5
done
}
if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
if ! id "${RUN_AS_USER}" >/dev/null 2>&1; then
echo "[production-stdb-publish] 发布用户不存在: ${RUN_AS_USER}" >&2
@@ -768,9 +958,11 @@ if [[ -n "${RUN_AS_USER}" && "$(id -u)" -eq 0 ]]; then
else
PUBLISH_ARGS+=(--server "${SERVER_ALIAS}")
fi
runuser -u "${RUN_AS_USER}" -- spacetime "${PUBLISH_ARGS[@]}"
PUBLISH_STARTED=1
run_spacetime_publish
else
spacetime "${PUBLISH_ARGS[@]}"
PUBLISH_STARTED=1
run_spacetime_publish
fi
RUNTIME_SERVICE_BOOTSTRAP_SECRET_DIR="$(dirname "${RUNTIME_SERVICE_BOOTSTRAP_SECRET_FILE}")"
+8 -3
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:20260807.1}"
image_tag="${GENARRATIVE_GITEA_CI_IMAGE_TAG:-genarrative/gitea-project-ci:20260920.2}"
runner_container="${GENARRATIVE_GITEA_RUNNER_CONTAINER:-gitea-runner}"
write_build_context_file_list() {
@@ -29,7 +29,10 @@ write_build_context_file_list() {
server-rs/Cargo.lock \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.lock
find server-rs/crates -name Cargo.toml -print0 | sort -z
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
-type f -print0 \
| sort -z
}
usage() {
@@ -87,7 +90,9 @@ case "${command_name}" in
server-rs/Cargo.lock \
apps/desktop-shell/src-tauri/Cargo.toml \
apps/desktop-shell/src-tauri/Cargo.lock
find server-rs/crates -name Cargo.toml -print0 \
find server-rs/crates plugins/agc-*-editor/native/*-editor-bridge \
\( -name Cargo.toml -o -path 'plugins/agc-*-editor/native/*-editor-bridge/*' \) \
-type f -print0 \
| sort -z \
| xargs -0 -r sha256sum
} \
+37 -4
View File
@@ -15,6 +15,8 @@ DATABASE_BACKUP_PROFILE="${DATABASE_BACKUP_PROFILE:-archive-full}"
DATABASE_BACKUP_FILES_HISTORY_WORK_DIR="${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR:-/var/lib/genarrative/database-backups/files-history}"
DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR="/etc/systemd/system/genarrative-database-backup.service.d"
DATABASE_BACKUP_FILES_HISTORY_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-files-history.conf"
DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR="${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR:-/var/lib/genarrative/database-backups/files-minimal}"
DATABASE_BACKUP_FILES_MINIMAL_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-files-minimal.conf"
DATABASE_BACKUP_LEGACY_DEV_DROP_IN="${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}/10-dev-files.conf"
require_non_root_relative_path() {
@@ -71,10 +73,10 @@ validate_server_names() {
validate_database_backup_profile() {
case "${DATABASE_BACKUP_PROFILE}" in
archive-full|files-history)
archive-full|files-history|files-minimal)
;;
*)
echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-fullfiles-history,当前值: ${DATABASE_BACKUP_PROFILE}" >&2
echo "[server-provision] DATABASE_BACKUP_PROFILE 只能是 archive-fullfiles-history 或 files-minimal,当前值: ${DATABASE_BACKUP_PROFILE}" >&2
exit 1
;;
esac
@@ -1323,12 +1325,29 @@ render_database_backup_files_history_drop_in() {
deploy/systemd/genarrative-database-backup-files-history.conf
}
render_database_backup_files_minimal_drop_in() {
local current_escaped env_escaped work_dir_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
env_escaped="$(escape_sed_replacement "${API_ENV_FILE}")"
work_dir_escaped="$(escape_sed_replacement "${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR}")"
sed \
-e "s|/opt/genarrative/current|${current_escaped}|g" \
-e "s|/etc/genarrative/api-server.env|${env_escaped}|g" \
-e "s|/var/lib/genarrative/database-backups/files-minimal|${work_dir_escaped}|g" \
deploy/systemd/genarrative-database-backup-files-minimal.conf
}
configure_database_backup_profile() {
local rendered_drop_in
if [[ "${DATABASE_BACKUP_PROFILE}" == "archive-full" ]]; then
echo "[server-provision] 数据库备份 profile=archive-full,保留主 service 的全量冷备行为。"
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
return
fi
if [[ "${DATABASE_BACKUP_PROFILE}" == "files-minimal" ]]; then
configure_database_backup_files_minimal_profile
return
fi
@@ -1350,13 +1369,26 @@ configure_database_backup_profile() {
run_cmd install -d -o genarrative -g genarrative -m 0750 "${DATABASE_BACKUP_FILES_HISTORY_WORK_DIR}"
run_cmd install -d -o root -g root -m 0755 "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}"
run_cmd rm -f "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
run_cmd rm -f "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
rendered_drop_in="$(mktemp)"
render_database_backup_files_history_drop_in >"${rendered_drop_in}"
install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" 0644
rm -f "${rendered_drop_in}"
}
configure_database_backup_files_minimal_profile() {
local rendered_drop_in
echo "[server-provision] 数据库备份 profile=files-minimal,只保留最近 snapshot 与其后 commitlog(热备、不停服)。"
run_cmd install -d -o genarrative -g genarrative -m 0750 "${DATABASE_BACKUP_FILES_MINIMAL_WORK_DIR}"
run_cmd install -d -o root -g root -m 0755 "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN_DIR}"
run_cmd rm -f "${DATABASE_BACKUP_FILES_HISTORY_DROP_IN}" "${DATABASE_BACKUP_LEGACY_DEV_DROP_IN}"
rendered_drop_in="$(mktemp)"
render_database_backup_files_minimal_drop_in >"${rendered_drop_in}"
install_file "${rendered_drop_in}" "${DATABASE_BACKUP_FILES_MINIMAL_DROP_IN}" 0644
rm -f "${rendered_drop_in}"
}
render_health_patrol_service() {
local current_escaped
current_escaped="$(escape_sed_replacement "${CURRENT_LINK}")"
@@ -1372,6 +1404,7 @@ require_path deploy/systemd/genarrative-external-generation-controller.service
require_path deploy/systemd/genarrative-bgfilter-worker.service
require_path deploy/systemd/genarrative-database-backup.service
require_path deploy/systemd/genarrative-database-backup-files-history.conf
require_path deploy/systemd/genarrative-database-backup-files-minimal.conf
require_path deploy/systemd/genarrative-database-backup.timer
require_path deploy/systemd/genarrative-health-patrol.service
require_path deploy/systemd/genarrative-health-patrol.timer
@@ -15,6 +15,16 @@ export const RUSTFMT_WORKSPACES = [
prefix: 'apps/ai-game-creator-shell/src-tauri/',
manifestPath: 'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
},
{
prefix: 'plugins/agc-unity-editor/native/unity-editor-bridge/',
manifestPath:
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
},
{
prefix: 'plugins/agc-godot-editor/native/godot-editor-bridge/',
manifestPath:
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
},
];
/**
+5 -1
View File
@@ -37,15 +37,19 @@ describe('lint-staged Rust 格式检查的 workspace 选择', () => {
).toEqual(['apps/ai-game-creator-shell/src-tauri/Cargo.toml']);
});
test('个 workspace 都有暂存文件时两个都查', () => {
test('个 workspace 都有暂存文件时分别检查', () => {
expect(
selectedManifests([
'server-rs/crates/api-server/src/editor_project.rs',
'apps/ai-game-creator-shell/src-tauri/src/assets.rs',
'plugins/agc-unity-editor/native/unity-editor-bridge/src/lib.rs',
'plugins/agc-godot-editor/native/godot-editor-bridge/src/lib.rs',
]),
).toEqual([
'server-rs/Cargo.toml',
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
]);
});
+145 -25
View File
@@ -35,16 +35,29 @@ const apiServerDockerfile = readFileSync(
'utf8',
);
const deployContainerReadme = readFileSync(
resolve(process.cwd(), 'deploy/container/README.md'),
'utf8',
);
const operationsDoc = readFileSync(
resolve(
process.cwd(),
'docs/【开发运维】本地开发验证与生产运维-2026-05-15.md',
),
'utf8',
);
const rustToolchainToml = readFileSync(
resolve(process.cwd(), 'rust-toolchain.toml'),
'utf8',
);
const jobNames = [
'repository-checks',
'frontend-tests',
'backend-tests',
'native-shell-tests',
'ai-game-creator-shell-web-tests',
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-lane-1',
'ai-game-creator-shell-rust-lane-2',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
] as const;
@@ -54,10 +67,8 @@ const jobNames = [
// 都不需要 node_modules。省掉这些 `npm ci`(各 1~3 分钟)是把客户端 Rust 关键路径
// 压到 7 分钟以内的前提,因此这里显式允许它们不装 npm 依赖。
const jobsWithoutNpmInstall: readonly string[] = [
'ai-game-creator-shell-rust-shard-1',
'ai-game-creator-shell-rust-shard-2',
'ai-game-creator-shell-rust-shard-3',
'ai-game-creator-shell-rust-shard-4',
'ai-game-creator-shell-rust-lane-1',
'ai-game-creator-shell-rust-lane-2',
'ai-game-creator-shell-rust-smoke',
'ai-game-creator-shell-rust-crates',
];
@@ -167,8 +178,16 @@ describe('project CI workflow', () => {
/^ARG RUNNER_IMAGE=[^\s]+@sha256:[a-f0-9]{64}$/m,
);
expect(imageDockerfile).toContain('ARG NPM_VERSION=10.9.7');
// base runner 镜像把 /opt/acttoolcache 的 Node 放在 PATH 最前;固定 Node/npm
// 必须写成绝对路径 wrapper 并覆盖 toolcache bin,否则登录与否会解析到不同工具链。
expect(imageDockerfile).toContain(
'npm install --global "npm@${NPM_VERSION}" --no-audit --no-fund',
'rm -rf /opt/acttoolcache/node/24.18.0/x64/bin',
);
expect(imageDockerfile).toContain(
'exec /usr/local/lib/genarrative-node/bin/node /usr/local/lib/genarrative-node/lib/node_modules/npm/bin/npm-cli.js "$@"',
);
expect(imageDockerfile).toContain(
'npm install --global --prefix /usr/local/lib/genarrative-node',
);
expect(imageDockerfile).toContain(
'GENARRATIVE_GITEA_CI_NPM_VERSION=${NPM_VERSION}',
@@ -248,6 +267,22 @@ describe('project CI workflow', () => {
expect(imageDockerignore).toContain(`!${path}`);
}
// AGC 通过本地 path 依赖引用三个编辑器 bridge crate。镜像预热会对
// AGC manifest 执行 cargo fetch --locked,构建上下文与 dockerignore
// 必须同时放行这些 crate,否则镜像在 cargo fetch 阶段必然失败。
for (const bridgeDir of [
'plugins/agc-cocos-editor/native/cocos-editor-bridge',
'plugins/agc-unity-editor/native/unity-editor-bridge',
'plugins/agc-godot-editor/native/godot-editor-bridge',
]) {
expect(imageBuildScript.split(bridgeDir)).toHaveLength(1);
expect(imageDockerignore).toContain(`!${bridgeDir}/`);
expect(imageDockerignore).toContain('**');
expect(imageDockerfile).toContain(
`COPY ${bridgeDir} /tmp/genarrative-cargo-cache/${bridgeDir}`,
);
}
expect(imageBuildScript).toContain(
'--build-arg "AGC_RUST_LOCK_SHA256=${agc_rust_lock_sha256}"',
);
@@ -416,27 +451,36 @@ describe('project CI workflow', () => {
const webJob = jobSection('ai-game-creator-shell-web-tests');
expect(webJob).toContain('run: npm run check:native-shells:agc-web');
expect(webJob).not.toContain('cargo fetch');
expect(nativeShellGateScript).toMatch(
/group: 'agc-web',[\s\S]*?args: \['run', 'agc:plugins:test'\]/u,
);
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
'plugins/agc-unity-editor/src/entry.test.mjs',
);
expect(rootPackageJson.scripts?.['agc:plugins:test']).toContain(
'plugins/agc-godot-editor/src/entry.test.mjs',
);
// 壳 bin 单测按名单分 4 片,一片一个 job:每个片 job 只跑自己那片,且只预热 AGC 壳
// 自己那份锁定依赖(server-rs 那份归 crate 级 job)。
for (const [index, jobName] of [
[1, 'ai-game-creator-shell-rust-shard-1'],
[2, 'ai-game-creator-shell-rust-shard-2'],
[3, 'ai-game-creator-shell-rust-shard-3'],
[4, 'ai-game-creator-shell-rust-shard-4'],
// 壳 bin 单测按名单分 4 片,但由两条 lane 各顺序运行两片;每条 lane 只预热
// 一次 AGC 壳自己的锁定依赖(server-rs 那份归 crate 级 job)。
for (const [laneName, indexes] of [
['ai-game-creator-shell-rust-lane-1', [1, 2]],
['ai-game-creator-shell-rust-lane-2', [3, 4]],
] as const) {
const shardJob = jobSection(jobName);
expect(shardJob).toContain(
`run: npm run check:native-shells:agc-rust-shard-${index}`,
);
expect(shardJob).toContain(
const laneJob = jobSection(laneName);
for (const index of indexes) {
expect(laneJob).toContain(
`run: npm run check:native-shells:agc-rust-shard-${index}`,
);
}
expect(laneJob).toContain(
'apps/ai-game-creator-shell/src-tauri/Cargo.toml',
);
expect(shardJob).toContain('cargo fetch --locked');
expect(shardJob).not.toContain('server-rs/Cargo.toml');
expect(laneJob).toContain('cargo fetch --locked');
expect(laneJob).not.toContain('server-rs/Cargo.toml');
}
// 整套用例不能再作为一条命令串行跑完:每个片 job 都必须落到分片运行器的
// 整套用例不能再作为一条命令串行跑完:每个分片调用都必须落到分片运行器的
// `--shard-index` 上,4 个 index 各一次。
for (const index of [1, 2, 3, 4]) {
expect(nativeShellGateScript).toContain(`'--shard-index=${index}'`);
@@ -458,6 +502,15 @@ describe('project CI workflow', () => {
);
expect(cratesJob).toContain('server-rs/Cargo.toml');
expect(cratesJob).toContain('cargo fetch --locked');
expect(nativeShellGateScript).toMatch(
/group: 'agc-rust-crates',[\s\S]*?args: \['run', 'agc:plugins:native-test'\]/u,
);
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
'cargo test --locked --manifest-path plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
);
expect(rootPackageJson.scripts?.['agc:plugins:native-test']).toContain(
'cargo test --locked --manifest-path plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
);
// 拆开的 web / rust 两段必须还是原 `ai-game-creator-shell:check` 的同一条命令序列,
// rust 段再拆成 crate 级与壳分片两段后在聚合脚本里保持同序。
@@ -515,13 +568,30 @@ describe('project CI workflow', () => {
for (const manifest of [
'server-rs/crates/agent-runtime-core/Cargo.toml',
'server-rs/crates/agent-runtime-orchestration/Cargo.toml',
'plugins/agc-cocos-editor/native/cocos-editor-bridge/Cargo.toml',
]) {
expect(standaloneStep).toContain(manifest);
}
// 这两个 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch
// 这 crate 没有提交 Cargo.lock,只能用不带 --locked 的 fetch
// 带 --locked 会因为缺少锁文件直接失败。
expect(standaloneStep).toContain('cargo fetch \\');
expect(standaloneStep).not.toContain('cargo fetch --locked');
const unityStep = stepSection(
'ai-game-creator-shell-rust-crates',
'Prepare Unity plugin Rust dependencies',
);
expect(unityStep).toContain('cargo fetch --locked');
expect(unityStep).toContain(
'plugins/agc-unity-editor/native/unity-editor-bridge/Cargo.toml',
);
const godotStep = stepSection(
'ai-game-creator-shell-rust-crates',
'Prepare Godot plugin Rust dependencies',
);
expect(godotStep).toContain('cargo fetch --locked');
expect(godotStep).toContain(
'plugins/agc-godot-editor/native/godot-editor-bridge/Cargo.toml',
);
const cratesJob = jobSection('ai-game-creator-shell-rust-crates');
expect(
@@ -530,4 +600,54 @@ describe('project CI workflow', () => {
cratesJob.indexOf('run: npm run check:native-shells:agc-rust-crates'),
);
});
// 预构建镜像的 Rust 版本分散在四处:rust-toolchain.toml 的 channel、Dockerfile 的
// RUST_IMAGE digest、构建脚本的默认 tag,以及两份运维文档。漏改任何一处都会让 job
// 在 runtime 校验里失败(`RUSTUP_AUTO_INSTALL=0` 不允许现场补装),或者让文档指向
// 已经不存在的镜像,所以这里把它们钉成同一个事实。
it('keeps the prebuilt CI job image Rust version, digest and tag in sync with the ops docs', () => {
const channel = rustToolchainToml.match(/^channel = "([^"]+)"$/mu)?.[1];
expect(channel).toBeTruthy();
const channelValue = channel ?? '';
const rustImage = imageDockerfile.match(
/^ARG RUST_IMAGE=([^\s@]+)@(sha256:[a-f0-9]{64})$/mu,
);
expect(rustImage).not.toBeNull();
const rustImageRef = rustImage?.[1] ?? '';
const rustImageDigest = rustImage?.[2] ?? '';
const [major, minor] = channelValue.split('.');
expect(rustImageRef).toBe(`rust:${major}.${minor}-bookworm`);
// 同一 Dockerfile 里可能出现在多个 LABEL 块中(后写的覆盖前者),必须同值,
// 否则镜像真实版本会和脚本、文档的说法分叉。
const labelVersions = [
...imageDockerfile.matchAll(
/org\.opencontainers\.image\.version="([0-9]{4}\.[0-9]{2}\.[0-9]{2}\.[0-9]+)"/gu,
),
].map((match) => match[1] ?? '');
expect(labelVersions.length).toBeGreaterThan(0);
expect(new Set(labelVersions).size).toBe(1);
const stampMatch = (labelVersions.at(-1) ?? '').match(
/^(\d{4})\.(\d{2})\.(\d{2})\.(\d+)$/u,
);
expect(stampMatch).not.toBeNull();
const imageTagStamp = `${stampMatch?.[1] ?? ''}${stampMatch?.[2] ?? ''}${
stampMatch?.[3] ?? ''
}.${stampMatch?.[4] ?? ''}`;
expect(imageBuildScript).toContain(
`genarrative/gitea-project-ci:${imageTagStamp}`,
);
for (const doc of [deployContainerReadme, operationsDoc]) {
expect(doc).toContain(rustImageDigest);
expect(doc).toContain(`Rust \`${channelValue}\``);
expect(doc).toContain(imageTagStamp);
}
// runtime 校验按 rust-toolchain.toml 的 channel 逐字比对镜像内工具链名,
// 所以基础镜像 digest 里的 RUST_VERSION 必须与 channel 完全相同。
expect(imageCheckScript).toContain(
'rustup toolchain list | rg -q "^${expected_toolchain}(-[^ ]+)?( |$)"',
);
});
});