完成资源管理阶段七验收
Project CI / Repository checks (pull_request) Successful in 1m1s
Project CI / Backend tests (pull_request) Successful in 3m43s
Project CI / Native shell tests (pull_request) Successful in 10m47s
Project CI / Frontend tests (pull_request) Failing after 1m41s

补齐视频与资源读取失败空态测试
完成全量前后端和 Rust CI 验收
修复 macOS/BSD 跨平台门禁与测试隔离
更新阶段七验收与共享项目记忆
This commit is contained in:
2026-08-04 10:06:12 +08:00
parent df4e61a208
commit 80bcb4ba0e
26 changed files with 233 additions and 45 deletions
@@ -885,6 +885,10 @@ function readBrowserDom(url) {
function resolveChromeBin() {
for (const candidate of [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
'/opt/google/chrome/chrome',
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
@@ -2057,7 +2057,7 @@ mod canvas_generation_tests {
#[tokio::test]
async fn recovery_scan_resumes_accepted_generation_on_default_worker_stack() {
let temporary = tempfile::tempdir().expect("create accepted scan project");
let temporary = crate::tests::canonical_test_tempdir("accepted-generation-scan-");
let root = temporary.path();
init_local_game_project_at(root, "accepted-scan", "恢复扫描测试")
.expect("init accepted scan project");
@@ -736,7 +736,7 @@ mod external_generation_state_tests {
#[test]
fn prepared_generation_state_reuses_identity_and_only_accepted_can_resume() {
let temporary = tempfile::tempdir().expect("create generation ledger project");
let temporary = crate::tests::canonical_test_tempdir("external-generation-ledger-");
let root = temporary.path();
init_local_game_project_at(root, "generation-ledger", "生成账本测试")
.expect("init project");
@@ -846,7 +846,7 @@ mod external_generation_state_tests {
#[test]
fn legacy_completed_generation_persists_only_allowlisted_safe_download_fields() {
let temporary = tempfile::tempdir().expect("create legacy generation ledger project");
let temporary = crate::tests::canonical_test_tempdir("legacy-generation-ledger-");
let root = temporary.path();
init_local_game_project_at(root, "legacy-generation-ledger", "旧同步生成账本测试")
.expect("init project");
@@ -790,7 +790,7 @@ mod tests {
#[test]
fn generation_cleanup_failure_preserves_pending_identity_anchor() {
let temporary = tempfile::tempdir().expect("create pending cleanup project");
let temporary = crate::tests::canonical_test_tempdir("pending-generation-cleanup-");
let root = temporary.path();
let run_id = "generation-cleanup-order-run";
init_local_game_project_at(root, "generation-cleanup-order", "生成账本清理顺序测试")
@@ -425,7 +425,7 @@ mod tests {
const ORDINARY_NOTICE: &str = "除下方有界仓库启动上下文、当前 Session 未压缩对话尾部或历史压缩摘要外,项目记忆、资产和源码正文不会预加载";
const MEMORY_MARKER: &str = "supervisor-preloaded-context-marker";
let directory = tempfile::tempdir().expect("temp project directory");
let directory = crate::tests::canonical_test_tempdir("provider-request-project-");
let root = directory.path().join("project");
init_local_game_project_at(&root, "project-1", "项目总控预加载说明测试")
.expect("project init");
@@ -64,14 +64,8 @@ async fn game_chat_absolute_deadline_returns_an_in_flight_result_before_expiry()
#[tokio::test]
async fn game_chat_absolute_deadline_preserves_external_generation_reconciliation() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-reconciliation-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-reconciliation-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-reconciliation", "硬截止收尾测试")
.expect("project init");
bind_game_creator_agent_runtime_run_profile_at(
@@ -271,14 +265,8 @@ async fn game_chat_absolute_deadline_preserves_external_generation_reconciliatio
#[test]
fn game_chat_absolute_deadline_still_cleans_local_action_recovery() {
let root = std::env::temp_dir().join(format!(
"genarrative-game-chat-deadline-local-cleanup-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock")
.as_nanos()
));
let temporary = crate::tests::canonical_test_tempdir("game-chat-deadline-local-cleanup-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "deadline-local-cleanup", "硬截止本地清理测试")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
@@ -767,7 +767,7 @@ fn standard_specialist_empty_plan_has_no_deterministic_final_reply_fallback() {
fn autonomous_manifest_waiting_context_persists_without_finishing_parent_run() {
const RUN_ID: &str = "autonomous-manifest-waiting-parent";
const TASK: &str = "生成完整小游戏并完成项目任务图";
let temporary = tempfile::tempdir().expect("create manifest waiting root");
let temporary = crate::tests::canonical_test_tempdir("manifest-waiting-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "manifest-waiting-project", TASK)
.expect("init manifest waiting project");
@@ -981,7 +981,7 @@ async fn autonomous_supervisor_converged_final_reply_deserialize_commits_fallbac
const TASK: &str = "生成一个可完成静态检查和双视口试玩的塔防游戏";
const TEST_KEY: &str = "autonomous-final-reply-fallback-key";
let temporary = tempfile::tempdir().expect("create autonomous fallback root");
let temporary = crate::tests::canonical_test_tempdir("autonomous-fallback-");
let root = temporary.path().join("project");
init_local_game_project_at(&root, "autonomous-fallback-project", TASK)
.expect("init autonomous fallback project");
@@ -1221,7 +1221,7 @@ mod orphaned_external_generation_recovery_tests {
#[test]
fn recovery_scan_preserves_active_generation_orphan_then_cleans_terminal_legacy_orphan() {
let temporary = tempfile::tempdir().expect("create orphan generation recovery project");
let temporary = crate::tests::canonical_test_tempdir("orphan-generation-recovery-");
let root = temporary.path();
let run_id = "orphan-generation-recovery-run";
init_local_game_project_at(root, "orphan-generation-recovery", "孤儿生成账本恢复测试")
@@ -827,10 +827,11 @@ mod tests {
.duration_since(UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let root = std::env::temp_dir().join(format!(
"genarrative-context-window-boundary-{}-{unique}",
let temporary = crate::tests::canonical_test_tempdir(&format!(
"context-window-boundary-{}-{unique}-",
std::process::id()
));
let root = temporary.path().join("project");
init_local_game_project_at(&root, "project-1", "上下文窗口边界恢复项目")
.expect("project init");
let mut runtime = start_game_creator_agent_runtime_task_at(
@@ -1931,7 +1931,10 @@ mod tests {
}
fn mcp_test_project(label: &str) -> PathBuf {
let root = std::env::temp_dir().join(format!(
let temp_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize MCP test temp root");
let root = temp_root.join(format!(
"game-creator-mcp-{label}-{}-{}",
std::process::id(),
MCP_TEST_PROJECT_COUNTER.fetch_add(1, Ordering::Relaxed)
@@ -1606,5 +1606,12 @@ async fn project_supervisor_resume_rechecks_delegate_policy_after_delivery_reser
.expect("read barrier after rejecting reserved delivery")
.is_clear());
let released = wait_for_agent_runtime_lane_release_async(
&root,
GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID,
)
.await;
assert_eq!(released.state.run_id, parent_run_id);
fs::remove_dir_all(root).ok();
}
@@ -120,6 +120,16 @@ fn unique_project_path() -> PathBuf {
))
}
pub(crate) fn canonical_test_tempdir(prefix: &str) -> tempfile::TempDir {
let temp_root = std::env::temp_dir()
.canonicalize()
.expect("canonicalize test temp root");
tempfile::Builder::new()
.prefix(prefix)
.tempdir_in(temp_root)
.expect("create test temp directory under canonical root")
}
fn agent_goal_sidecar_path_for_test(root: &Path, agent_id: &str, session_id: &str) -> PathBuf {
let path_key = |value: &str| {
format!("{:x}", Sha256::digest(value.as_bytes()))
@@ -56,7 +56,6 @@ import {
type ProjectResource,
type ProjectResourceCategory,
projectResourcesFromReadModels,
type ProjectVersionResourceSummary,
} from './resourceProjectionModel';
import { useProjectResourceCanvasLayout } from './useProjectResourceCanvasLayout';
@@ -1,6 +1,6 @@
import type {
GameIterationVersion,
GameCreationAppManifest,
GameIterationVersion,
ProjectResourceCanvasSection,
} from '../../../../../packages/shared/src/contracts/gameCreationApp';
@@ -931,7 +931,7 @@ export function registerProjectWorkbenchFoundationTests() {
expect(restoredCard?.getAttribute('aria-pressed')).toBe('true');
});
it('loads registered documents, art media, and audio inside the central focus state', async () => {
it('loads registered documents, art media, video, and audio with safe failure states inside central focus', async () => {
const manifest = createGameCreationAppManifest(
'workbench-resource-media',
'资源媒体测试',
@@ -951,6 +951,13 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/icon.svg',
source: { kind: 'generated' },
},
{
id: 'art-video',
kind: 'animation',
mediaType: 'video/mp4',
localPath: 'assets/intro.mp4',
source: { kind: 'generated' },
},
{
id: 'audio-bgm',
kind: 'bgm',
@@ -958,6 +965,13 @@ export function registerProjectWorkbenchFoundationTests() {
localPath: 'assets/bgm.mp3',
source: { kind: 'generated' },
},
{
id: 'blocked-document',
kind: 'design-document',
mediaType: 'text/markdown',
localPath: 'game/blocked.md',
source: { kind: 'generated' },
},
);
let layoutRevision = 0;
const invoke = vi.fn(
@@ -990,6 +1004,9 @@ export function registerProjectWorkbenchFoundationTests() {
};
}
if (command === 'read_local_project_text_preview') {
if (args?.relativePath === 'game/blocked.md') {
throw new Error('项目权限策略要求用户确认:file.read');
}
expect(args).toMatchObject({
projectPath: '/tmp/workbench-resource-media',
relativePath: 'game/design.md',
@@ -1004,6 +1021,14 @@ export function registerProjectWorkbenchFoundationTests() {
}
if (command === 'read_local_project_media_preview') {
if (args?.category === 'art') {
if (args?.relativePath === 'assets/intro.mp4') {
return {
path: 'assets/intro.mp4',
mediaType: 'video/mp4',
byteLen: 128,
dataUrl: 'data:video/mp4;base64,AAAAIGZ0eXA=',
};
}
return {
path: 'assets/icon.svg',
mediaType: 'image/svg+xml',
@@ -1065,6 +1090,22 @@ export function registerProjectWorkbenchFoundationTests() {
),
);
fireEvent.click(screen.getByRole('button', { name: /intro\.mp4/ }));
const video = (await screen.findByLabelText(
'intro.mp4 视频预览',
)) as HTMLVideoElement;
expect(video.controls).toBe(true);
expect(video.preload).toBe('metadata');
expect(video.getAttribute('src')).toBe(
'data:video/mp4;base64,AAAAIGZ0eXA=',
);
fireEvent.click(
within(screen.getByRole('region', { name: 'intro.mp4' })).getByRole(
'button',
{ name: '收起资源' },
),
);
fireEvent.click(screen.getByRole('button', { name: /bgm\.mp3/ }));
const audio = (await screen.findByLabelText(
'bgm.mp3 音频播放器',
@@ -1076,6 +1117,19 @@ export function registerProjectWorkbenchFoundationTests() {
'read_local_project_media_preview',
expect.objectContaining({ category: 'audio' }),
);
fireEvent.click(
within(screen.getByRole('region', { name: 'bgm.mp3' })).getByRole(
'button',
{ name: '收起资源' },
),
);
fireEvent.click(screen.getByRole('button', { name: /blocked\.md/ }));
expect((await screen.findByRole('alert')).textContent).toBe(
'当前项目策略要求先确认读取资源',
);
expect(screen.getByLabelText('陶泥儿 Agent 对话')).not.toBeNull();
expect(screen.getByLabelText('子 Agent 状态栏')).not.toBeNull();
});
it('renders, filters, highlights, and destroys the resource dependency overlay without pointer previews', async () => {
@@ -1,6 +1,6 @@
# AI 游戏创作项目开发工作台 PRD
更新时间:`2026-08-03`(阶段
更新时间:`2026-08-03`(阶段七验收收口
## 1. 产品定位
@@ -445,9 +445,17 @@ type ProjectAgentMudPointAttribution = {
5. 任意现有 manifest 写入只能保留磁盘版本前缀并追加新记录;修改、删除或重排已有版本时写入失败,原 manifest 字节不被覆盖。
6. 版本选择和高亮不写 manifest、布局 sidecar 或 project revisiondependency / type 两种布局都可显示绑定高亮,既有依赖关系 SVG 语义不变。
### 7.5 阶段七完整验收
1. 对照飞书需求、当前 PRD、技术方案、代码、测试与阶段提交复核阶段零至阶段六;美术编辑生成新资源继续按本 PRD 已确认的闭环条件暂缓,不作为遗漏或伪完成。
2. AppSurface 同时覆盖文档、图片、SVG、音频和视频聚焦;视频必须使用原生 `controls``preload="metadata"`,读取策略失败时中央主视窗显示安全空态,右侧对话和底部 Agent 状态栏继续存在。
3. `1280×800` 应用内浏览器实测 `window`、document 与 body 均无页面级横向或纵向溢出。浏览器开发页受真实登录门禁保护,不为验收绕过认证或伪造 Tauri;工作台内部结构由 AppSurface 集成测试与资源布局 CSS 合同测试复核。
4. 根目录全量 Vitest、前后端 typecheck / lint / build、Rust workspace test / check、SpacetimeDB schema、原生壳、内容 / 编码、生产运维与部署门禁全部通过后,阶段七才允许提交。
5. 本地 `.env``.env.local`、密钥、缓存、日志和构建产物不进入阶段七提交;提交前再次执行编码检查和 `git diff --check`
## 8. 非目标
- 阶段六不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但本阶段只读取、校验和展示已有记录。
- 当前收口不实现资源卡手动拖动,也不实现资源聚焦工具栏、资源聚焦工具侧边栏、美术编辑、音频编辑 / 替换、资源重新生成、资源替换、下一迭代版本创建入口、运行版本切换、版本回滚、运行模块扩展、测试切片、运行态消费版本、数值参数或泥点归因。正式版本记录已经成为 manifest 业务真相,但当前只读取、校验和展示已有记录。
- 本切片不持久化资源聚焦状态、画布缩放 / 平移、搜索条件、筛选条件或当前 mode;聚焦退出时的列表上下文恢复只限当前前端会话,这些状态如需跨重启保存必须另行扩展合同,不能塞入 `game-creator-resource-layout.v1`
- 不修改 SpacetimeDB schema。
- 不开放普通用户 Agent.md/Skill。
@@ -1,5 +1,13 @@
# 决策记录
## 2026-08-03 资源管理阶段七以完整 CI 与可重复界面合同收口
- 背景:飞书资源管理需求的阶段零至阶段六已经分别完成资源卡禁拖、固定资源投影、中央聚焦、安全文档 / 媒体预览、依赖深度与正式版本只读模型;最后需要统一复核需求边界并用当前主分支完整门禁排除集成回归。
- 决策:阶段七不新增平行功能,只补齐视频原生控件和资源读取策略失败空态的 AppSurface 证据,运行完整前端 / Rust / 运维 CI,并以真实 `1280×800` 浏览器测量证明页面级无溢出。开发页的真实登录门禁不得为验收绕过,工作台内部以 AppSurface 与 CSS 合同测试复核;美术编辑继续等待画板回写、血缘登记、新资源自动选中与邻近布局闭环。
- 影响范围:`apps/ai-game-creator-shell` 资源管理测试、跨平台 CI / 运维测试脚本、工作台 PRD、AI 游戏创作实施计划和共享项目记忆;不修改 SpacetimeDB schema、manifest 业务合同或资源布局 sidecar。
- 验证方式:根目录全量 Vitest `171` 个文件、`2155 passed / 5 skipped`;追加 lint、build、Rust workspace test / check、schema、原生壳、内容 / 编码和生产运维门禁;应用内浏览器在 `1280×800` 下 document/body client 与 scroll 尺寸相等。
- 关联文档:`docs/prd/【AI游戏创作】项目开发工作台PRD-2026-07-20.md``docs/technical/【技术方案】AI游戏创作智能体App实施计划-2026-06-24.md``docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`
## 2026-08-03 正式项目版本阶段六落在 manifest 追加不可变记录
- 背景:阶段一至五已经完成资源卡禁拖、固定分类投影、中央聚焦、依赖关系图和引用深度排列,但“项目版本”仍只能接受未接线的前端 read modelcheckpoint、布局 sidecar 和项目 mutation revision 都不能代表正式可追溯版本。
@@ -14,6 +14,14 @@
- 关联:相关文件、文档、提交或 Issue
```
## Linux 生产脚本门禁不能假设本地也是 GNU userland
- 现象:macOS 本地运行维护页、生产 API 部署和 Rust 产物门禁时,依次出现 `mv: illegal option -- T``mapfile: command not found``/usr/bin/cp` / `/usr/bin/chmod` 不存在,以及 `.rlib` 明明含有 `.o` 却报告“没有可扫描成员”;安全修复计划还会把 `/var/folders``/private/var/folders` 的系统别名误判为用户符号链接。
- 原因:生产机是 Linux/GNU,而本地门禁运行在 BSD userland、Bash 3.2 和 BSD ar;测试桩硬编码 Linux 二进制路径与参数,归档解析器没有去掉 BSD 扩展成员名的尾随 NUL,路径校验也直接比较了未规范化字符串。
- 处理:维护 marker 使用同目录临时文件加 POSIX `mv -f`;生产部署测试桩在 macOS 忠实模拟 GNU `mv/ln -T` 的“目标不是目录”语义,并按平台选择系统工具;脚本收集服务使用 Bash 3.2 可用的 `while read`;rlib 解析清理 BSD 成员名 NUL;计划文件只规范化系统临时目录别名,仍拒绝其下用户创建的符号链接组件。
- 验证:运行 `npm run check:maintenance-page``npm run check:production-api-deploy``npm run check:server-rs-ddd``npm run test -- scripts/spacetime-repair-editor-canvas-resources.test.ts`,并在 Linux CI 保留同一生产脚本语义。
- 关联:`scripts/deploy/maintenance-on.sh``scripts/check-maintenance-page.mjs``scripts/check-production-api-deploy.mjs``scripts/deploy/production-api-deploy.sh``scripts/check-module-runtime-artifact.mjs``scripts/spacetime-repair-editor-canvas-resources.mjs`
## External Editor taskId 不能当作本地 manifest taskId
- 现象:画布资产之间已有橙色精确引用线,但依赖任务之间没有灰色 task flow;测试用 `design-foundation` 之类字符串时正常,真实生成返回 `task-1` 后失败。
@@ -3707,6 +3715,7 @@
- 原因:`libc::stat.st_dev` 跟随平台 `dev_t`macOS 为有符号整数,而 `std::os::unix::fs::MetadataExt::dev()` 统一返回 `u64`;直接比较会把 Linux 的类型偶合误当成 Unix 通用契约。
- 处理:与 Rust 标准库的 Unix `MetadataExt` 实现保持一致,先把 `st_dev / st_ino` 规范为 `u64`,再与 `metadata.dev() / metadata.ino()` 比较;设备号、inode 和文件类型三重检查均必须保留。
- macOS 测试夹具:`std::env::temp_dir()` 可能返回 `/var/folders/...`,而 `/var` 是系统兼容符号链接。需要真实项目根的 Runtime 测试应先 canonicalize 已存在的临时根目录,再创建唯一子目录;不得为了让夹具通过而放宽生产 Runtime 的项目根及祖先符号链接拒绝规则。
- 异步测试隔离:测试触发后台 continuation 后,必须等待对应 Agent lane 完整释放,再删除项目夹具或安装下一项全局 mock 配置;否则前一项后台任务可能抢占后一项的唯一 mock 响应,形成只在全量顺序执行时出现的跨测试污染。
- 验证:macOS 本机运行 `cargo check --locked --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`,并复跑 Agent DB、project owner 和 tool-plan handoff 的 Unix 相对句柄替换检测;Linux CI 继续覆盖原有安全回归。
- 关联:`apps/ai-game-creator-shell/src-tauri/src/project.rs``runner.rs``tool_plan_handoff.rs`
File diff suppressed because one or more lines are too long
@@ -607,7 +607,7 @@ Nginx 与 Pingora 在维护 marker 存在时对内网来源绕过整站维护闸
该规则只绕过网关维护响应,不会自动拉起 api-server、SpacetimeDB 或其它已停止的服务。人工执行 `maintenance-on.sh` 且后端仍运行时,可以从内网继续访问整站和修改后台数据;`pause-after-stdb` 会停止旧 API/controller/worker,在 API 被停期间静态页面可能仍可加载,但普通 API 与 `/admin/api/**` 仍不可用。验证使用 `npm run check:nginx-spa-routes``npm run check:pingora-route-parity``cargo test -p pingora-gateway --manifest-path server-rs/Cargo.toml``npm run check:pingora-gateway-smoke``npm run check:production-ops`,不要在 live 机器上为测试临时创建维护 marker。
版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world``www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。
版本化默认维护页固定为 `public/maintenance.html`,使用 `public/branding/taonier-maintenance-page.png` 作为品牌视觉,只允许保存无日期、无具体时段的通用文案;正常 Web 构建由 Vite 复制到发布包根目录的 `web/maintenance.html`,并由 `check-maintenance-page.mjs` 在打包前拒绝“今天 / 今晚”、具体日期或 `HH:MM` 等临时公告。计划内停服的临时公告必须放在 release 外文件中,通过 `/opt/genarrative/current/scripts/deploy/maintenance-on.sh --page-file <公告HTML> <维护原因>` 原子安装到 `/var/lib/genarrative/maintenance/page.html`;公告页和 marker 都使用同目录临时文件加 POSIX 兼容 `mv -f` 的原子替换,不得依赖 GNU `mv -T`,确保 Linux 生产机与 macOS/BSD 本地门禁语义一致。Nginx 与 Pingora 在该文件存在时优先返回它,缺失时回退当前 Web 制品的默认维护页;同一维护窗口内 Stdb / API 的后续 `maintenance-on.sh` 调用保留已安装公告,`maintenance-off.sh` 同时删除 marker 和运行态公告,避免下次维护复活旧内容。公告启用后同时用 `genarrative.world``www.genarrative.world` 的真实 HTTPS 响应校验 `503` 和公告正文。
生产 Jenkins 的 `Pipeline script from SCM` 由 Jenkins controller 读取 Jenkinsfile。所有生产 Job 的 SCM URL,以及 Jenkinsfile 内部在 Jenkins Built-In Node 执行的源码准备,统一使用 `ssh://git@127.0.0.1:2222/GenarrativeAI/Genarrative.git`,并显式传入 Jenkins SSH 凭据 `genarrative-local-gitea-ssh`;不再配置局域网 IP、`https://git.genarrative.world/...` 公网 fallback 或 `https://git.genarrative.world/git/GenarrativeAI/Genarrative.git`。所有 `GitSCM checkout` 都必须保留单分支 refspec、`shallow=true``depth=1``noTags=true``honorRefspec=true`。API / Web / Stdb 发布类流水线不在目标机器 checkout Git,统一执行上游构建归档里的部署脚本;Server-Provision 和数据库导入导出也由带 `linux && genarrative-build` 标签的 Jenkins Built-In Node 先 checkout 并 stash 所需脚本,再交给目标 dev / release agent,避免目标机把 `127.0.0.1` 误解为远端 Gitea 或让产物 commit 与执行脚本漂移。
+8
View File
@@ -68,11 +68,19 @@ function validateRuntimePageLifecycle() {
const sourcePageFile = path.join(tempRoot, 'announcement.html');
const onScript = path.join(repoRoot, 'scripts/deploy/maintenance-on.sh');
const offScript = path.join(repoRoot, 'scripts/deploy/maintenance-off.sh');
const onScriptSource = readFileSync(onScript, 'utf8');
const env = {
GENARRATIVE_MAINTENANCE_FILE: markerFile,
GENARRATIVE_MAINTENANCE_PAGE_FILE: runtimePageFile,
};
if (!onScriptSource.includes('replace_file_atomically')) {
fail('maintenance-on 必须通过统一 helper 原子替换公告页和 marker。');
}
if (/\bmv\s+-[^\s]*T\b/u.test(onScriptSource)) {
fail('maintenance-on 不得使用 GNU mv 专属的 -T 参数。');
}
try {
const announcement = '<!doctype html><title>planned maintenance</title>\n';
writeFileSync(sourcePageFile, announcement);
+2 -1
View File
@@ -165,7 +165,8 @@ function parseArchiveObjectMembers(artifact) {
}
memberName = artifact
.subarray(contentStart, contentStart + nameLength)
.toString('utf8');
.toString('utf8')
.replace(/\0+$/u, '');
contentStart += nameLength;
}
+56 -4
View File
@@ -15,6 +15,22 @@ import { tmpdir } from 'node:os';
import path from 'node:path';
const failures = [];
const systemCpPath = ['/usr/bin/cp', '/bin/cp'].find((candidate) =>
existsSync(candidate),
);
const systemMvPath = ['/usr/bin/mv', '/bin/mv'].find((candidate) =>
existsSync(candidate),
);
const systemLnPath = ['/usr/bin/ln', '/bin/ln'].find((candidate) =>
existsSync(candidate),
);
const systemChmodPath = ['/usr/bin/chmod', '/bin/chmod'].find((candidate) =>
existsSync(candidate),
);
const systemStatModeCommand =
process.platform === 'darwin'
? '/usr/bin/stat -f %Lp'
: '/usr/bin/stat -c %a --';
const tmpRoot = mkdtempSync(
path.join(tmpdir(), 'genarrative-production-api-deploy-'),
);
@@ -2421,7 +2437,7 @@ function prepareFixture(name) {
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'/usr/bin/cp "$@"',
`${shellQuote(systemCpPath ?? 'cp')} "$@"`,
'if [[ "${FAKE_CREATE_RELEASE_DURING_COPY:-false}" == "true" ]]; then',
' marker="${FAKE_RELEASE_ROOT}/.${FAKE_RELEASE_VERSION}.race-created"',
' if [[ ! -e "${marker}" ]]; then',
@@ -2434,6 +2450,40 @@ function prepareFixture(name) {
].join('\n'),
'utf8',
);
writeFileSync(
path.join(fakeBin, 'mv'),
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'if [[ "${1:-}" == "-T" ]]; then',
' shift',
' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then',
' exit 1',
' fi',
` exec ${shellQuote(systemMvPath ?? 'mv')} "$1" "$2"`,
'fi',
`exec ${shellQuote(systemMvPath ?? 'mv')} "$@"`,
'',
].join('\n'),
'utf8',
);
writeFileSync(
path.join(fakeBin, 'ln'),
[
'#!/usr/bin/env bash',
'set -euo pipefail',
'if [[ "${1:-}" == "-sfnT" ]]; then',
' shift',
' if [[ "$#" -ne 2 || ( -d "$2" && ! -L "$2" ) ]]; then',
' exit 1',
' fi',
` exec ${shellQuote(systemLnPath ?? 'ln')} -sfn "$1" "$2"`,
'fi',
`exec ${shellQuote(systemLnPath ?? 'ln')} "$@"`,
'',
].join('\n'),
'utf8',
);
writeFileSync(
path.join(fakeBin, 'install'),
[
@@ -2479,15 +2529,15 @@ function prepareFixture(name) {
' IFS="|" read -r -a env_files <<< "${FAKE_SUDO_ENV_FILES}"',
' modes=()',
' for env_file in "${env_files[@]}"; do',
' modes+=("$(/usr/bin/stat -c %a -- "${env_file}")")',
' /usr/bin/chmod u+rw -- "${env_file}"',
` modes+=("$(${systemStatModeCommand} "\${env_file}")")`,
` ${shellQuote(systemChmodPath ?? 'chmod')} u+rw "\${env_file}"`,
' done',
' set +e',
' "$@"',
' status=$?',
' set -e',
' for index in "${!env_files[@]}"; do',
' /usr/bin/chmod "${modes[$index]}" -- "${env_files[$index]}"',
` ${shellQuote(systemChmodPath ?? 'chmod')} "\${modes[$index]}" "\${env_files[$index]}"`,
' done',
' exit "${status}"',
'fi',
@@ -2501,6 +2551,8 @@ function prepareFixture(name) {
chmodExecutable(path.join(fakeBin, 'sleep'));
chmodExecutable(path.join(fakeBin, 'stat'));
chmodExecutable(path.join(fakeBin, 'cp'));
chmodExecutable(path.join(fakeBin, 'mv'));
chmodExecutable(path.join(fakeBin, 'ln'));
chmodExecutable(path.join(fakeBin, 'install'));
chmodExecutable(path.join(fakeBin, 'sudo'));
+15 -2
View File
@@ -7,6 +7,19 @@ MAINTENANCE_PAGE_FILE="${GENARRATIVE_MAINTENANCE_PAGE_FILE:-/var/lib/genarrative
PAGE_SOURCE=""
REASON_PARTS=()
replace_file_atomically() {
local source_file="$1"
local target_file="$2"
if [[ -d "${target_file}" && ! -L "${target_file}" ]]; then
echo "[maintenance] 原子替换目标不能是目录: ${target_file}" >&2
exit 1
fi
# 源文件与目标文件位于同一目录,POSIX rename 语义即可保证原子替换。
# 不使用 GNU mv 专属的 -T,确保 macOS/BSD 本地门禁也能执行。
mv -f "${source_file}" "${target_file}"
}
while [[ $# -gt 0 ]]; do
case "$1" in
--page-file)
@@ -41,7 +54,7 @@ if [[ -n "${PAGE_SOURCE}" ]]; then
page_temp="$(mktemp "${MAINTENANCE_PAGE_FILE}.tmp.XXXXXX")"
trap 'rm -f "${page_temp:-}" "${marker_temp:-}"' EXIT
install -m 0644 -- "${PAGE_SOURCE}" "${page_temp}"
mv -fT -- "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
replace_file_atomically "${page_temp}" "${MAINTENANCE_PAGE_FILE}"
page_temp=""
echo "[maintenance] 已安装本次运行态公告页: ${MAINTENANCE_PAGE_FILE}"
elif [[ ! -f "${MAINTENANCE_FILE}" && ( -e "${MAINTENANCE_PAGE_FILE}" || -L "${MAINTENANCE_PAGE_FILE}" ) ]]; then
@@ -55,7 +68,7 @@ marker_temp="$(mktemp "${MAINTENANCE_FILE}.tmp.XXXXXX")"
} >"${marker_temp}"
chmod 0644 "${marker_temp}"
mv -fT -- "${marker_temp}" "${MAINTENANCE_FILE}"
replace_file_atomically "${marker_temp}" "${MAINTENANCE_FILE}"
marker_temp=""
trap - EXIT
echo "[maintenance] 已进入维护模式: ${MAINTENANCE_FILE}"
+9 -3
View File
@@ -892,7 +892,9 @@ ensure_default_worker_service() {
return 1
fi
mapfile -t services < <(list_worker_services "${pattern}")
while IFS= read -r service; do
services+=("${service}")
done < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -gt 0 ]]; then
return 0
fi
@@ -1033,7 +1035,9 @@ restart_worker_services() {
fi
ensure_default_worker_service "${pattern}"
mapfile -t services < <(list_worker_services "${pattern}")
while IFS= read -r service; do
services+=("${service}")
done < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 未发现已加载的外部生成 worker 单元: ${pattern}" >&2
return 1
@@ -1052,7 +1056,9 @@ wait_for_worker_services() {
return 0
fi
mapfile -t services < <(list_worker_services "${pattern}")
while IFS= read -r service; do
services+=("${service}")
done < <(list_worker_services "${pattern}")
if [[ "${#services[@]}" -eq 0 ]]; then
echo "[production-api-deploy] 外部生成 worker 单元不存在,发布失败: ${pattern}" >&2
return 1

Some files were not shown because too many files have changed in this diff Show More