diff --git a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs index 97d19625f..e170b07c6 100644 --- a/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs +++ b/apps/ai-game-creator-shell/scripts/agent-swarm-test-chat.mjs @@ -143,7 +143,12 @@ export const usage = `用法: --plan 走「做方案」立项策划入口,不做游戏,不做产物验收和试玩 --timeout-minutes <分钟> 设置本次执行期限;自动任务默认 50 分钟,--plan 默认 6 分钟,手工模式默认不限时 --dry-run 只检查目录发现和项目准备,不启动 LLM - -h, --help 显示帮助`; + -h, --help 显示帮助 + +环境变量: + AGC_PLAN_GDD_DECISION 审批卡自动应答动作,默认 approve;revise/reject 必须 + 同时用 AGC_PLAN_GDD_COMMENT 给出真实修改意见 + AGC_PLAN_GDD_COMMENT revise/reject 的意见原文`; function readOptionValue(args, index, option) { const value = args[index + 1]?.trim(); @@ -911,12 +916,15 @@ export function parseSettledSwarmTurnReport(output) { async function runCapturedCargo( cliArguments, setActiveChild, - { timeoutMs = null, label = 'Cargo 子命令' } = {}, + { timeoutMs = null, label = 'Cargo 子命令', stdin = null } = {}, ) { const child = spawnChild(cargoCommand, buildCargoCliArguments(cliArguments), { - stdio: ['ignore', 'pipe', 'pipe'], + stdio: [stdin === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], }); setActiveChild(child); + if (stdin !== null) { + child.stdin.end(stdin); + } let stdout = ''; let stderr = ''; child.stdout.setEncoding('utf8'); @@ -1007,7 +1015,9 @@ async function runTaskCargo( let planGddApprovalPromise = null; const startPlanGddApproval = () => { planGddApprovalStarted = true; - console.log('[自动审批] 检测到 Fast GDD 审批位,正在提交 approve'); + console.log( + `[自动审批] 检测到 Fast GDD 审批位,正在提交 ${resolvePlanGddAutoDecision().action}`, + ); planGddApprovalPromise = onPlanGddApprovalWait() .then((value) => { planGddApproval = value; @@ -1842,13 +1852,30 @@ export function parsePlanGddDecisionOutput(output) { ); } -// 审批卡是这条链路唯一的人类判据,所以自动应答只投 approve,且只在投影确实有一张 -// 待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把判据 -// 换成噪声;要跑那两条分支就手工调 --plan-gdd-decide。 +// 审批卡是这条链路唯一的人类判据,所以自动应答默认只投 approve,且只在投影确实有 +// 一张待决定审批时出手。revise/reject 需要一段真实的修改意见,让机器编一段等于把 +// 判据换成噪声——所以那两条分支只在跑的人自己用 AGC_PLAN_GDD_COMMENT 给出意见时 +// 才走。手工调 --plan-gdd-decide 也能达到同样效果,但那要求 plan 根 run 仍然活着, +// 而它恰好是本进程持有的 CLI 子进程。 export function planGddAutoApprovalIsPending(state) { return Boolean(state?.pendingApproval); } +export function resolvePlanGddAutoDecision(env = process.env) { + const action = (env.AGC_PLAN_GDD_DECISION ?? 'approve').trim(); + if (!['approve', 'revise', 'reject'].includes(action)) { + throw new Error('AGC_PLAN_GDD_DECISION 只能是 approve / revise / reject'); + } + const comment = (env.AGC_PLAN_GDD_COMMENT ?? '').trim(); + if (action === 'approve') return { action, comment: null }; + if (!comment) { + throw new Error( + `${action} 必须同时设 AGC_PLAN_GDD_COMMENT 提供真实修改意见`, + ); + } + return { action, comment }; +} + async function settlePlanGddApproval( projectPath, runtimeConfigPath, @@ -1875,18 +1902,21 @@ async function settlePlanGddApproval( if (!planGddAutoApprovalIsPending(before)) { return { decided: false, state: before }; } + const { action, comment } = resolvePlanGddAutoDecision(); const decision = await runCapturedCargo( [ '--config-dir', runtimeConfigPath, '--plan-gdd-decide', projectPath, - 'approve', + action, + ...(comment === null ? [] : ['--stdin']), ], setActiveChild, { timeoutMs: planGddApprovalTimeoutMs, label: 'Fast GDD 审批决定', + stdin: comment, }, ); if (decision.code !== 0 || decision.signal) { @@ -1926,7 +1956,7 @@ async function reportPlanGddApproval(approval) { return; } console.log( - ` [已批准] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, + ` [已决定 ${approval.receipt.decisionRef.action}] outcome=${approval.receipt.outcome} v${approval.receipt.decisionRef.version} 投影状态=${state.state}`, ); if (approval.recovered) { console.log( diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md index feef2a6a2..9901e42d7 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/plan/supervisor-playbook.md @@ -5,11 +5,11 @@ 3. 等待子 Agent 期间不得调用 `respond_to_user`。Runtime 会通过 delegate 完成屏障保持同一父 run,回执到达后再继续。 4. 子 Agent 以问询信封退出时,决策卡由 Runtime 直接按信封原文呈现给用户,**不需要你调用任何工具**——你根本不会在那一刻被恢复。用户答完之后你才会拿到答案,届时为该原 delivery 创建且仅创建一次 continuation 委派,`continuationOfDelegationId` 与 `repairOfDelegationId` 都指向该原 delivery。`questionsSha256`、`answersSha256`、`acceptanceCriteria`、`expectedArtifacts` 四个全传 null——Runtime 会从该原 delivery 补齐权威指纹和原委派合同,你不要自己抄。子 Agent 在 continuation 里**再次**以信封退出时,对那条新 delivery 重复同一动作:「仅创建一次」约束的是单条 delivery,不是整条链,澄清预算未用尽时这个循环继续。Runtime 会在委派 task 末尾写明已用轮次与上限,不需要你自己数,也不要替它宣布预算已尽。 5. 回执 contractStatus=evidence-ready 且 GDD 已提交时,用 `file.read` 从第 1 行读到 `game/fast_gdd.md` 末尾取证,每次都传 `maxLines: 240`(上限),尽量一页读完;确实需要第二页时从上一页的下一行开始,不要重复读同一段。每次 `file.read` 的 observation 末尾都带着 `sourceAgentId` / `sourceRunId` / `sourceActionId` 三个字段,把它们原样抄成 evidence 的 `{agentId, runId, actionId}`,用一次 `agent.acceptance_update` 一并提交即可——evidence 是按这三个字段整体查回执的,回忆错任何一个都会被判成"缺少持久动作回执"。不要为了取这些字段再去查动作历史。取证完成前审批卡不会出现。 -6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里;同一原委派只能返工一次。用户通过后只做一句简短收尾。 +6. 用户在审批卡上选择修改或退回时,直接创建返工委派:`repairOfDelegationId` 指向原 delegationId,`runId`、`acceptanceCriteria`、`expectedArtifacts` 都传 null——Runtime 会从原 delivery 继承权威合同,不需要先 `agent.run_status` 去取再手抄。把用户原话完整附在 task 里。「同一原委派只能返工一次」约束的是单条 delivery,不是整条链:用户看过新稿再点一次修改,就对那条新 delivery 重复同一动作,这个循环没有次数上限——`repair_depth` 防的是 runaway agent,而每一轮修订都由用户亲手触发,人本身就是循环边界。不要替 Runtime 宣布「这是最后一次修改机会」,也不要因此把多条意见攒到一轮里改完。用户通过后只做一句简短收尾。 【转达的规则】 -- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 恒为「第N轮·关键决定」,不含任何信息量;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它 header 和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 +- 把用户答案回灌给 `project-planning` 时,逐条列出全部已确认决定,每条格式为 `[已确认] 第N轮问的是:{question 原文} | 候选项:{option1.label} / {option2.label} / {option3.label} → 用户答:{原文}`。**问题原文和三个选项标签必须带上**:`{header}` 只写到「第N轮·当前要决定:{主题}」这一层,答案落在选项上;子 Agent 每轮都是全新 run,除了这段正文什么都看不到,只给它主题和答案,「类似B」「B · 沙盒里程碑成长」这类答案就无从解读,它只能把同一件事再问一遍。用户答案原文一字不改、不归纳、不拆分、不搬轮次;任务长度接近上限时压缩你自己的说明文字和选项描述,绝不压缩用户答案、问题原文和选项标签。 - 策划链路的澄清信封**恰好一题**,不是通用静态委派协议里的 1-3 题:`project-planning` 每轮只提一个主要决定,Runtime 也只接受一题,多于一题会在出卡时被拒。委派 task 里不要写“1-3 个结构化问题”。 - 上一条格式里的三个选项标签就是决策卡上的 A、B 和“需要原型验证”,必须原样转述、一个都不能省;B 是用户确认的 `confirmed/user_option`,不能转成默认建议。用户后续自由填写推翻了更早的决定时,你只负责把两轮答案的原文都原样带到,并说明后者更晚;怎么记进决定台账由 `project-planning` 判断,不要替它裁定哪条作废。 diff --git a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md index b8b19ccbc..347c83ae8 100644 --- a/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md +++ b/apps/ai-game-creator-shell/src-tauri/prompts/runtime/roles/project-planning.md @@ -3,26 +3,26 @@ ## 身份与边界 - 当前 run 固定为 `source=agent-delegate`、`profile=standard`,父 Agent 是 `project-supervisor`。不得伪造、改写或猜测这些 Runtime 身份。 -- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 -- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,单行且不超过 12 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 是 2~3 个 `{"label": ..., "description": ...}`,label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 +- 你不能委派或调度其他 Agent,不能创建 isolated child,不能调用 MCP、命令、进程、预览、画布、素材生成、写入/补丁/删除工具,也不能改变项目版本或审批事实。 +- 你的原生工具目录只应包含 `file.read`、`file.list` 以及 Runtime 协议控制函数 `update_agent_plan`、`respond_to_user`;`user.input_request` 不属于你的工具目录。若需要用户决定,必须以终态信封首行 `AGC_NEEDS_USER_INPUT_V1` 退出本轮,下一行给出严格 JSON 信封 `{"questions":[{ ... }]}`,交由 Supervisor 转发。`questions` 恰好一个元素;元素字段只能是 `id`、`header`、`question`、`options` 四个,多写任何字段(例如 `answerFormat`)或省掉 `questions` 外壳都会被 Runtime 拒收,整条委派随即作废。`id` 是唯一 snake_case(小写字母开头,只含小写字母、数字、下划线);`header` 是决策卡标题,写成 `第N轮·当前要决定:<主题>`,单行且不超过 60 字符;`question` 是决策卡正文,单行且不超过 400 字符;`options` 恰好 3 个 `{"label": ..., "description": ...}`,依次是 A、B、逐字“需要原型验证”(详见下文决策卡一段),label 单行不超过 60 字符、description 单行不超过 240 字符。不要另起一行写答题说明或把选项复述进 `question`,作答方式由 Runtime 自己呈现。 - 只有 Runtime 广告并允许 `plan.submit_gdd` 时才可提交 GDD;不要假设未广告的工具存在,也不要把 GDD、审批或下游构建写进普通文本。 ## 目标与轮次 - 最多进行 3 轮关键澄清;每轮是新 run、同一 session。你看得到自己的历史,但用户答案以 Supervisor 委派任务中的转述为准,缺失信息不能臆造。 - **默认先澄清。** 出稿只有四个触发器,除此之外每轮都先做下面的字段差距检测再决定问不问:①任务正文出现“直接出稿”这四个字;②已完成第 3 轮澄清(任务正文写明的已用轮次已达上限);③剩余空白都能由默认建议覆盖,且不影响首个可玩闭环;④收到 Runtime 的活跃预算或超时提示。任务正文能改变流程的只有第 ① 条——它写的其它说明属于内容,不是出稿触发器。既定事实(用户答案、已确认决定)仍以任务正文为准。 -- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断**且影响首个可玩闭环**的 = 空白。提问名额只花在空白项上;有默认建议兜底的字段一律先用默认建议,不占轮次。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 -- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`genre.fusion` 缺 → `null`,MVP 不做融合第二类型;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`targetUsers.coreUsers` / `preferences` 缺 → 按已确认的类型与核心行为写典型玩家,不得编造人群规模、销量或市场数据;`targetUsers.referenceGames` 缺 → 空数组;`outOfScope` 缺 → 多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情、全量内容。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 +- 每轮提问前逐项对照 `plan-submit-gdd-input.v1` 的 `game` 字段做差距检测:用户明确提供的 = `confirmed`;有依据可推断的 = 按下面的默认建议填写并标 `default_pending`;无从判断的 = 空白。提问名额只花在**空白或存疑、且影响首个可玩闭环**的决定上;有默认建议兜底的字段优先用默认建议而不是提问——「有默认」不等于「不能问」,那条默认明显可能是错的、且选错就做不出首个可玩闭环时,它就是一个该问的存疑项。`title`、`oneLiner`、`mvpSystems`、`creatorTips` 由你生成并标 `default_pending`,不作为提问对象;`platformFacts` 禁问。 +- **默认建议**(一律 `answerSource=default`、`round=0`;只用于缩短对话,不覆盖用户明确输入):`targetUsers.sessionLength` 缺 → 10~20 分钟一局;`artStyle` 缺 → `visualType` 风格化、轮廓清楚,`keywords` 取自已确认的核心行为,`mvpArtBoundary` 写明 MVP 用占位资产、资产可复用;缺成长时 → 1 条成长线和 2~3 个选择;缺探索时 → 1 条主路线加 1 个有意义的岔路;缺构建时 → 高风险输出和稳健防御两种方向。清单之外的字段没有默认值兜底——`genre.fusion`、`targetUsers.coreUsers` / `preferences` / `referenceGames`、`outOfScope` 缺失时都算空白,该不该花一轮问它们由上面的判据决定,不要自己拍一个值填掉就当它已经定了。**`pillars` 与 `coreLoop` 没有默认建议**:它们就是首个可玩闭环本身,空白时属于该问的空白,不得用默认值填掉。 - 优先顺序:核心行为与本局目标 → 重玩动力 → 制作边界与 MVP。每轮最多问一个主要决定。**已确认决定关掉的那条轴不得重问。** 任务正文里每条 `[已确认]` 都带着当轮的问题原文和三个选项标签,先照它判断哪些轴已经关闭,本轮的问题必须落在另一条还没关闭的轴上。把已确认答案换个说法再问一遍——例如用户已经选定“自由经营、靠成就和攒钱升级推进”,你又拿“短周期经营目标 vs 沙盒里程碑成长”去问——是白烧一轮预算。所有轴都已关闭时按出稿触发器③直接出稿。 -- 决策卡的 header 固定为“第N轮·关键决定”,其中 N 是 Runtime 从委派谱系派生的当前轮号,必须精确相等,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。正文以“当前要决定:”开头,只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 +- 决策卡的 header 写成“第N轮·当前要决定:<主题>”,最多 60 字符。N 是 Runtime 从委派谱系派生的当前轮号,写错会被 Runtime 拒收:首轮恒为 1;之后每次续跑的任务正文都会写明已用轮次与上限,本轮该用的 N 就是“已用轮次 + 1”。`<主题>` 是这一轮真正要定的那件事本身(例如“塔的构筑方式”“每局变化来源”),一句话说完、不带状态标记——它会原样落进决定台账的 `topic`,也是你下一轮辨认哪些轴已经关掉的唯一线索,写成“关键决定”这类空话等于把它作废。正文只问尚未由平台事实或 MVP 规则排除的真实产品取舍,并说明为什么现在问;每张卡固定提供三个选项:A 是你的推荐方案(label 以 `A ·`、`A:`、`A:` 或 `A-` 开头并写明推荐、好处和代价),B 是形状不同且真实可行的平行备选(label 以 `B ·`、`B:`、`B:` 或 `B-` 开头并写明后果和代价),第三项逐字为“需要原型验证”,description 必须给出 30~90 分钟微型原型、试玩对象、观察信号和通过标准。自由输入按用户原话处理。 ## 低幻觉与 GDD 约束 - 用户描述玩法类型、机制或“像某款游戏”时,不代表授权复刻该游戏。游戏名称、世界观、角色与单位名、阵营、资源、界面术语和视觉语言必须原创;不得沿用、翻译或近似改写现有游戏的专有名称、Logo、标志性布局与受保护视觉语言,也不得把它们写进 GDD 正文、决定台账或原型验证项。用户提到的相似作品只能作为抽象品类参考,`targetUsers.referenceGames` 同样不得填入受保护名称。你的工具面窄,但内容红线不因此放宽——GDD 是整条产线的上游。 -- 决定台账里,**事实归 Runtime、判断归你**。`decisions` 必须逐条包含 Runtime 已记录的全部决定(含首项 `initial-request`),id 用你提问时的 `id` 把下划线换成连字符;这些条目的 `answerSummary`、`answerSource`、`round` 由 Runtime 用用户的真实作答覆盖,你写占位值也会被替换,**不需要、也不要**为了抄准而改写或压缩用户原话。你真正决定的是 `topic` 和 `state`。 -- A、B 或自由填写得到的用户决定标 `confirmed`;用户选择“需要原型验证”标 `prototype_pending`,并保留同 id 的原型验证项——这两项是用户亲手选的,不得改判。只有未提问、由你按默认建议填写的字段才标 `default_pending`,其 `answerSource=default`、`round=0`。不要把用户选择的 B 当成默认项,也不要凭空把没问过的字段标成 `confirmed`——Runtime 会拒收任何没有对应用户作答的 `confirmed`。 -- 用户的自由填写没有回答你问的那道题时(他谈的是别的取舍,或者推翻了更早的决定),改这条决定的 `topic`,按他**实际说的内容**重新命名——这是你纠正错误绑定的唯一手段,Runtime 不会替你判断一句话答没答上一道题。若他对该题确实没有作出取舍,把该条降级为 `default_pending` + `answerSource=default` 并按默认建议写 `answerSummary`,再另起一条记录他实际确定下来的东西,在新条目的 `topic` 里写明与被推翻决定的关系。降级只能往这个方向;用户已作出的决定不得整条丢弃。 +- 决定台账记录当前 GDD 的决定快照。澄清阶段的 A、B 或自由填写得到的用户决定标 `confirmed`,选择“需要原型验证”标 `prototype_pending`;未提问、由你按默认建议填写的字段标 `default_pending`、`answerSource=default`、`round=0`。审批阶段的用户修改意见是本轮最高优先级:由该意见新增或改写的决定使用 `answerSource=user_revision`、`round=0`,并按当前意见重新填写 `topic`、`state` 和 `answerSummary`。 +- 以当前 GDD 为基线,仅修改用户审批意见明确涉及的内容,以及为保持内部一致性所必需同步调整的派生内容。未被意见涉及的内容保持不变;如果意见与过去决定冲突,以最新意见为准。不要把用户未要求的其它方向自行扩展进本轮修订。提交时仍须提供完整 GDD 快照,但完整快照不代表可以任意重写未涉及内容。 - `prototypeValidationItems` 是必填字段(没有就传空数组),与 `prototype_pending` 决定**一一对应**:每条 `prototype_pending` 决定必须有一个同 id 的验证项,每个验证项也必须对应一条 `prototype_pending` 决定,最多 3 项。除了用户亲选“需要原型验证”之外,你自己也可以主动标:手感、节奏、可读性、难度曲线这类你没问过、但选错就做不出首个可玩闭环的判断,标 `prototype_pending`(`answerSource=default`、`round=0`)比标 `default_pending` 诚实——那不是一个默认值,是一个没人验证过的假设。每项写清 30~90 分钟微型原型做什么、让谁试玩、观察什么信号、什么算通过。 +- 不得编造具体游戏的机制、数值、销量、人群规模、团队规模或来源。写 `targetUsers` 时按已确认的类型与核心行为描述典型玩家即可。 - 只定义一个完整可玩闭环。MVP 不含多人、商城、服务器、开放世界、赛季、复杂社交、完整剧情或全量内容,除非用户明确改变范围。 - GDD 至少覆盖:游戏名称与类型、一句话描述、2~4 条游戏支柱、核心循环、目标用户、美术方向、3~6 个最小 MVP 系统、先做/暂缓/验证/扩展条件、决定状态和审批请求。不要把 Runtime 注入的身份、时间、指纹、审批 receipt 或平台事实当作 Provider 输入字段。 - 平台事实由 Runtime 固定注入为自包含 Web、desktop/mobile 双视口、keyboard/touch 双输入、本地 HTTP 预览;不得修改、删减或向用户询问。 diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs index 5ddffe348..7648408da 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/prompt.rs @@ -872,13 +872,17 @@ mod tests { "brief 要点名这个真实踩过的坑" ); for stated in [ - format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS} 字符"), + // 策划卡的 header 走 `AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS`:它装的是 + // 这一轮要定的主题本身,不是通用问询那 12 字的标题格。钉住的仍是「brief 与 + // 解析器同一把尺子」,只是尺子换成了策划链路实际生效的那一把。 + format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS} 字符"), format!("不超过 {AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS} 字符"), - format!( - "{AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS}~{AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS} 个" - ), + // 选项数同理:通用协议是 2-3 个,策划决策卡恒为 A / B /「需要原型验证」 + // 三项。brief 早先照通用常量写「2~3 个」,和它自己下文的「固定提供三个 + // 选项」以及 `planning_coordinator` 的硬校验三方打架。 + format!("恰好 {PLAN_CLARIFICATION_OPTION_COUNT} 个"), ] { assert!( planning.contains(&stated), @@ -985,9 +989,17 @@ mod tests { /// 这条会跟着红。 /// /// 二、`pillars` / `coreLoop` 明确排除在清单外:它们就是首个可玩闭环本身, - /// 给它们配默认值等于把最该花提问预算的那两项默认掉。原型那份清单里的 - /// 成长 / 探索 / 构建三条落到本仓库的 schema 上正好落在这两个字段上,照抄 - /// 会和「提问顺序:核心行为与本局目标 → 重玩动力」的前两顺位直接打架。 + /// 给它们配默认值等于把最该花提问预算的那两项默认掉。 + /// + /// 清单成员已按原型(`local-scripts/deisgn_agent/prompts.py:136`)那五条拉齐: + /// 局长偏好、美术、成长、探索、构建。`genre.fusion` / `targetUsers.coreUsers` + /// / `preferences` / `referenceGames` / `outOfScope` 从清单里摘掉了——它们 + /// 原型就没有默认值,进了清单就等于把第三顺位「制作边界与 MVP」整条轴默认 + /// 掉,出稿触发器③「剩余空白都能由默认建议覆盖」随之在第 3 轮恒真,3 轮预算 + /// 实际只花得出 2 轮。成长 / 探索 / 构建三条与上面那句不冲突:它们是维度级 + /// 缺省内容,不是 `pillars` / `coreLoop` 两个字段的缺省值,而且「优先用默认 + /// 建议而不是提问」是软优先级,不禁止提问——原型正是带着这三条默认,仍然把 + /// 第 1 轮花在 coreLoop、第 2 轮花在重玩动力上。 /// /// 三、出稿触发器是闭集。生产实测过 Supervisor 会把「若缺少会实质改变结果的 /// 事实才提问,否则直接提交」写进委派 task,子 Agent 照办后 0 轮出稿;这里 @@ -1003,15 +1015,27 @@ mod tests { planning.contains("**默认建议**"), "role brief 三处引用「默认建议」,清单本身必须在场" ); - for field in [ - "`genre.fusion`", - "`artStyle`", - "`targetUsers.sessionLength`", - "`targetUsers.referenceGames`", - "`outOfScope`", - ] { + for field in ["`artStyle`", "`targetUsers.sessionLength`"] { assert!(planning.contains(field), "默认建议清单缺少字段 {field}"); } + for dimension in ["缺成长时", "缺探索时", "缺构建时"] { + assert!( + planning.contains(dimension), + "默认建议清单缺少原型的维度级缺省 {dimension}" + ); + } + // 反向:这几个字段一旦回到默认清单,轴三就又被默认掉了。它们仍会在 brief 里 + // 出现(被点名为「没有默认值兜底」),所以只能钉「缺 → 」这个清单条目形状。 + for defaulted in [ + "`genre.fusion` 缺 →", + "`targetUsers.referenceGames` 缺 →", + "`outOfScope` 缺 →", + ] { + assert!( + !planning.contains(defaulted), + "{defaulted} 不得回到默认建议清单:那会让出稿触发器③在第 3 轮恒真" + ); + } assert!( planning.contains("**`pillars` 与 `coreLoop` 没有默认建议**"), "pillars / coreLoop 不得进默认建议清单" @@ -1528,12 +1552,15 @@ mod tests { /// 澄清回灌必须带上问题原文和三个选项标签,两端都要钉住。 /// - /// `header` 按信封契约恒为「第N轮·关键决定」,零信息量;而 `project-planning` - /// 每轮都是全新 run(`observations: []`),除了委派任务正文什么都看不到。只回灌 - /// `{header} → 用户答:{原文}` 时,「类似B」「B · 沙盒里程碑成长」这类答案无从 - /// 解读——生产实测的农场经营项目里,第 1 轮问「季节订单冲刺 vs 自主农场成长」, - /// 用户答了 B,第 2 轮又拿「短周期经营目标 vs 沙盒里程碑成长」问同一条轴, - /// 而且 B 选项几乎是用户原话的复述。 + /// `header` 现在带主题(「第N轮·当前要决定:{主题}」),但只到主题这一层——用户 + /// 拍的板落在**选项**上。而 `project-planning` 每轮都是全新 run(`observations: []`), + /// 除了委派任务正文什么都看不到。只回灌 `{header} → 用户答:{原文}` 时, + /// 「类似B」「B · 沙盒里程碑成长」这类答案仍然无从解读——生产实测的农场经营项目里, + /// 第 1 轮问「季节订单冲刺 vs 自主农场成长」,用户答了 B,第 2 轮又拿「短周期经营 + /// 目标 vs 沙盒里程碑成长」问同一条轴,而且 B 选项几乎是用户原话的复述。 + /// + /// 这条与 header 带不带主题正交:主题解决「问过哪些轴」,选项标签解决「答案指的是 + /// 哪一个」。两端都得钉。 #[test] fn plan_clarification_relay_carries_the_question_and_option_labels() { let plan = required_runtime_prompt_section("planSupervisorPlaybook"); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs index 9b3e03fae..bd5fc0fb3 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions.rs @@ -150,6 +150,6 @@ pub(crate) use tool_policy_snapshot::{ agent_runtime_native_executable_tools, agent_runtime_plan_root_supervisor_tools, agent_runtime_plan_root_supervisor_tools_for_stage, agent_runtime_tool_policy_snapshot_for_run_at, plan_root_supervisor_stage_at, - PlanRootSupervisorStage, AGENT_RUNTIME_CANVAS_ASSET_KINDS, - AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, + plan_root_supervisor_stage_at_locked, PlanRootSupervisorStage, + AGENT_RUNTIME_CANVAS_ASSET_KINDS, AGENT_RUNTIME_PROJECT_PLANNING_ACTION_TOOLS, }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs index 3ddefafc7..fa78cab89 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/project_gates.rs @@ -995,6 +995,25 @@ pub(in crate::agent) fn static_delegate_barrier_has_waiting_deliveries(detail: & waiting || user_revision_pending || unknown_contract_status } +/// `StaticDelegateCompletionBarrier::has_external_wait()` 的 detail 侧等价物。 +/// +/// 与 `static_delegate_barrier_has_waiting_deliveries` 的差别只有一项:不计 +/// `userRevisionPending`。park 决策必须用这个——用户修订没有任何外部事件可等, +/// park 住就是等自己派出的委派,必然死锁。自动唤醒侧仍然用前者收手。 +pub(in crate::agent) fn static_delegate_barrier_has_external_wait(detail: &str) -> bool { + let waiting = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("waitingDelegations=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + let unknown_contract_status = detail + .split_whitespace() + .find_map(|part| part.strip_prefix("unknownContractStatus=")) + .and_then(|value| value.parse::().ok()) + .is_some_and(|count| count > 0); + waiting || unknown_contract_status +} + pub(in crate::agent) fn static_delegate_barrier_requires_repair(detail: &str) -> bool { detail .split_whitespace() @@ -1888,6 +1907,21 @@ mod static_delegate_barrier_detail_gate_tests { barrier.user_revision_pending_count > 0, "userRevisionPending 往返失真:{barrier:?}\ndetail={detail}" ); + assert_eq!( + static_delegate_barrier_has_external_wait(&detail), + barrier.has_external_wait(), + "has_external_wait() 与 detail 解析必须等价:{barrier:?}\ndetail={detail}" + ); + // 两个谓词只能在「仅 userRevisionPending」这一种情形上分叉,别的组合必须一致。 + // 分叉点写死在这里:park 决策用 has_external_wait,自动唤醒收手用 has_waiting, + // 哪天有人把两者合并回一个,这条会先炸。 + assert_eq!( + barrier.has_waiting() && !barrier.has_external_wait(), + barrier.user_revision_pending_count > 0 + && barrier.waiting_count == 0 + && barrier.unknown_contract_status_count == 0, + "两个等待谓词只应在「仅用户修订待办」时分叉:{barrier:?}" + ); } #[test] diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs index c1fdcb5e4..b61c02441 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_request_builders.rs @@ -152,8 +152,9 @@ pub(in crate::agent) fn remove_autonomous_art_director_non_canvas_validation_too Ok(()) } -pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( +fn build_game_creator_agent_background_tool_plan_request_at( root: &Path, + project_lock: Option<&ProjectWriteLock>, agent_id: &str, session_id: &str, run_id: &str, @@ -509,7 +510,12 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )?) .with_tool_choice(platform_llm::LlmToolChoice::Required); if plan_root { - let stage = plan_root_supervisor_stage_at(root, agent_id, run_id)?; + let stage = match project_lock { + Some(project_lock) => { + plan_root_supervisor_stage_at_locked(root, project_lock, agent_id, run_id)? + } + None => plan_root_supervisor_stage_at(root, agent_id, run_id)?, + }; retain_plan_root_supervisor_native_tools(&mut request.function_tools, stage)?; // 固定单节点 schema 只对还在广告 agent.goal_contract 的阶段有意义;收窄之后 // 它已经不在目录里,此处再调只会撞上那道 fail-closed 的"缺少工具"守卫。 @@ -643,6 +649,67 @@ pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( )) } +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request( + root: &Path, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + None, + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + +pub(in crate::agent) fn build_game_creator_agent_background_tool_plan_request_locked( + root: &Path, + project_lock: &ProjectWriteLock, + agent_id: &str, + session_id: &str, + run_id: &str, + task: &str, + observations: &[AgentRuntimeToolObservation], + loop_index: usize, +) -> Result< + ( + GameCreatorLlmConfig, + String, + LlmRunRequest, + String, + AgentRuntimeToolPlanRequestSnapshot, + ), + String, +> { + build_game_creator_agent_background_tool_plan_request_at( + root, + Some(project_lock), + agent_id, + session_id, + run_id, + task, + observations, + loop_index, + ) +} + pub(in crate::agent) fn build_game_creator_agent_background_final_reply_request( root: &Path, agent_id: &str, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs index 017966869..b04408c07 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/provider_tool_plan.rs @@ -239,8 +239,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -303,8 +304,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at == AGENT_RUNTIME_RUN_PROFILE_AUTONOMOUS_GAME_BUILD && agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID && autonomous_manifest_dag_in_progress_at(root)?; - let request = build_game_creator_agent_background_tool_plan_request( + let request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, @@ -356,8 +358,9 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at // that lock so a session successor cannot be used to re-label an // object assembled from an older session. if agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID { - built_request = build_game_creator_agent_background_tool_plan_request( + built_request = build_game_creator_agent_background_tool_plan_request_locked( root, + &_lock, agent_id, session_id, run_id, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs index 477e54c39..54cefdf91 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/response_stream_tests.rs @@ -1847,3 +1847,68 @@ fn finalization_v4_binds_response_request_slot_into_identity() { .expect_err("tampered v4 responseRequestSlot must break finalization identity"); assert!(error.contains("幂等身份不匹配")); } + +/// 澄清信封退出不受结构化计划完成度判据约束,普通交付收束仍然受。 +/// +/// 这两半是同一条不变量的两面,缺任何一面都是活锁:`respond_to_user` 是问询唯一 +/// 的出口,而计划里「按用户决定收敛」那一步在用户答之前不可能 completed——用完成 +/// 度拦信封,就等于问不出去、答不了、永远重试。实测一条生产 run 因此空转 65 轮。 +#[test] +fn a_user_input_envelope_finalizes_while_an_incomplete_plan_still_blocks_delivery() { + let (project, mut state, response_revision, _snapshot) = + response_stream_fixture("finalization-user-input-envelope-run"); + let root = project.path(); + state.plan_revision = 1; + state.plan_explanation = "先问清核心闭环再出稿。".to_string(); + state.plan = vec!["发起首轮澄清".to_string(), "按用户决定出稿".to_string()]; + state.plan_steps = vec![ + AgentRuntimePlanStep { + index: 0, + title: "发起首轮澄清".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_IN_PROGRESS.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + AgentRuntimePlanStep { + index: 1, + title: "按用户决定出稿".to_string(), + status: AGENT_RUNTIME_PLAN_STATUS_PENDING.to_string(), + detail: None, + updated_at: unix_timestamp(), + }, + ]; + state.active_plan_step_index = Some(0); + write_game_creator_agent_runtime_state(root, &state).expect("write incomplete plan state"); + + let delivery = "已完成本轮交付。"; + let blocked = finish_game_creator_agent_background_runtime_turn_at( + root, + state.clone(), + delivery, + response_revision, + &[], + ) + .expect("finalize plain delivery"); + match blocked { + AgentBackgroundFinalizationOutcome::Stale(blocker) => { + assert_eq!(blocker.tool, "runtime.plan_update"); + } + other => panic!("计划未完成时普通交付收束必须被拦下,实际 {other:?}"), + } + + let envelope = format!( + "{STATIC_DELEGATE_USER_INPUT_PREFIX}{{\"questions\":[{{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环\",\"question\":\"本局主要追求什么?\",\"options\":[{{\"label\":\"A · 推荐:抵达终点\",\"description\":\"沿路线避障抵达终点。\"}},{{\"label\":\"B · 计分生存\",\"description\":\"在加速路线里刷新分数。\"}},{{\"label\":\"需要原型验证\",\"description\":\"各做一个最小原型让目标玩家试玩。\"}}]}}]}}" + ); + let finalized = finish_game_creator_agent_background_runtime_turn_at( + root, + state, + &envelope, + response_revision, + &[], + ) + .expect("finalize clarification envelope"); + assert!( + !matches!(finalized, AgentBackgroundFinalizationOutcome::Stale(_)), + "澄清信封是挂起等用户答,不能被计划完成度判据拦下" + ); +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs index 6f79e5476..cf652c9f6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_actions/tool_policy_snapshot.rs @@ -159,6 +159,9 @@ pub(crate) enum PlanRootSupervisorStage { GoalContract, /// 合同已冻结但本根 run 还没有任何委派:唯一能推进的动作是派出策划子 Agent。 Delegate, + /// 最新 GDD 已提交但尚未完成当前根 Run 的 Acceptance Graph 取证:只能读取 + /// `game/fast_gdd.md`、更新验收图或重放状态,不能抢先创建重复策划 delivery。 + AwaitingAcceptanceEvidence, /// 已有委派:取证、返工与审批相关工具全部开放。 Delegated, } @@ -169,6 +172,9 @@ pub(crate) fn agent_runtime_plan_root_supervisor_tools_for_stage( match stage { PlanRootSupervisorStage::GoalContract => &["agent.goal_contract"], PlanRootSupervisorStage::Delegate => &["agent.delegate"], + PlanRootSupervisorStage::AwaitingAcceptanceEvidence => { + &["file.read", "agent.acceptance_update", "agent.run_status"] + } // 合同已冻结且不可重写,再广告 agent.goal_contract 只会诱导一次必被拒的调用。 PlanRootSupervisorStage::Delegated => &[ "file.read", @@ -195,6 +201,7 @@ mod plan_root_stage_tests { let union = [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] .into_iter() @@ -224,22 +231,47 @@ mod plan_root_stage_tests { } /// 只按 durable 事实判定阶段,不看 Provider 说了什么。 -pub(crate) fn plan_root_supervisor_stage_at( +/// +/// 调用方必须已经持有当前项目写锁;需要自行取得锁的调用方使用下面的 +/// `plan_root_supervisor_stage_at` 包装入口。这样 Provider 请求构建路径可以复用外层 +/// 已有的项目锁,不会在阶段判定中再次获取同一把非重入锁。 +pub(crate) fn plan_root_supervisor_stage_at_locked( root: &Path, + project_lock: &ProjectWriteLock, agent_id: &str, run_id: &str, ) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } if read_game_creator_agent_runtime_goal_contract_at(root, agent_id, run_id)?.is_none() { return Ok(PlanRootSupervisorStage::GoalContract); } let delegated = list_static_delegate_deliveries_at(root)? .into_iter() .any(|delivery| delivery.parent_agent_id == agent_id && delivery.parent_run_id == run_id); - Ok(if delegated { - PlanRootSupervisorStage::Delegated - } else { - PlanRootSupervisorStage::Delegate - }) + if !delegated { + return Ok(PlanRootSupervisorStage::Delegate); + } + if agent_id == GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID + && plan_root_supervisor_acceptance_evidence_required_locked(root, project_lock, run_id)? + { + return Ok(PlanRootSupervisorStage::AwaitingAcceptanceEvidence); + } + Ok(PlanRootSupervisorStage::Delegated) +} + +/// 供未持有项目写锁的调用方使用的阶段判定入口。 +pub(crate) fn plan_root_supervisor_stage_at( + root: &Path, + agent_id: &str, + run_id: &str, +) -> Result { + let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + root, + "planning.supervisor-stage", + )?; + plan_root_supervisor_stage_at_locked(root, &_lock, agent_id, run_id) } pub(crate) fn agent_runtime_native_executable_tools() -> Vec<&'static str> { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs index 7cb0b3801..fc5d9579a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/finalization.rs @@ -393,7 +393,11 @@ pub(in crate::agent) fn resume_game_creator_agent_finalization_at( } if journal.status == AGENT_RUNTIME_FINALIZATION_STATUS_PREPARED && !assistant_exists { let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 与 `finish_game_creator_agent_background_runtime_turn_with_checkpoint_at` + // 同一判据:澄清信封是挂起等用户答,不是交付收束,用计划完成度拦它会死锁。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(&journal.response)) + { Some(blocker) } else if let Some(blocker) = plan_gdd_completion_blocker_at_locked(root, &journal.agent_id, &journal.run_id) diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs index c841465d3..379b102c7 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/main_loop.rs @@ -208,15 +208,10 @@ pub(super) fn game_creator_agent_final_reply_error_allows_fallback(error: &str) matches!(kind.as_str(), "empty-response" | "deserialize") } -/// `PLAN_SESSION_DECISIONS_MISMATCH` 与前两者同类:错的是本次 Provider input, -/// durable 权威完好,把拒绝理由回灌给策划子 Agent 它就能改。真 CAS -/// (`PLAN_SESSION_CAS_CONFLICT`)不在此列——那说明 session 已被推进或损坏, -/// 重交同一份 input 不可能成功,必须 reconcile。 +/// 这些错误只描述本次 Provider input 或候选 GDD;真正的 session CAS 冲突不在 +/// 此列——那说明 durable session 已被推进或损坏,必须 reconcile。 fn plan_submit_error_is_business_rejection(error: &PlanningStorageError) -> bool { - matches!( - error.code(), - "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT" | "PLAN_SESSION_DECISIONS_MISMATCH" - ) + matches!(error.code(), "PLAN_INVALID_REQUEST" | "PLAN_SIZE_LIMIT") } /// A malformed Fast GDD is useful feedback for the planning child, but it @@ -255,6 +250,39 @@ fn finish_plan_submit_business_rejection_limit_at( /// Runtime state 上,进程重启不能把一次活锁洗成新的无限 Provider 开销。 const AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT: u32 = 4; +/// 最终回复被收束门禁拦下后 run 会原地续跑重试。多数 blocker 是模型自己能解的 +/// (补动作、补证据、重新规划),所以这里的额度比上面两个宽得多;它拦的是另一 +/// 类:模型根本无法满足的 blocker。那种情况下每一轮都是同一个请求换来同一个拒绝, +/// 没有任何计数器会累加——空转闸只认裸 `update_agent_plan`,而这里模型每轮都在 +/// 认真调 `respond_to_user`。实测一条生产 run 因此空转 65 轮直到人工介入。 +const AGENT_RUNTIME_STALE_FINALIZATION_LIMIT: u32 = 32; + +fn stale_finalization_limit_reached(rounds: u32) -> bool { + rounds >= AGENT_RUNTIME_STALE_FINALIZATION_LIMIT +} + +fn next_stale_finalization_rounds(current: u32) -> (u32, bool) { + let next = current.saturating_add(1); + (next, stale_finalization_limit_reached(next)) +} + +fn finish_stale_finalization_limit_at( + root: &Path, + runtime: &AgentRuntimeState, +) -> Result { + let error = format!( + "最终回复连续 {} 轮被收束门禁拦下,已停止自动续跑;请检查最后一次 blocker observation 后重新发起本轮任务。", + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT + ); + let failed = fail_game_creator_agent_runtime_turn_at(root, runtime.clone(), &error)?; + let _ = append_game_creator_agent_background_task_failed_audit( + root, + &failed, + AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT, + ); + Ok(AgentBackgroundTaskOutcome::Finished) +} + /// 纯只读工具不算「推进」。 /// /// 空转计数只在**裸 `update_agent_plan` 且步骤没有真实变化**时累加,早期实现却让 @@ -350,6 +378,22 @@ mod plan_update_idle_guard_threshold_tests { assert!(first_repair_round < AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); } + /// 最终回复重试额度是 runaway 兜底,不是主判据:它必须留在两道软闸之上, + /// 让「摘掉 update_agent_plan 逼它调真动作」和空转闸先有机会自愈。调到软闸 + /// 以下,兜底就会抢在自愈之前把正常 run 打断。 + #[test] + fn the_stale_finalization_backstop_sits_above_the_self_healing_guards() { + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > AGENT_RUNTIME_PLAN_UPDATE_IDLE_LIMIT); + assert!(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT > PLAN_SUBMIT_GDD_BUSINESS_REJECTION_LIMIT); + assert!(!stale_finalization_limit_reached( + AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1 + )); + assert_eq!( + next_stale_finalization_rounds(AGENT_RUNTIME_STALE_FINALIZATION_LIMIT - 1), + (AGENT_RUNTIME_STALE_FINALIZATION_LIMIT, true) + ); + } + #[test] fn idle_limit_is_reached_only_at_the_configured_round() { assert!(!plan_update_idle_limit_reached(0)); @@ -459,6 +503,8 @@ const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_SUBMIT_REJECTION_LIMIT: &str = "plan-submit-validation-retries-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_PLAN_UPDATE_IDLE_LIMIT: &str = "plan-update-idle-rounds-exhausted"; +const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_STALE_FINALIZATION_LIMIT: &str = + "stale-finalization-rounds-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_BUDGET: &str = "loop-budget-exhausted"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINAL_REPLY: &str = "final-reply-failed"; const AGENT_RUNTIME_BACKGROUND_FAILURE_KIND_FINALIZATION: &str = "finalization-failed"; @@ -531,6 +577,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( }; } + // 同上:计数随上一轮的 blocker 一起落盘,重启不能把第 N 次被拦洗成新一轮。 + if stale_finalization_limit_reached(runtime.stale_finalization_rounds) { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } + if continuation.applied_steer_cursor < runtime.applied_steer_cursor { return fail_game_creator_agent_background_context_at( &root, @@ -681,8 +741,11 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( if let Some(blocker) = static_delegate_completion_blocker_at(&root, &agent_id, &runtime.run_id) { + // park 只在真有外部事件可等时才对。用户修订待办不是外部事件——那条回执 + // 只能来自本 run 自己创建的修订委派,park 住就是等自己。下面 1700 行附近 + // 的 `user_revision_pending` 分支才是它该去的地方。 let waits_for_delivery = blocker.detail.as_deref().is_some_and(|detail| { - static_delegate_barrier_has_waiting_deliveries(detail) + static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail) }); if waits_for_delivery { @@ -1559,6 +1622,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( { // 本轮至少有一个能推进 durable 状态的动作,计划没有空转。 runtime.plan_update_idle_rounds = 0; + // 同一个判据也给最终回复重试额度解锁:真实推进之后再被拦,是新的一 + // 轮尝试,不该继承上一段死循环的计数。 + runtime.stale_finalization_rounds = 0; } if plan.actions.is_empty() { // blocked 的 plan_gdd blocker 有三种截然不同的继续推进态,phase 与 @@ -1776,7 +1842,7 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::WaitingForIsolatedJoin, )) } else if observation.tool == "runtime.delegate_receipts" - && (static_delegate_barrier_has_waiting_deliveries(detail) + && (static_delegate_barrier_has_external_wait(detail) || static_delegate_barrier_requires_user_input(detail)) { Some(( @@ -3844,6 +3910,9 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( AgentBackgroundTaskOutcome::Finished } Ok(AgentBackgroundFinalizationOutcome::Stale(blocker)) => { + let (stale_rounds, exhausted) = + next_stale_finalization_rounds(runtime.stale_finalization_rounds); + runtime.stale_finalization_rounds = stale_rounds; if let Err(error) = provider_handoff::remove_at(&root, &agent_id, &runtime.run_id) { return fail_game_creator_agent_background_context_at( &root, @@ -3873,6 +3942,20 @@ async fn run_game_creator_agent_background_task_pass_without_deadline( ); } }; + // 计数已随 blocker 一起落盘,这里才收束:让最后一次拒绝的 observation + // 留在续跑上下文里,失败原因指得回具体 blocker 而不是一句「超限」。 + if exhausted { + return match finish_stale_finalization_limit_at(&root, &runtime) { + Ok(outcome) => outcome, + Err(error) => fail_game_creator_agent_background_context_at( + &root, + &agent_id, + &session_id, + runtime, + &format!("收束已耗尽的最终回复重试失败:{error}"), + ), + }; + } AgentBackgroundTaskOutcome::ContinueSameRun { state: runtime, continuation, @@ -3900,9 +3983,9 @@ mod plan_envelope_repair_tests { use super::*; const TRUNCATED: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"}]}"; const COMPLETE: &str = "AGC_NEEDS_USER_INPUT_V1 -{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",\"question\":\"当前要决定:?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; +{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",\"question\":\"?\",\"options\":[{\"label\":\"A\",\"description\":\"甲\"},{\"label\":\"B\",\"description\":\"乙\"}]}]}"; /// 截断的信封必须在 run 内被认出来,否则它会随 final reply 逃逸成一条 /// needs-repair 委派,把返工额度和澄清轮次一起卷进去。 @@ -3956,7 +4039,7 @@ mod plan_envelope_repair_tests { fn a_degenerated_tail_no_longer_burns_a_repair_attempt() { let reply = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"它决定玩家为何持续规划、赚钱与重玩,也控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施。"},{"label":"需要原型验证","description":"制作微型原型让目标玩家试玩两种目标结构。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ); assert!(game_creator_agent_runtime_plan_envelope_parse_error( GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, @@ -4101,27 +4184,4 @@ mod plan_gdd_blocker_projection_tests { "版本上限由既有 lineage 决定,重试相同 Provider submit 不会改变它" ); } - - /// 台账逐项比对失败是本次 Provider input 写错,durable 权威完好,回灌理由后 - /// 策划子 Agent 能自行改稿;真 CAS 则说明 session 已被推进或损坏,重交同一份 - /// input 不可能成功。两者曾共用 `PLAN_SESSION_CAS_CONFLICT`,导致前者也被判成 - /// 硬阻断——实测中策划子 Agent 靠回灌连改三轮修好了形状层,紧接着撞上这一支 - /// 直接 needs-reconciliation,整条链路无产物收场。 - #[test] - fn session_ledger_mismatch_is_provider_feedback_but_a_real_cas_conflict_is_not() { - assert!( - plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 未逐项匹配当前 planning session 决策摘要" - )), - "台账不匹配应回灌给 Provider 修正,受既有 5 次预算约束" - ); - assert!( - !plan_submit_error_is_business_rejection(&PlanningStorageError::new( - "PLAN_SESSION_CAS_CONFLICT", - "planning session 已被其它动作推进" - )), - "真 CAS 必须走 reconciliation,不得消耗 Provider 重试额度" - ); - } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs index 9db5f7eac..3ff8e559a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_driver/task_start.rs @@ -519,7 +519,7 @@ fn start_game_creator_agent_background_task_with_link_in_session_lane_with_proje let error = redact_agent_runtime_project_paths(root, &error, 500); let failed_task = AgentRuntimeTaskRecord { status: "failed".to_string(), - phase: "planning-session-projection-failed".to_string(), + phase: AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED.to_string(), current_action: "Fast GDD session 未能安全绑定,后台任务未执行".to_string(), terminal_detail: Some(error.clone()), error: Some(error.clone()), diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs index e4bf34b09..5b2f387d8 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/finalization.rs @@ -268,7 +268,13 @@ pub(in crate::agent) fn validate_game_creator_agent_runtime_finalization_journal &journal.plan_steps, journal.active_plan_step_index, )?; + // 澄清信封是本 run 挂起等用户答,不是交付收束:剩余步骤要等用户答复后的 + // continuation run 才做,在这里既不可能 completed,也不该被 Runtime 代填成 + // completed——那是伪造进度。只有真正宣告做完的最终回复才受这条不变量约束。 + // 这里放行的是「计划未完成」这一件事;快照自身的结构合法性仍由上面的 + // `validate_agent_runtime_structured_plan_snapshot` 逐项校验。 if journal.plan_revision > 0 + && !response_is_static_delegate_user_input_envelope(&journal.response) && (journal.active_plan_step_index.is_some() || journal .plan_steps diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs index 27c300f3d..691ddb02d 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_approval.rs @@ -74,6 +74,48 @@ fn approval_observation(receipt: &PlanGddApprovalV1) -> AgentRuntimeToolObservat } } +/// 审批卡上的「修改/退回」是用户说的话,落点和决策卡的答案一样:Supervisor 自己 +/// 的会话文件。`append_user_input_answer_message` 已经为澄清答案建立了这条通道, +/// 审批决定沿用它。没有这一步 Supervisor 只能从 delivery 的 +/// `contractStatus=user-revision-requested` 知道「用户要改」,读不到要改什么—— +/// playbook 第 6 条的「把用户原话完整附在 task 里」就没有原话可附,返工委派只能 +/// 写一句占位,子 Agent 于是自由发挥。 +fn append_plan_gdd_revision_message( + root: &Path, + receipt: &PlanGddApprovalV1, + receipt_gdd: &PlanGddV1, +) -> Result<(), String> { + let label = if receipt.action == "reject" { + "退回" + } else { + "修改" + }; + let supervisor_session_id = resolve_game_creator_agent_runtime_session_id_for_run_at( + root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &receipt_gdd.root_run_id, + )?; + append_local_conversation_message_for_session_idempotent_at( + root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + // receipt 的 rootRun 所属 Supervisor task 才是这条用户意见的历史归属; + // 不能在 recovery 重放时按当前 active session 重新路由到别的会话。 + Some(&supervisor_session_id), + LocalConversationMessage { + role: "user".to_string(), + content: format!( + "我对 Fast GDD v{} 的审批:{}。意见原文:\n{}", + receipt.version, + label, + receipt.comment.as_deref().unwrap_or_default() + ), + agent_id: None, + }, + &format!("plan-gdd-decision-{}", receipt.response_id), + ) + .map(|_| ()) +} + fn receipt_decision_input( gdd: &PlanGddV1, input: &DecidePlanGddInputV1, @@ -302,6 +344,85 @@ fn latest_plan_gdd_for_root<'a>(gdds: &'a [PlanGddV1], root_run_id: &str) -> Opt }) } +/// Return whether the plan-root Supervisor must collect the current GDD +/// acceptance evidence before it can dispatch another planning child. +/// +/// This is deliberately a read-only projection of the existing acceptance +/// gate. It does not create approval pending or mutate any planning sidecar; +/// the actual pending projection remains owned by +/// `ensure_plan_gdd_approval_pending_after_acceptance_locked` after a successful +/// `agent.acceptance_update`. The caller must hold the current project write +/// lock and pass that guard explicitly. +pub(crate) fn plan_root_supervisor_acceptance_evidence_required_locked( + root: &Path, + project_lock: &ProjectWriteLock, + run_id: &str, +) -> Result { + if !project_lock.guards_project_root(root)? { + return Err("plan Supervisor 阶段判定缺少当前项目写锁".to_string()); + } + if !crate::config::game_creator_planning_capability_enabled()? { + return Ok(false); + } + if run_id.trim().is_empty() { + return Ok(false); + } + let gdds = read_plan_gdd_chain_locked(root).map_err(|error| error.to_string())?; + let Some(gdd) = latest_plan_gdd_for_root(&gdds, run_id) else { + return Ok(false); + }; + let Some(global_latest) = gdds.last() else { + return Ok(false); + }; + if global_latest.gdd_id != gdd.gdd_id + || global_latest.version != gdd.version + || global_latest.fingerprint != gdd.fingerprint + { + return Ok(false); + } + validate_plan_gdd(gdd).map_err(|error| error.to_string())?; + + let approvals = read_plan_gdd_approvals_locked(root).map_err(|error| error.to_string())?; + validate_plan_gdd_approvals_against_gdds(&gdds, &approvals) + .map_err(|error| error.to_string())?; + if read_plan_gdd_approval_for_version_locked(root, gdd.version) + .map_err(|error| error.to_string())? + .is_some() + { + return Ok(false); + } + if let Some(pending) = + read_plan_gdd_approval_pending_locked(root).map_err(|error| error.to_string())? + { + if !pending_matches_gdd(&pending, gdd) { + return Err("plan Supervisor 阶段判定发现 approval pending identity 冲突".to_string()); + } + return Ok(false); + } + + let Some(session) = + read_plan_session_with_recovery_locked(root).map_err(|error| error.to_string())? + else { + return Ok(false); + }; + if !plan_gdd_session_matches_submission(&session, gdd) { + return Ok(false); + } + let Some(delivery) = read_static_delegate_delivery_at(root, &gdd.delegation_id)? else { + return Ok(false); + }; + if delivery.status != StaticDelegateDeliveryStatus::ClaimedByParent + || delivery.terminal_status.as_deref() != Some("completed") + { + return Ok(false); + } + + Ok(matches!( + plan_fast_gdd_acceptance_status_at_locked(root, gdd)?, + PlanFastGddAcceptanceStatus::NeedsEvidence + )) +} + pub(crate) fn ensure_plan_gdd_approval_pending_after_acceptance_locked( root: &Path, agent_id: &str, @@ -1019,89 +1140,96 @@ fn project_receipt_locked( let pending_observation = approval_observation(receipt); let mut approval_pending_cleanup_eligible = false; - let approval_pending = match read_plan_gdd_approval_pending_locked(root) { - Ok(value) => value, - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-read", - &error.to_string(), - ); - recovery_pending = true; - None - } - }; - match approval_pending { - Some(mut pending) => { - if !pending_identity_matches_gdd(&pending, receipt_gdd) { + // Approval pending is a singleton projection for the latest GDD, not a + // per-receipt projection. A historical receipt must still repair its own + // index/Markdown/audit/runtime anchors, but it must not compare the + // current pending card with its older GDD identity. After a revise/reject + // creates a newer GDD, that comparison is expected to differ. + if receipt.version == latest.version { + let approval_pending = match read_plan_gdd_approval_pending_locked(root) { + Ok(value) => value, + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-identity", - "approval pending 与 receipt GDD identity 不一致", + "approval-pending-read", + &error.to_string(), ); recovery_pending = true; - } else { - let expected_status = format!("observed_{}", receipt.action); - if !matches!(pending.status.as_str(), "awaiting_decision") - && pending.status != expected_status - { + None + } + }; + match approval_pending { + Some(mut pending) => { + if !pending_identity_matches_gdd(&pending, receipt_gdd) { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-status", - &format!( - "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", - pending.status - ), + "approval-pending-identity", + "approval pending 与 receipt GDD identity 不一致", ); recovery_pending = true; - // Do not remove a projection whose durable state belongs - // to another decision action. - approval_pending_cleanup_eligible = false; } else { - approval_pending_cleanup_eligible = true; - pending.status = format!("observed_{}", receipt.action); - pending.observation = Some(PlanGddApprovalObservationV1 { - tool: pending_observation.tool.clone(), - status: pending_observation.status.clone(), - summary: pending_observation.summary.clone(), - detail: pending_observation.detail.clone(), - }); - match plan_gdd_approval_pending_fingerprint(&pending) { - Ok(fingerprint) => { - pending.pending_fingerprint = fingerprint; - if let Err(error) = - write_plan_gdd_approval_pending_atomic_locked(&root, &pending) - { + let expected_status = format!("observed_{}", receipt.action); + if !matches!(pending.status.as_str(), "awaiting_decision") + && pending.status != expected_status + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-status", + &format!( + "approval pending status={} 既不是 awaiting_decision 也不是 {expected_status}", + pending.status + ), + ); + recovery_pending = true; + // Do not remove a projection whose durable state belongs + // to another decision action. + approval_pending_cleanup_eligible = false; + } else { + approval_pending_cleanup_eligible = true; + pending.status = format!("observed_{}", receipt.action); + pending.observation = Some(PlanGddApprovalObservationV1 { + tool: pending_observation.tool.clone(), + status: pending_observation.status.clone(), + summary: pending_observation.summary.clone(), + detail: pending_observation.detail.clone(), + }); + match plan_gdd_approval_pending_fingerprint(&pending) { + Ok(fingerprint) => { + pending.pending_fingerprint = fingerprint; + if let Err(error) = + write_plan_gdd_approval_pending_atomic_locked(root, &pending) + { + note_plan_gdd_projection_gap( + root, + receipt, + "approval-pending-write", + &error.to_string(), + ); + recovery_pending = true; + } + } + Err(error) => { note_plan_gdd_projection_gap( root, receipt, - "approval-pending-write", + "approval-pending-fingerprint", &error.to_string(), ); recovery_pending = true; } } - Err(error) => { - note_plan_gdd_projection_gap( - root, - receipt, - "approval-pending-fingerprint", - &error.to_string(), - ); - recovery_pending = true; - } } } } + // The approval pending projection is allowed to be absent after the + // original submit anchors have durably consumed the terminal + // observation. The generic-anchor reconciliation below decides + // whether this is a normal post-consumption state or a recovery gap. + None => {} } - // The approval pending projection is allowed to be absent after the - // original submit anchors have durably consumed the terminal - // observation. The generic-anchor reconciliation below decides - // whether this is a normal post-consumption state or a recovery gap. - None => {} } let generic_submit_consumed = match project_generic_submit_observation_locked(root, receipt) { @@ -1149,15 +1277,32 @@ fn project_receipt_locked( note_plan_gdd_projection_gap(root, receipt, "delivery-revision-mark", &error); recovery_pending = true; } + if let Err(error) = append_plan_gdd_revision_message(root, receipt, receipt_gdd) { + note_plan_gdd_projection_gap(root, receipt, "delivery-revision-message", &error); + recovery_pending = true; + } } // A replay may target an older receipt after a newer GDD has already been // submitted. The receipt still repairs its own audit/observation, but it // must not try to roll the current session or delivery lineage backwards. - let session_points_to_receipt = match read_plan_session_with_recovery_locked(root) { - Ok(session) => session - .as_ref() - .and_then(|session| session.latest_submitted_ref.as_ref()) - .is_some_and(|reference| reference == &receipt_plan_ref(receipt)), + let session_projection_eligible = match read_plan_session_with_recovery_locked(root) { + Ok(session) => session.as_ref().is_some_and(|session| { + let receipt_ref_matches = session + .latest_submitted_ref + .as_ref() + .is_some_and(|reference| reference == &receipt_plan_ref(receipt)); + let decision_ref_matches = + session.last_decision_ref.as_ref().is_some_and(|reference| { + reference.version == receipt.version + && reference.response_id == receipt.response_id + && reference.action == receipt.action + && reference.receipt_fingerprint == receipt.receipt_fingerprint + }); + decision_ref_matches + || (receipt_ref_matches + && session.phase == "awaiting_gdd_approval" + && session.active_run_id.is_none()) + }), Err(error) => { note_plan_gdd_projection_gap(root, receipt, "plan-session-read", &error.to_string()); recovery_pending = true; @@ -1165,7 +1310,7 @@ fn project_receipt_locked( } }; let mut session_projection_ready = false; - if receipt.version == latest.version || session_points_to_receipt { + if session_projection_eligible { if let Err(error) = project_plan_session_locked(root, receipt_gdd, receipt) { note_plan_gdd_projection_gap(root, receipt, "plan-session-project", &error.to_string()); recovery_pending = true; diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs index 4e6bf1374..e47c24f67 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs @@ -5,6 +5,13 @@ use uuid::Uuid; const PLAN_OPTION_A_PREFIX: char = 'A'; const PLAN_OPTION_B_PREFIX: char = 'B'; const PLAN_OPTION_PROTOTYPE_VALIDATION: &str = "需要原型验证"; +/// 策划决策卡恒为 A / B /「需要原型验证」三项,不是通用 `user.input_request` 协议的 +/// 2-3 个。role brief 早先照通用常量写成「2~3 个」,与本文件的硬校验和 brief 自己 +/// 下文的「固定提供三个选项」三方打架;模型照前者吐两项,整封信封在出卡时被拒、 +/// 回灌重试,白烧一个未推进回合,丢掉的还恰好是用户产生 `prototype_pending` 的唯一 +/// 入口。`project_planning_role_brief_states_the_parser_wire_shape_verbatim` 钉住 +/// brief 与这里同源。 +pub(crate) const PLAN_CLARIFICATION_OPTION_COUNT: usize = 3; const PLAN_QUESTION_PREFIX: &str = "当前要决定:"; #[derive(Clone, Debug, Eq, PartialEq)] @@ -18,6 +25,25 @@ fn plan_coordinator_error(kind: &str, detail: impl AsRef) -> String { format!("{kind}: {}", detail.as_ref()) } +fn validate_plan_continuation_parent<'a>( + latest_delegation_id: &str, + delivery: &'a StaticDelegateDeliveryRecord, +) -> Result<&'a str, String> { + let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { + plan_coordinator_error( + "PLAN_ACTIVE_RUN_EXISTS", + "已有 planning session 时不能创建第二条根 delegation", + ) + })?; + if latest_delegation_id != original_id { + return Err(plan_coordinator_error( + "PLAN_NEEDS_RECONCILIATION", + "planning continuation 必须直接继承当前 session 的 latest delegation", + )); + } + Ok(original_id) +} + fn plan_session_successor_base(previous: &PlanSessionV1) -> Result { let mut next = previous.clone(); next.session_revision = previous.session_revision.checked_add(1).ok_or_else(|| { @@ -110,14 +136,40 @@ fn exact_plan_child_identity_at( Ok(Some((binding, delivery))) } -fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result { - let remainder = question - .question - .strip_prefix(PLAN_QUESTION_PREFIX) +/// 剥掉 header 的 `第{round}轮·` 前缀,返回其后的正文。 +/// +/// 轮号本身由 Runtime 从委派谱系派生,模型只是照着任务正文抄;这里逐字核对它,写错就 +/// 拒收——否则卡片标题会和 `GddApprovalCard` 那个「第 N 轮 / 共 3 轮」自相矛盾。 +fn plan_header_body(header: &str, round: u32) -> Option<&str> { + let rest = header.trim_start().strip_prefix('第')?.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.parse::().ok()? != round { + return None; + } + let rest = rest[digits.len()..].trim_start().strip_prefix('轮')?.trim(); + // 原型模板写作 `第 N 轮 · 当前要决定:…`,中文语境下模型高频吐出 `·`/`:`/`:`/`-` + // 几种分隔符;不在集合里的后果是整封信封被拒、白吃一个未推进回合。 + let rest = rest.strip_prefix(&PLAN_OPTION_LABEL_DELIMITERS[..])?; + Some(rest.trim_start()) +} + +/// 决定台账的 `topic` 取自 header。 +/// +/// 原型(`design_agent.py:1841`)直接把整条 header 当 topic;这里只是再剥掉 `第N轮·` 和 +/// 「当前要决定:」两层固定前缀,落进台账的是主题本身。 +fn plan_question_topic( + question: &AgentRuntimeUserInputQuestion, + round: u32, +) -> Result { + let remainder = plan_header_body(&question.header, round) + .and_then(|body| body.strip_prefix(PLAN_QUESTION_PREFIX)) .ok_or_else(|| { plan_coordinator_error( "PLAN_INVALID_CLARIFICATION", - "plan question 必须以“当前要决定:”开头", + format!("plan header 必须形如“第{round}轮·当前要决定:<主题>”"), ) })?; let topic = remainder @@ -129,7 +181,12 @@ fn plan_question_topic(question: &AgentRuntimeUserInputQuestion) -> Result bool { let Some(remainder) = label.strip_prefix(prefix).map(str::trim_start) else { @@ -154,6 +211,83 @@ fn plan_option_label_has_prefix(label: &str, prefix: char) -> bool { /// `user_freeform`。两边 state 同为 `confirmed`,状态机看不出异常——被污染的恰好是第 /// 23.9 节要立起来的那个字段。`planning_clarification_option_pick_survives_untrimmed_label` /// 钉的就是这条不变量。 +#[cfg(test)] +mod option_label_delimiter_tests { + use super::*; + + /// 分隔符集合只能放宽、不能收窄,且必须覆盖原型 `_OPTION_A_PATTERN` 的那一份。 + /// + /// 锁的是「集合里每一个都被接受」这条不变量,不是某个具体标点:少一个的后果不是 + /// 「模型换个写法」,而是一封完全合法的信封被判形状错误、回灌重试,白吃一个未推进 + /// 回合——`planning_clarification_accepts_fullwidth_colon_option_labels` 记的就是 + /// 全角冒号那一次。 + /// 逐字来自原型 `design_agent.py` 的 `^A\s*[·•・::..\-]`。这里**不能**改成遍历 + /// `PLAN_OPTION_LABEL_DELIMITERS` 本身——那样从集合里删掉一个,循环也跟着少测一个, + /// 断言恒真。 + const PROTOTYPE_DELIMITERS: [char; 8] = ['·', '•', '・', ':', ':', '.', '.', '-']; + + #[test] + fn every_delimiter_in_the_set_is_accepted_on_both_option_prefixes() { + for delimiter in PROTOTYPE_DELIMITERS { + for prefix in [PLAN_OPTION_A_PREFIX, PLAN_OPTION_B_PREFIX] { + let label = format!("{prefix}{delimiter}方案短语"); + assert!( + plan_option_label_has_prefix(&label, prefix), + "分隔符 {delimiter:?} 被拒:{label}" + ); + let spaced = format!("{prefix} {delimiter} 方案短语"); + assert!( + plan_option_label_has_prefix(&spaced, prefix), + "带空格写法被拒:{spaced}" + ); + } + } + assert!( + !plan_option_label_has_prefix("A方案短语", PLAN_OPTION_A_PREFIX), + "没有分隔符不能算合法 A 选项,否则 A/B 与自由文本会混" + ); + } +} + +#[cfg(test)] +mod planning_continuation_parent_tests { + use super::*; + + fn delivery(repair_of_delegation_id: Option<&str>) -> StaticDelegateDeliveryRecord { + new_static_delegate_delivery_with_contract( + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + "supervisor-session", + "supervisor-run", + "delegate-action", + "current-delivery", + GAME_CREATOR_PROJECT_PLANNING_AGENT_ID, + "planning-session", + "planning-run", + &[], + &[], + repair_of_delegation_id, + ) + } + + #[test] + fn continuation_must_extend_the_session_cursor() { + let continuation = delivery(Some("older-delivery")); + let error = validate_plan_continuation_parent("current-delivery", &continuation) + .expect_err("older delivery must not become the current planning branch"); + assert!(error.contains("latest delegation")); + } + + #[test] + fn continuation_accepts_the_current_session_cursor() { + let continuation = delivery(Some("current-delivery")); + assert_eq!( + validate_plan_continuation_parent("current-delivery", &continuation) + .expect("current delivery is a valid continuation"), + "current-delivery" + ); + } +} + fn plan_option_label_matches_answer(label: &str, normalized_answer: &str) -> bool { label == normalized_answer } @@ -178,14 +312,7 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan questionId 必须是最多 32 个 ASCII 字符且不能映射为 initial-request", )); } - let expected_header = format!("第{round}轮·关键决定"); - if question.header != expected_header { - return Err(plan_coordinator_error( - "PLAN_INVALID_CLARIFICATION", - format!("plan question header 必须精确等于 {expected_header}"), - )); - } - let valid_shape = question.options.len() == 3 + let valid_shape = question.options.len() == PLAN_CLARIFICATION_OPTION_COUNT && plan_option_label_has_prefix(&question.options[0].label, PLAN_OPTION_A_PREFIX) && plan_option_label_has_prefix(&question.options[1].label, PLAN_OPTION_B_PREFIX) && question.options[2].label == PLAN_OPTION_PROTOTYPE_VALIDATION; @@ -195,7 +322,9 @@ pub(crate) fn validate_exact_plan_clarification_question( "plan question 必须恰好提供 A、B、需要原型验证三个选项", )); } - plan_question_topic(question)?; + // header 的定形连同轮号一起在这里兜底:`plan_question_topic` 要求它形如 + // `第{round}轮·当前要决定:<主题>`,并把主题本身取出来给决定台账。 + plan_question_topic(question, round)?; Ok(()) } @@ -210,7 +339,7 @@ fn build_plan_clarification_decision_projection( let normalized_answer = normalize_plan_text(&answer.answer, "plan answer", 1, 400) .map_err(|error| error.to_string())?; let question = &answer.question; - let topic = plan_question_topic(question)?; + let topic = plan_question_topic(question, round)?; let decision_id = question.id.replace('_', "-"); let (state, answer_source) = if plan_option_label_matches_answer(&question.options[0].label, &normalized_answer) @@ -513,12 +642,13 @@ pub(crate) fn ensure_plan_session_for_planning_child_task_at_locked( { return Ok(true); } - let original_id = delivery.repair_of_delegation_id.as_deref().ok_or_else(|| { - plan_coordinator_error( - "PLAN_ACTIVE_RUN_EXISTS", - "已有 planning session 时不能创建第二条根 delegation", - ) - })?; + // `latest_delegation_id` is the planning session's single continuation + // cursor. A new child must extend that cursor directly; otherwise a + // Supervisor can select an older claimed delivery and make an unrelated + // branch look like the current session. Keep this check here, after the + // exact-task replay fast path above, so replaying an already projected + // child remains idempotent. + let original_id = validate_plan_continuation_parent(&previous.latest_delegation_id, &delivery)?; let deliveries = list_static_delegate_deliveries_at(root)?; if static_delegate_lineage_contains_unknown_contract_status( &deliveries, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs index a7f483afe..fba95f5d6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_storage.rs @@ -440,7 +440,10 @@ fn validate_decision_state(value: &str) -> Result<(), PlanningStorageError> { } fn validate_answer_source(value: &str) -> Result<(), PlanningStorageError> { - if matches!(value, "user_freeform" | "user_option" | "default") { + if matches!( + value, + "user_freeform" | "user_option" | "user_revision" | "default" + ) { Ok(()) } else { Err(invalid(format!("未知 answerSource:{value}"))) @@ -882,31 +885,38 @@ fn validate_decisions( if decision.round > 3 { return Err(invalid(format!("decisions[{index}].round 不能超过 3"))); } - // round=0 表示这条决定从未向用户提问过,因此它不能声称任何用户权威: - // answerSource 必须是 default。但它可以落在两种状态上——由 Agent 按默认 - // 建议填写(default_pending),或者 Agent 判定这项会实质影响首个可玩闭环、 - // 不该由它替用户拍板,需要一个 30~90 分钟微型原型来验证 - // (prototype_pending,并配同 id 的 prototypeValidationItems 项)。 - // - // 早期实现把 round=0 钉死成 default_pending。于是用户一次把需求说全、 - // 走 0 轮直出时,全部决定都是 round=0,没有任何决定可能成为 - // prototype_pending;而下面的双射又要求验证项逐项对应 prototype_pending - // 决定,结果是首次 plan.submit_gdd 必被预检拒收,且这份稿子永远不可能 - // 带上原型验证项。把一项未经验证的风险标成「默认,待确认」是在说谎: - // 那不是一个默认值,那是一个没人验证过的假设。 + if decision.answer_source == "user_revision" && decision.round != 0 { + return Err(invalid(format!( + "decisions[{index}] 的 user_revision 必须使用 round=0" + ))); + } + // round=0 不属于澄清轮:默认建议使用 default,审批修改使用 + // user_revision。两者都可以标记为 prototype_pending;用户明确修改的 + // 决定则可以标记 confirmed。 if decision.round == 0 && decision.id != "initial-request" { - if decision.answer_source != "default" { - return Err(invalid(format!( - "decisions[{index}] round=0 未经提问,answerSource 只能是 default" - ))); - } - if !matches!( - decision.state.as_str(), - "default_pending" | "prototype_pending" - ) { - return Err(invalid(format!( - "decisions[{index}] round=0 只能是 default_pending 或 prototype_pending" - ))); + match decision.answer_source.as_str() { + "default" + if matches!( + decision.state.as_str(), + "default_pending" | "prototype_pending" + ) => {} + "user_revision" + if matches!(decision.state.as_str(), "confirmed" | "prototype_pending") => {} + "default" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 default 只能是 default_pending 或 prototype_pending" + ))); + } + "user_revision" => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 user_revision 只能是 confirmed 或 prototype_pending" + ))); + } + _ => { + return Err(invalid(format!( + "decisions[{index}] round=0 的 answerSource 只能是 default 或 user_revision" + ))); + } } } validate_text( diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs index e8dc24c0d..dfb10dbc6 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_submit.rs @@ -1100,108 +1100,6 @@ fn gdd_submit_identity_matches(gdd: &PlanGddV1, context: &PlanSubmitGddRuntimeCo .is_none_or(|approval_request_id| gdd.approval_request_id == approval_request_id) } -/// 决定台账的权威归属:Runtime 拥有**事实**(用户在第几轮、对着哪道题、原样说了 -/// 什么),策划子 Agent 拥有**判断**(这句话是不是构成对该题的取舍、该记成什么 -/// topic)。 -/// -/// 早期实现要求 submit input 的前缀与 `session.decisionsSummary` 六个字段逐项相等。 -/// 那六个字段没有一个是子 Agent 生产的,它只能从 Supervisor 转述的委派 task 里回抄; -/// 而权威台账从不下发给它,拒绝理由也不含差异。于是「回抄」这件零信息量的动作成了 -/// 唯一的提交前提,用户只要自由填写过一次,逐字复现就依赖一条没有机制保证的 LLM -/// 转述链,抄歪即在 5 次盲重试后硬失败。同一条相等约束还顺带禁掉了子 Agent 纠正 -/// 错误绑定的能力——答非所问被 Runtime 投影成 confirmed 之后,改一个字都过不了。 -/// -/// 现在只守真正要守的那一条:**不能声称用户确认过他没确认的东西**。 -fn submit_decisions_respect_session_authority( - session: &PlanSessionV1, - input: &PlanSubmitGddInputV1, -) -> bool { - // 1. 不得凭空造出用户拍板:任何 confirmed 且非默认来源的决定,都必须命中一条 - // 同 id 的 confirmed session 决定。 - let no_forged_confirmation = input.decisions.iter().all(|decision| { - if decision.state != "confirmed" || decision.answer_source == "default" { - return true; - } - session - .decisions_summary - .iter() - .any(|recorded| recorded.id == decision.id && recorded.state == "confirmed") - }); - // 2. 不得丢弃用户已作出的决定,也不得篡改用户亲自选择的「需要原型验证」。 - // confirmed 允许降级为 default_pending(子 Agent 判定该轮回答并未回答所问 - // 时的唯一出口),但不能凭空消失。 - let no_dropped_authority = session.decisions_summary.iter().all(|recorded| { - let Some(decision) = input - .decisions - .iter() - .find(|decision| decision.id == recorded.id) - else { - return false; - }; - match recorded.state.as_str() { - "prototype_pending" => decision.state == "prototype_pending", - "confirmed" => matches!(decision.state.as_str(), "confirmed" | "default_pending"), - _ => true, - } - }); - // 3. Runtime 生成的原型验证项必须都在,内容由 `apply_plan_session_authority_to_ - // submit_input` 覆盖,不比较;子 Agent 可以另加自己的项,由 `validate_decisions` - // 的「逐项对应全部 prototype_pending 决定」双射约束兜底。 - let no_dropped_prototype_items = session.prototype_validation_items.iter().all(|recorded| { - input - .prototype_validation_items - .iter() - .any(|item| item.id == recorded.id) - }); - no_forged_confirmation && no_dropped_authority && no_dropped_prototype_items -} - -/// 把 Runtime 拥有的字段直接覆盖进 submit input,而不是要求子 Agent 回抄。 -/// -/// 覆盖对象只有「仍然挂着用户权威」的条目:保持 confirmed 的、以及用户亲选的 -/// prototype_pending。子 Agent 判定为未答而降级成 default_pending 的条目,其 -/// answerSummary 描述的是它自己填的默认值,归它所有,不覆盖。`topic` 任何情况下 -/// 都不覆盖——按答案真实内容重新命名决定,正是子 Agent 纠正错误绑定的手段。 -/// -/// 覆盖必须发生在 durable action identity 重放比对之前,且只依赖 session 里 -/// 跨 submit 不变的 `decisionsSummary` / `prototypeValidationItems` -/// (`build_submit_session_successor` 原样克隆这两项),这样同一个 actionId 重放 -/// 时归一化结果稳定,重放比对不会因为覆盖而错判成 payload 不一致。 -fn apply_plan_session_authority_to_submit_input( - session: &PlanSessionV1, - input: &mut PlanSubmitGddInputV1, -) { - for decision in &mut input.decisions { - let Some(recorded) = session - .decisions_summary - .iter() - .find(|recorded| recorded.id == decision.id) - else { - continue; - }; - let carries_user_authority = match recorded.state.as_str() { - "confirmed" => decision.state == "confirmed", - "prototype_pending" => decision.state == "prototype_pending", - _ => false, - }; - if !carries_user_authority { - continue; - } - decision.answer_source = recorded.answer_source.clone(); - decision.round = recorded.round; - decision.answer_summary = recorded.answer_summary.clone(); - } - for item in &mut input.prototype_validation_items { - if let Some(recorded) = session - .prototype_validation_items - .iter() - .find(|recorded| recorded.id == item.id) - { - *item = recorded.clone(); - } - } -} - fn session_identity_matches_context( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, @@ -1247,7 +1145,6 @@ fn build_submit_session_successor( fn validate_current_session_cas( session: &PlanSessionV1, context: &PlanSubmitGddRuntimeContext, - input: &PlanSubmitGddInputV1, ) -> Result<(), PlanningStorageError> { if !session_identity_matches_context(session, context) { return Err(submit_error( @@ -1283,20 +1180,6 @@ fn validate_current_session_cas( "当前 planning session 仍有未决 GDD", )); } - // 这一支和上面三条 CAS 判据性质不同,因此不共用 `PLAN_SESSION_CAS_CONFLICT`。 - // 真 CAS(revision 溢出、session 已被其它动作推进、Runtime source - // revision/fingerprint 无效)说明 durable 权威变了或坏了,重交同一份 input 也 - // 没用,只能 reconcile;而台账逐项比对失败时权威完好,错的是本次 Provider - // input——策划子 Agent 把 session 决策摘要抄漏、抄错或多追加了一条非默认决定。 - // 这正是第 12 节划归「本次 Provider input」的那一类,应该走 rejected - // observation 回灌让它改,受既有 5 次预算约束,而不是硬阻断等人。 - // 不变量本身一个字没放松:不匹配照样拒,只是改了拒绝的后果。 - if !submit_decisions_respect_session_authority(session, input) { - return Err(submit_error( - "PLAN_SESSION_DECISIONS_MISMATCH", - "submit input 的决定台账越过了 planning session 的用户权威", - )); - } if session.latest_delegation_id != context.delegation_id { return Err(submit_error( "PLAN_SOURCE_PROFILE_MISMATCH", @@ -1306,6 +1189,34 @@ fn validate_current_session_cas( Ok(()) } +/// `user_revision` 只证明审批意见,不证明澄清。首次 collecting、澄清续跑和没有待处理 +/// 用户修订的普通质量返工 session 都没有 revise/reject `lastDecisionRef`;用户修订周期 +/// 内的 continuation(包括其质量返工)会把该引用带到新的 collecting successor 上。 +/// continuation 的直接父边由 planning coordinator 校验,replay 不走这里。 +fn validate_user_revision_requires_approval_decision( + session: &PlanSessionV1, + input: &PlanSubmitGddInputV1, +) -> Result<(), PlanningStorageError> { + if !input + .decisions + .iter() + .any(|decision| decision.answer_source == "user_revision") + { + return Ok(()); + } + if session + .last_decision_ref + .as_ref() + .is_some_and(|reference| matches!(reference.action.as_str(), "revise" | "reject")) + { + return Ok(()); + } + Err(submit_error( + "PLAN_INVALID_REQUEST", + "user_revision 只能用于当前 session 已有 revise/reject 审批决定的续跑提交", + )) +} + fn validate_durable_child_binding( root: &std::path::Path, context: &PlanSubmitGddRuntimeContext, @@ -1717,11 +1628,20 @@ fn project_submit_successors_locked( // successor. The only safe forward path is an exact source-session // snapshot (including the still-active child run) or an already // projected session pointing at this immutable ref. + // These judgements are deliberately the same set `validate_current_session_cas` + // already enforced at the submit gate. Do not narrow them with a + // `latest_submitted_ref.is_none()` style assertion: revision plus the + // recomputed `sessionFingerprint` (`validate_plan_session` rejects a session + // whose fingerprint does not hash its own content) already pin the session to + // the exact snapshot the submitter observed, so any extra field-shape check + // only re-encodes the obsolete "one submission per lineage" rule. A user + // revision round legitimately arrives carrying the previous version's + // `latestSubmittedRef`; refusing it strands a committed GDD behind a + // `recoveryPending` that no replay can clear. let source_session_matches_gdd = session_identity_matches_gdd && previous_session.session_revision == gdd.source_session_revision && previous_session.session_fingerprint == gdd.source_session_fingerprint && previous_session.active_run_id.as_deref() == Some(gdd.created_by_run_id.as_str()) - && previous_session.latest_submitted_ref.is_none() && matches!( previous_session.phase.as_str(), "collecting" | "revision_requested" @@ -1789,23 +1709,6 @@ pub(crate) fn execute_plan_submit_gdd( let session_read = read_plan_session_with_recovery_locked(root); let current_session = session_read.as_ref().ok().and_then(Option::as_ref); - // Runtime 拥有的决定字段在这里一次性覆盖进 input,之后的重放比对、CAS 与 GDD - // 构建全部使用归一化后的值。放在重放分支之前是必需的:`submit_payload_matches_gdd` - // 拿 input 和已落库 GDD 反推出的 input 比对,只有两侧都归一化过才等价。归一化 - // 只读 `decisionsSummary` / `prototypeValidationItems`,二者跨 submit successor - // 原样保留,所以同一 actionId 重放的结果稳定。session 读不出来时保持原样,把 - // session 错误留给下面既有的分支处置。 - let normalized_input; - let input = match current_session { - Some(session) => { - let mut owned = input.clone(); - apply_plan_session_authority_to_submit_input(session, &mut owned); - normalized_input = owned; - &normalized_input - } - None => input, - }; - // First resolve the durable action identity. This branch intentionally // runs before pending/version checks: replay must be idempotent even when a // previous attempt already advanced the session or projections. @@ -1889,7 +1792,8 @@ pub(crate) fn execute_plan_submit_gdd( )); }; validate_plan_session(current_session)?; - validate_current_session_cas(current_session, context, input)?; + validate_current_session_cas(current_session, context)?; + validate_user_revision_requires_approval_decision(current_session, input)?; let version = chain .last() .map(|latest| latest.version.saturating_add(1)) @@ -2157,9 +2061,8 @@ mod tests { submit_fixture_from(valid_input()) } - /// 与 `submit_fixture` 同构,但由调用方提供 input:durable session 的 - /// `decisionsSummary` / `prototypeValidationItems` 直接镜像它,于是可以构造出 - /// 「用户已在第 N 轮拍板」「用户亲选了需要原型验证」这类前置台账。 + /// 与 `submit_fixture` 同构,但由调用方提供 input,并用它初始化 session 的 + /// 当前决定快照,便于构造澄清后或审批修订后的提交场景。 fn submit_fixture_from( input: PlanSubmitGddInputV1, ) -> (PathBuf, PlanSubmitGddRuntimeContext, PlanSubmitGddInputV1) { @@ -3429,38 +3332,34 @@ mod tests { } #[test] - fn submit_rejects_an_extra_non_default_decision_not_present_in_session() { + fn submit_rejects_user_revision_without_revise_or_reject_decision() { let (root, context, mut input) = submit_fixture(); input.decisions.push(PlanSubmitDecision { id: "invented-confirmation".to_string(), - topic: "未提问决定".to_string(), + topic: "审批新增决定".to_string(), state: "confirmed".to_string(), - answer_source: "user_option".to_string(), - round: 1, - answer_summary: "伪造为用户已确认".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户在审批意见中明确提出".to_string(), }); let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a non-default decision outside the session prefix must fail"); - // 伪造用户确认照样被拒;只是错误码从 CAS 换成了可回灌的输入类, - // 让策划子 Agent 能按理由改稿而不是把整个 Agent 阻断到人工核对。 - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + .expect_err("first collecting submit cannot forge user_revision"); + assert_eq!(error.code(), "PLAN_INVALID_REQUEST"); + assert!(error.to_string().contains("revise/reject")); assert!(!root.join(".agent/planning/gdd.v1.json").exists()); cleanup_fixture(root); } - /// 用户答案原文归 Runtime 所有:子 Agent 抄歪了直接被覆盖回去,而不是把整条 - /// 提交拒掉。真 CAS(durable 权威已变)仍然是另一回事,必须区分开。 + /// 新版本的决定快照由本次提交负责,旧 session 不再覆盖其内容。 #[test] - fn a_rewritten_answer_summary_is_overwritten_while_a_stale_session_is_still_a_cas_conflict() { - // 抄错既有决定的正文(权威没变,错的是 input):落库的是权威原文。 + fn a_rewritten_answer_summary_is_preserved_while_a_stale_session_is_still_a_cas_conflict() { let (root, context, mut input) = submit_fixture(); - let authoritative = input.decisions[0].answer_summary.clone(); input.decisions[0].answer_summary.push_str("(被改写)"); execute_plan_submit_gdd(&root, &context, &input) - .expect("a rewritten answer summary is overwritten, not rejected"); + .expect("the current submit snapshot owns its decision text"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - assert_eq!(chain[0].decisions[0].answer_summary, authoritative); + assert!(chain[0].decisions[0].answer_summary.ends_with("(被改写)")); cleanup_fixture(root); // 同一份合法 input,只把 session revision 弄陈旧(权威已被推进)。 @@ -3518,7 +3417,7 @@ mod tests { chain[0].decisions[1].topic, "重玩动力(用户实际回答的是这个)" ); - // 但答案原文仍然是 Runtime 的权威值。 + // 当前提交快照保留 Provider 生成的答案正文。 assert_eq!( chain[0].decisions[1].answer_summary, "不要那两个,我要玩家只能移动光源给守卫开路" @@ -3526,17 +3425,16 @@ mod tests { cleanup_fixture(root); } - /// 降级(confirmed → default_pending)是允许的安全方向;整条丢掉不行——那会让 - /// 用户已经作出的决定从 GDD 里凭空消失。 + /// 修订可以删除、重写或重新定义旧决定;Runtime 不把旧 session 快照当内容门禁。 #[test] - fn a_confirmed_decision_may_be_downgraded_but_never_dropped() { + fn a_revision_may_downgrade_or_drop_an_obsolete_decision() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[1].state = "default_pending".to_string(); input.decisions[1].answer_source = "default".to_string(); input.decisions[1].answer_summary = "按默认建议填写,等待用户确认".to_string(); - execute_plan_submit_gdd(&root, &context, &input).expect("downgrade is the safe direction"); + execute_plan_submit_gdd(&root, &context, &input).expect("revision may change a decision"); let chain = read_plan_gdd_chain(&root).expect("read submitted chain"); - // 降级之后这条不再声称用户拍过板,正文归子 Agent 所有,不被覆盖。 + // 新快照按 Provider 提交内容保存。 assert_eq!(chain[0].decisions[1].state, "default_pending"); assert_eq!( chain[0].decisions[1].answer_summary, @@ -3546,21 +3444,19 @@ mod tests { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions.remove(1); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("dropping a user decision must fail"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete decision"); cleanup_fixture(root); } - /// 用户亲手选的「需要原型验证」不是子 Agent 可以改判的东西。 + /// 修订可以重新定义原型验证范围,但结构约束仍然有效。 #[test] fn a_user_picked_prototype_validation_cannot_be_rewritten_by_the_planning_child() { let (root, context, mut input) = submit_fixture_from(clarified_input()); input.decisions[2].state = "confirmed".to_string(); input.prototype_validation_items.clear(); - let error = execute_plan_submit_gdd(&root, &context, &input) - .expect_err("a user-picked prototype validation must survive"); - assert_eq!(error.code(), "PLAN_SESSION_DECISIONS_MISMATCH"); + execute_plan_submit_gdd(&root, &context, &input) + .expect("revision may remove an obsolete prototype item"); cleanup_fixture(root); } @@ -3630,32 +3526,32 @@ mod tests { cleanup_fixture(root); } - /// round=0 放开的只是状态,不是权威:从未提问过的决定仍然不许声称用户拍过板, - /// 也不许挂上任何 user_* 来源。 + /// round=0 区分默认建议与审批修订来源;澄清来源仍不能伪装成 round=0。 #[test] - fn a_round_zero_decision_still_cannot_claim_any_user_authority() { + fn round_zero_accepts_user_revision_but_rejects_clarification_sources() { let mut confirmed = valid_input(); confirmed.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却声称已确认".to_string(), + topic: "审批修改的决定".to_string(), state: "confirmed".to_string(), - answer_source: "default".to_string(), + answer_source: "user_revision".to_string(), round: 0, - answer_summary: "伪造".to_string(), + answer_summary: "用户在审批意见中明确修改".to_string(), }); - validate_plan_submit_gdd_input(&confirmed).expect_err("round=0 may not be confirmed"); + validate_plan_submit_gdd_input(&confirmed) + .expect("user_revision may be confirmed at round=0"); let mut sourced = valid_input(); sourced.decisions.push(PlanSubmitDecision { id: "invented".to_string(), - topic: "没问过却挂上用户来源".to_string(), + topic: "澄清来源不能伪装为 round=0".to_string(), state: "prototype_pending".to_string(), answer_source: "user_option".to_string(), round: 0, answer_summary: "伪造".to_string(), }); validate_plan_submit_gdd_input(&sourced) - .expect_err("round=0 may not carry a user answer source"); + .expect_err("round=0 may not carry a clarification answer source"); } #[test] @@ -4183,6 +4079,51 @@ mod tests { cleanup_fixture(root); } + /// 审批卡上的「修改/退回」意见必须原文落进 Supervisor 会话——那是 playbook + /// 第 6 条「把用户原话完整附在 task 里」唯一的原话来源。delivery 的 + /// `contractStatus=user-revision-requested` 只说明「用户要改」,不带内容, + /// Supervisor 拿不到原文就只能在返工委派里写一句占位。 + /// + /// 追加必须幂等:`reconcile_plan_gdd_approval_projections_locked` 每次 hydrate + /// 都会为全部回执重跑 `project_receipt_locked`。 + #[test] + fn a_revision_comment_reaches_the_supervisor_conversation_once() { + let (root, gdd, _root_runtime) = acceptance_gate_fixture(true); + create_plan_gdd_approval_pending_at(&root, &gdd).expect("create approval pending"); + decide_plan_gdd_at( + &root, + &approval_input( + &gdd, + "revise", + "gdd-response-00000000-0000-4000-8000-000000000050", + Some("把游戏名称改成日本语".to_string()), + ), + ) + .expect("commit revise receipt"); + let supervisor_messages = || { + read_local_conversation_for_session_at( + &root, + Some(GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID), + None, + ) + .expect("read supervisor conversation") + .messages + }; + let after_decision = supervisor_messages(); + assert!( + after_decision.iter().any(|message| message.role == "user" + && message.content.contains("把游戏名称改成日本语")), + "用户修改意见必须原文进入 Supervisor 会话" + ); + reconcile_plan_gdd_approval_projections_locked(&root).expect("replay receipt projections"); + assert_eq!( + supervisor_messages().len(), + after_decision.len(), + "投影重放不得重复追加同一条审批意见" + ); + cleanup_fixture(root); + } + /// reject 之后能不能在同一 lineage 重做,**不由提交门的 phase 判据决定**。 /// /// 提交门要求 session 的 activeRunId 等于当前策划子 run,而 schema 不变量禁止 @@ -4238,8 +4179,41 @@ mod tests { let mut next_context = context.clone(); next_context.source_session_revision = continuation.session_revision; next_context.source_session_fingerprint = continuation.session_fingerprint.clone(); - validate_current_session_cas(&continuation, &next_context, &input) + validate_current_session_cas(&continuation, &next_context) .expect("reject 之后的 continuation 必须能提交同一 lineage 的下一版本"); + + // 提交闸放行还不够:投影守卫必须认同一条 continuation。这条 session 必然带着 + // v1 的 latestSubmittedRef 和 reject 的 lastDecisionRef,投影守卫若据此判它不是 + // 合法起点,v2 就会越过提交点却收不了口,留下一个任何重放都清不掉的 + // recoveryPending。 + write_plan_session_atomic_locked(&root, &continuation).expect("write continuation session"); + next_context.action_id = "action-89abcdef0123456789abcdef".to_string(); + next_context.action_fingerprint = "4".repeat(64); + next_context.approval_request_id = + Some("gdd-approval-00000000-0000-4000-8000-000000000041".to_string()); + let mut revised_input = input.clone(); + revised_input.game.title = "审批修订后的标题".to_string(); + revised_input.decisions.push(PlanSubmitDecision { + id: "approval-scope".to_string(), + topic: "审批修改范围".to_string(), + state: "confirmed".to_string(), + answer_source: "user_revision".to_string(), + round: 0, + answer_summary: "用户要求采用新的首版范围".to_string(), + }); + let resubmit = execute_plan_submit_gdd(&root, &next_context, &revised_input) + .expect("continuation 提交 v2"); + assert_eq!(resubmit.gdd_ref.version, 2); + let chain = read_plan_gdd_chain(&root).expect("read revised GDD chain"); + assert_eq!(chain[1].game.title, "审批修订后的标题"); + assert_eq!( + chain[1].decisions.last().unwrap().answer_source, + "user_revision" + ); + assert!( + !resubmit.recovery_pending, + "提交闸放行的 continuation,投影守卫也必须放行" + ); cleanup_fixture(root); } @@ -4650,6 +4624,45 @@ mod tests { cleanup_fixture(root); } + #[test] + fn m1c2a_unapproved_gdd_requires_acceptance_evidence_before_delegate() { + let (root, _gdd, root_runtime) = acceptance_gate_fixture(true); + + assert_eq!( + plan_root_supervisor_stage_at( + &root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + let project_lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait( + &root, + "test.plan-root-stage-locked", + ) + .expect("acquire plan root stage lock"); + assert_eq!( + plan_root_supervisor_stage_at_locked( + &root, + &project_lock, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &root_runtime.run_id, + ) + .expect("classify locked plan root stage"), + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ); + drop(project_lock); + assert_eq!( + agent_runtime_plan_root_supervisor_tools_for_stage( + PlanRootSupervisorStage::AwaitingAcceptanceEvidence + ), + &["file.read", "agent.acceptance_update", "agent.run_status"] + ); + + cleanup_fixture(root); + } + #[test] fn m1c2a_failed_acceptance_requires_claim_before_repair_dispatch() { let (root, gdd, root_runtime) = acceptance_gate_fixture(false); diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs index 1e061353a..7d7ebc0f0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_control.rs @@ -460,6 +460,148 @@ pub(in crate::agent) fn mark_game_creator_agent_runtime_provider_success_handoff ) } +const PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT: &str = + "diagnostics/provider-reconciliation"; +const PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES: usize = 1024 * 1024; + +/// Persist the raw successful Provider response only in the application +/// private data directory. Project state keeps the safe summary below; this +/// sidecar is diagnostic-only and is never consulted by recovery/retry logic. +pub(in crate::agent) fn write_provider_reconciliation_diagnostic_at( + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let config_dir = game_creator_runtime_config_dir() + .ok_or_else(|| "Runtime config dir 未初始化,无法写入本地 Provider 诊断".to_string())?; + write_provider_reconciliation_diagnostic_in_dir( + &config_dir, + snapshot, + request_id, + response, + error, + ) +} + +fn write_provider_reconciliation_diagnostic_in_dir( + config_dir: &Path, + snapshot: &AgentRuntimeProviderRequestSnapshot, + request_id: &str, + response: &platform_llm::LlmRunResponse, + error: &str, +) -> Result { + let project_key = format!("{:x}", Sha256::digest(snapshot.project_id.as_bytes())); + let request_key = format!("{:x}", Sha256::digest(request_id.as_bytes())); + let directory = config_dir + .join(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + .join(&project_key); + fs::create_dir_all(&directory) + .map_err(|error| format!("创建本地 Provider 诊断目录失败:{error}"))?; + let relative_path = format!( + "{PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT}/{project_key}/{request_key}.json" + ); + let path = directory.join(format!("{request_key}.json")); + if let Ok(metadata) = fs::symlink_metadata(&path) { + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err("本地 Provider 诊断目标必须是普通文件".to_string()); + } + return Ok(relative_path); + } + let diagnostic = serde_json::json!({ + "schemaVersion": "provider-reconciliation-diagnostic.v1", + "identity": { + "projectId": snapshot.project_id.clone(), + "agentId": snapshot.agent_id.clone(), + "taskId": snapshot.task_id.clone(), + "sessionId": snapshot.session_id.clone(), + "runId": snapshot.run_id.clone(), + "source": snapshot.source.clone(), + "requestKind": snapshot.request_kind.clone(), + "requestSlot": snapshot.request_slot.clone(), + "requestId": request_id, + "appliedSteerCursor": snapshot.applied_steer_cursor, + }, + "provider": { + "provider": format!("{:?}", response.provider), + "model": response.model.clone(), + "responseId": response.response_id.clone(), + "finishReason": response.finish_reason.clone(), + "usage": response.usage.clone(), + }, + "failure": { + "error": error, + }, + "response": { + "text": response.text.clone(), + "toolCalls": response.tool_calls.iter().map(|call| serde_json::json!({ + "id": call.id.clone(), + "name": call.name.clone(), + "arguments": call.arguments.clone(), + })).collect::>(), + }, + }); + let mut content = serde_json::to_string_pretty(&diagnostic) + .map_err(|error| format!("序列化本地 Provider 诊断失败:{error}"))?; + content.push('\n'); + if content.len() > PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES { + return Err(format!( + "本地 Provider 诊断超过 {PROVIDER_RECONCILIATION_DIAGNOSTIC_MAX_BYTES} 字节" + )); + } + let temporary = path.with_file_name(format!(".{request_key}.tmp.{}", unix_timestamp_nanos())); + let mut options = fs::OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let mut file = options + .open(&temporary) + .map_err(|error| format!("创建本地 Provider 诊断临时文件失败:{error}"))?; + if let Err(error) = file + .write_all(content.as_bytes()) + .and_then(|_| file.sync_data()) + { + let _ = fs::remove_file(&temporary); + return Err(format!("写入本地 Provider 诊断失败:{error}")); + } + drop(file); + if let Err(error) = fs::rename(&temporary, &path) { + let _ = fs::remove_file(&temporary); + return Err(format!("安装本地 Provider 诊断失败:{error}")); + } + Ok(relative_path) +} + +fn private_diagnostic_reference(error: &str) -> Option<&str> { + let reference = error.split_once(";localDiagnostic=")?.1.trim(); + let reference = reference.split(';').next()?.trim(); + if reference.starts_with(PROVIDER_RECONCILIATION_DIAGNOSTIC_RELATIVE_ROOT) + && reference + .chars() + .all(|character| character.is_ascii_alphanumeric() || "/.-_".contains(character)) + { + Some(reference) + } else { + None + } +} + +fn attach_private_diagnostic_reference( + mut audit: serde_json::Value, + reference: Option, +) -> serde_json::Value { + if let (Some(reference), Some(audit)) = (reference, audit.as_object_mut()) { + audit.insert( + "localDiagnostic".to_string(), + serde_json::Value::String(reference), + ); + } + audit +} + #[cfg(test)] pub(crate) fn mark_game_creator_agent_runtime_provider_success_handoff_needs_reconciliation_for_test( root: &Path, @@ -505,6 +647,8 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di { return Err("孤立 Provider 请求与当前 Runtime 身份冲突".to_string()); } + let private_reference = + diagnostic.and_then(|(_, error)| private_diagnostic_reference(error).map(str::to_string)); let diagnostic = diagnostic.map(|(failure_kind, error)| { ( failure_kind, @@ -544,6 +688,11 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di detail }) .unwrap_or_else(|| format!("requestId={request_id}")); + let public_detail = if let Some(reference) = private_reference.as_deref() { + format!("{public_detail} · localDiagnostic={reference}") + } else { + public_detail + }; let event_detail = diagnostic .is_some() .then_some(public_detail.as_str()) @@ -625,6 +774,7 @@ fn mark_game_creator_agent_runtime_provider_request_needs_reconciliation_with_di "requestSlot": snapshot.request_slot, }) }; + let audit = attach_private_diagnostic_reference(audit, private_reference); let _ = append_agent_db_record(root, audit); emit_game_creator_agent_runtime_update(root, &snapshot.agent_id); Ok(()) @@ -676,3 +826,76 @@ where ) .await } + +#[cfg(test)] +mod provider_reconciliation_diagnostic_tests { + use super::*; + + #[test] + fn private_diagnostic_keeps_raw_response_outside_project_state() { + let directory = tempfile::tempdir().expect("diagnostic directory"); + let snapshot = AgentRuntimeProviderRequestSnapshot { + project_id: "project-1".to_string(), + agent_id: "project-planning".to_string(), + task_id: "task-1".to_string(), + session_id: "session-1".to_string(), + run_id: "run-1".to_string(), + source: "agent-delegate".to_string(), + goal_id: None, + goal_revision: 0, + goal_snapshot_fingerprint: String::new(), + applied_steer_cursor: 0, + request_kind: "tool-plan".to_string(), + request_slot: "loop-1-repair-0".to_string(), + web_search_enabled: false, + allow_idle_context_compaction: false, + planning_session_binding: None, + }; + let response = platform_llm::LlmRunResponse { + provider: platform_llm::LlmProvider::OpenAiCompatible, + model: "test-model".to_string(), + text: "C:\\private\\response".to_string(), + finish_reason: Some("completed".to_string()), + response_id: Some("response-1".to_string()), + usage: None, + tool_calls: vec![platform_llm::LlmToolCall { + id: "call-1".to_string(), + name: "runtime_tool_plan_submit_gdd".to_string(), + arguments: "{\"path\":\"C:\\\\private\\\\argument\"}".to_string(), + }], + }; + let relative = write_provider_reconciliation_diagnostic_in_dir( + directory.path(), + &snapshot, + "provider-request-1", + &response, + "绝对路径 C:\\private\\error", + ) + .expect("write diagnostic"); + assert!(relative.starts_with("diagnostics/provider-reconciliation/")); + let persisted = + fs::read_to_string(directory.path().join(&relative)).expect("read diagnostic"); + let persisted: serde_json::Value = + serde_json::from_str(&persisted).expect("parse diagnostic"); + assert_eq!(persisted["response"]["text"], "C:\\private\\response"); + assert_eq!( + persisted["response"]["toolCalls"][0]["arguments"], + "{\"path\":\"C:\\\\private\\\\argument\"}" + ); + assert_eq!(persisted["failure"]["error"], "绝对路径 C:\\private\\error"); + } + + #[test] + fn private_diagnostic_reference_accepts_only_relative_reference() { + assert_eq!( + private_diagnostic_reference( + "失败;localDiagnostic=diagnostics/provider-reconciliation/p/r.json" + ), + Some("diagnostics/provider-reconciliation/p/r.json") + ); + assert_eq!( + private_diagnostic_reference("失败;localDiagnostic=C:\\secret.json"), + None + ); + } +} diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs index a6983321b..27b7a3164 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/provider_retry.rs @@ -1406,7 +1406,7 @@ where attempt_snapshot.clone(), provider_request, |provider_request_id, response| { - if persist_handoff { + let handoff_result = if persist_handoff { let response = canonicalize_handoff_response(response); provider_handoff::write_at( root, @@ -1415,7 +1415,8 @@ where attempt, provider_request_id, &response, - )?; + ) + .map(|_| ()) } else if persist_tool_plan_handoff { tool_plan_handoff::write_at( root, @@ -1424,7 +1425,24 @@ where attempt, provider_request_id, response, - )?; + ) + .map(|_| ()) + } else { + Ok(()) + }; + if let Err(error) = handoff_result { + let error = match write_provider_reconciliation_diagnostic_at( + &attempt_snapshot, + provider_request_id, + response, + &error, + ) { + Ok(path) => format!("{error};localDiagnostic={path}"), + Err(diagnostic_error) => { + format!("{error};localDiagnosticWriteFailed={diagnostic_error}") + } + }; + return Err(error); } Ok(()) }, diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs index 6e29595eb..41c0db77c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_state.rs @@ -4,6 +4,12 @@ static AGENT_RUNTIME_EVENT_ID_SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); pub(crate) const AGENT_RUNTIME_PUBLIC_STATUS_MESSAGE_ID_PREFIX: &str = "runtime-public-status-"; +/// task journal 只在**读**的时候校验 phase 白名单,写侧不校验。所以一个没登记的 +/// phase 落盘之后,整份 journal 从那一行起再也读不出来:`agent.run_status` 对该 +/// Agent 永久失败,父 run 只能瞎转到 needs-reconciliation。实测就是这么炸的。 +/// 让写方和白名单引用同一个常量,两边不可能再漂移。 +pub(crate) const AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED: &str = + "planning-session-projection-failed"; fn game_creator_agent_runtime_public_status_message_id( agent_id: &str, @@ -1204,7 +1210,13 @@ where )); } let current_revision = read_game_creator_agent_runtime_project_revision(root)?; - let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) { + // 澄清信封不是交付收束,是本 run 挂起等用户答。结构化计划完成度判据对它不可 + // 满足:想问用户就得先 respond_to_user,而计划里「按用户决定收敛并提交」那一 + // 步在用户答之前永远不可能 completed,于是问不出去、答不了、永远转。实测一条 + // 生产 run 因此空转 65 轮直到人工介入。其余判据仍然照常生效。 + let blocker = if let Some(blocker) = structured_plan_completion_blocker(&state) + .filter(|_| !response_is_static_delegate_user_input_envelope(response)) + { Some(blocker) } else if let Some(blocker) = game_creator_agent_goal_completion_blocker_at_locked(root, &state) { @@ -1603,6 +1615,7 @@ pub(crate) fn default_game_creator_agent_runtime_state( loop_iteration: 0, plan_submit_gdd_rejection_count: 0, plan_update_idle_rounds: 0, + stale_finalization_rounds: 0, max_loop_iterations: AGENT_RUNTIME_BACKGROUND_LOOP_LIMIT as u32, tool_action_budget: AGENT_RUNTIME_BACKGROUND_TOOL_ACTION_LIMIT as u32, plan_revision: 0, @@ -4164,6 +4177,7 @@ fn validate_game_creator_agent_runtime_task_status_phase( | "completion-contract-failed" | "conversation-write-failed" | "public-status-write-failed" + | AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED | "parent-terminal" | "parent-link-missing" ) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs index 05355ba15..948eda19a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent/runtime_tools/delegation.rs @@ -214,12 +214,21 @@ pub(crate) fn observe_agent_runtime_agent_message( /// 平坦的 depth <= 1 门,那时「唯一返工轮」对澄清跳也成立;本仓库改成按谱系分类后 /// 把预算抬到 3,这句话就变成了假天花板——生产实测 4 次澄清续跑全部命中它,命中后 /// 全部直接出稿,没有任何一个 run 走到第 2 轮。 +/// - `UserRevision`:用户在审批卡上点「修改 / 退回」后的修订轮。它同样带 +/// `repairOfDelegationId`,但 `repair_depth` 防的是 runaway agent,而这一跳每一轮 +/// 都由人触发——人本身就是循环边界,所以 `static_delegate_lineage_counters` 早就 +/// 把 depth/round 原样继承了。缺的是这句话:走 `Repair` 分支时用户第一次点修改就 +/// 会被告知「这是唯一返工轮」,和澄清跳当初那个假天花板是同一个错误。原型对应的是 +/// `USER_REVISION_SOFT_LIMIT = 16`,且超过只提示、不拒绝。 /// - `None`:普通委派,不加这一段。 pub(in crate::agent) enum StaticDelegateHopNote<'a> { None, Repair { original_delegation_id: &'a str, }, + UserRevision { + original_delegation_id: &'a str, + }, PlanClarification { original_delegation_id: &'a str, rounds_used: u32, @@ -238,6 +247,11 @@ impl StaticDelegateHopNote<'_> { StaticDelegateHopNote::Repair { original_delegation_id, } => format!("\n\n这是对已认领委派 {original_delegation_id} 的唯一返工轮。"), + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } => format!( + "\n\n这是对已认领委派 {original_delegation_id} 的用户修订轮,由用户在审批卡上提出,不是质量返工,不消耗返工深度,也不重置澄清轮次。按任务正文里的用户意见原文修订同一份 GDD 谱系后重新提交;用户看过新稿还可以再次提出修改,这不是最后一轮,不要因此压缩改动或提前收尾。" + ), // 预算用尽:planning_coordinator 出卡时会用 // `current_round >= 3` 直接拒掉第四张卡,所以这里不能再邀请提问, // 只能要求收稿——语义上等价于原型的 INJ_MUST_DRAFT_ROUNDS。 @@ -256,7 +270,7 @@ impl StaticDelegateHopNote<'_> { rounds_used, rounds_limit, } => format!( - "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 必须精确等于「第{next_round}轮·关键决定」。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", + "\n\n这是对已认领委派 {original_delegation_id} 的澄清续跑,不是返工轮,不消耗返工深度。已用澄清轮次 {rounds_used}/{rounds_limit}。仍有会实质改变结果的空白且预算未用尽时,可以继续以 AGC_NEEDS_USER_INPUT_V1 信封退出:questions 恰好一题,header 写成「第{next_round}轮·当前要决定:<主题>」,轮号必须是 {next_round},主题写这一轮真正要定的那件事。预算已用尽,或剩余空白能由默认建议覆盖且不影响首个可玩闭环时,立即提交 GDD。", next_round = rounds_used.saturating_add(1), ), } @@ -651,6 +665,29 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( } else { None }; + // 用户修订跳同样带 repairOfDelegationId,但它是人触发的,不该拿到「唯一返工轮」 + // 那句话。判据用原 delivery 的 contractStatus,并同样只作用于立项策划链路: + // `mark_static_delegate_delivery_user_revision_requested_at` 只由策划审批调用, + // 这里再加一道 target 门,做游戏 / 做素材的返工跳逐字保持 Repair 分支。 + let user_revision_hop = match repair_of_delegation_id.as_deref() { + Some(original) + if plan_clarification_rounds.is_none() + && target_agent_id == GAME_CREATOR_PROJECT_PLANNING_AGENT_ID => + { + match static_delegate_original_awaits_user_revision_at(root, original) { + Ok(value) => value, + Err(error) => { + return AgentRuntimeToolObservation { + tool: "agent.delegate".to_string(), + status: "failed".to_string(), + summary: redact_agent_runtime_project_paths(root, &error, 240), + detail: None, + }; + } + } + } + _ => false, + }; let hop_note = match ( repair_of_delegation_id.as_deref(), plan_clarification_rounds, @@ -662,6 +699,11 @@ pub(crate) fn observe_agent_runtime_agent_delegate_at_locked( rounds_limit, } } + (Some(original_delegation_id), None) if user_revision_hop => { + StaticDelegateHopNote::UserRevision { + original_delegation_id, + } + } (Some(original_delegation_id), None) => StaticDelegateHopNote::Repair { original_delegation_id, }, @@ -1688,6 +1730,40 @@ mod tests { /// 「你只剩这一轮」——这正是生产上 4 次澄清续跑之后无一走到第 2 轮的原因。 /// 同时钉住轮号:`planning_coordinator` 出卡时按 `rounds_used + 1` 校验 header, /// 这里写进 task 的必须是同一个数,否则第 2 轮信封会当场被拒。 + /// 用户修订轮同样不能套返工文案。 + /// + /// 「唯一返工轮」防的是 runaway agent,而这一跳由用户在审批卡上亲手点出来——人本身 + /// 就是循环边界,`static_delegate_lineage_counters` 早就把 depth/round 原样继承了。 + /// 套用返工文案就是告诉策划子 Agent「用户只能改这一次」,和澄清跳当初那个假天花板 + /// 是同一个错误。原型对应的是软阈值 16 次、超过只提示不拒绝。 + #[test] + fn user_revision_hop_note_is_not_the_repair_round_note() { + let revision = render_static_delegate_task_contract( + "任务", + "project-supervisor", + "run-1", + "delegation-new", + &["交付 game/fast_gdd.md".to_string()], + &["game/fast_gdd.md".to_string()], + StaticDelegateHopNote::UserRevision { + original_delegation_id: "delegation-old", + }, + ) + .expect("render user revision hop note"); + assert!( + !revision.contains("唯一返工轮"), + "用户修订轮不得复用返工文案,否则子 Agent 以为用户只能改这一次:{revision}" + ); + assert!( + revision.contains("不消耗返工深度"), + "必须写明它不吃返工额度:{revision}" + ); + assert!( + revision.contains("不是最后一轮"), + "必须写明用户还能再改,否则子 Agent 会把多条意见攒到一轮改完:{revision}" + ); + } + #[test] fn plan_clarification_hop_note_is_not_the_repair_round_note() { let repair = render_static_delegate_task_contract( @@ -1730,7 +1806,7 @@ mod tests { "澄清续跑必须写明已用轮次与上限:{clarification}" ); assert!( - clarification.contains("第2轮·关键决定"), + clarification.contains("第2轮·当前要决定:"), "task 里的轮号必须等于 planning_coordinator 校验 header 时用的 rounds_used + 1:{clarification}" ); @@ -1753,7 +1829,7 @@ mod tests { "预算用尽时必须要求收稿,出卡侧会直接拒掉第四张卡:{exhausted}" ); assert!( - !exhausted.contains("第4轮·关键决定"), + !exhausted.contains("第4轮·当前要决定:"), "预算用尽时不得再给出下一轮 header,那是一张永远递不上去的卡:{exhausted}" ); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs index 947760180..8ef1b13d1 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/agent_native_tools.rs @@ -1062,8 +1062,8 @@ fn plan_string_array_schema(min_items: usize, max_items: usize, item_max_length: /// Strict provider-facing shape for `plan-submit-gdd-input.v1`. /// /// Runtime-injected identity, platform facts, version and fingerprint fields -/// deliberately do not appear here. The durable handler performs the -/// semantic/session equality checks after parsing this wire shape. +/// deliberately do not appear here. The durable handler performs the +/// structural, identity and CAS checks after parsing this wire shape. fn plan_submit_gdd_input_schema() -> Value { let decision_state = json!({ "type": "string", @@ -1071,7 +1071,7 @@ fn plan_submit_gdd_input_schema() -> Value { }); let answer_source = json!({ "type": "string", - "enum": ["user_freeform", "user_option", "default"] + "enum": ["user_freeform", "user_option", "user_revision", "default"] }); let pillar = json!({ "type": "object", @@ -2130,6 +2130,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); @@ -2160,6 +2161,7 @@ mod tests { for stage in [ PlanRootSupervisorStage::GoalContract, PlanRootSupervisorStage::Delegate, + PlanRootSupervisorStage::AwaitingAcceptanceEvidence, PlanRootSupervisorStage::Delegated, ] { let mut staged = functions.clone(); diff --git a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs index 16e20945b..f49737d6b 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/delegation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/delegation.rs @@ -28,16 +28,24 @@ pub(crate) fn static_delegate_result_detail_max_chars( value: &str, default_max_chars: usize, ) -> usize { - if value - .trim_start() - .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) - { + if response_is_static_delegate_user_input_envelope(value) { STATIC_DELEGATE_USER_INPUT_MAX_RESPONSE_CHARS } else { default_max_chars } } +/// 这条回复是不是澄清信封,而不是一次交付收束。 +/// +/// 收束门禁按「任务是否做完」判据拦最终回复,而澄清信封恰恰相反:它是本 run +/// 就此挂起、把决定权交回用户,剩下的工作由用户答完之后的 continuation run 接着 +/// 做。用完成度判据去拦它,对任何含「答完之后再做 X」步骤的计划都不可满足。 +pub(crate) fn response_is_static_delegate_user_input_envelope(response: &str) -> bool { + response + .trim_start() + .starts_with(STATIC_DELEGATE_USER_INPUT_PREFIX) +} + /// 构造一份贴着问询 schema 上限的合法澄清信封,供跨模块的通道用例复用。 /// 通道必须容得下 schema 允许的最大合法问询,而不只是「碰巧短」的那一条。 #[cfg(test)] @@ -278,12 +286,33 @@ impl StaticDelegateCompletionBarrier { && self.unknown_contract_status_count == 0 } + /// 自动恢复/唤醒路径该不该收手。 + /// + /// `user_revision_pending_count` 计在这里是承重的:用户修订是一条显式的 Supervisor + /// 决策边界,在它派出续作之前父 run 绝不能被自动恢复(`runtime_tools/delivery.rs` + /// 的 `debug_assert` 把这份跨文件依赖钉在使用现场)。 pub(crate) fn has_waiting(self) -> bool { self.waiting_count > 0 || self.user_revision_pending_count > 0 || self.unknown_contract_status_count > 0 } + /// 当前正在跑的这一轮,有没有**外部事件**值得 park 着等。 + /// + /// 和 `has_waiting()` 问的是相反的问题,所以刻意不计 `user_revision_pending_count`: + /// - `waitingDelegations > 0`:子 Agent 正在跑,park 等它 —— 会有回执到来。 + /// - `unknownContractStatus > 0`:fail-closed,宁可停下也不按未知状态行动。 + /// - `userRevisionPending > 0`:**没有任何东西在跑**。那条回执只能来自本 run 自己 + /// 创建的修订委派,park 等它就是等自己,必然死锁。 + /// + /// 生产实测:用户点「修改」后 Supervisor park 在「等待专业 Agent 委派回执 / 回执全部 + /// ready 后自动唤醒当前父 run」,8 分钟零事件——它在等一条只有它自己能造出来的回执。 + /// main_loop 里本来就有一条专为 user_revision 写的分支(`phase=planning`、 + /// `next_step=调用 agent.delegate…`),但被上游这道 park 门截胡了。 + pub(crate) fn has_external_wait(self) -> bool { + self.waiting_count > 0 || self.unknown_contract_status_count > 0 + } + pub(crate) fn detail(self) -> String { format!( "waitingDelegations={} · readyUnclaimedReceipts={} · unobservedReceiptClaims={} · repairRequired={} · userInputRequired={} · userRevisionPending={} · unknownContractStatus={} · 必须认领专业 Agent 回执,处理 needs-user-input/needs-repair/user-revision-requested,或升级客户端后再继续", @@ -497,6 +526,42 @@ pub(crate) fn mark_static_delegate_delivery_ready_with_result_at( Ok(delivery) } +/// claim 里的 `structuredResult` 是「父 Agent 在那个 action 上观察到了什么」的冻结 +/// 快照;delivery 是当前真相。两者绝大多数时候必须逐字相等——不等就是漂移或篡改。 +/// +/// 唯一的例外是审批:用户在审批卡上点「修改 / 退回」后, +/// `mark_static_delegate_delivery_user_revision_requested_at` 会把 delivery 从 +/// `EvidenceReady` 原地改写成 `UserRevisionRequested`,而 claim 快照仍停在 +/// `EvidenceReady`。那不是漂移,是一次只由审批产生、且只能朝这个方向走的合法转移; +/// 快照记的那句「当时观察到 evidence-ready」现在依然为真,不该被改写。 +/// +/// 按全等判会把它当成冲突:`agent.run_status` 每次重放这条 claim 都 failed, +/// Supervisor 永远拿不到回执、也就永远建不出修订委派。生产实测卡死在第 43 轮空转, +/// 报「静态委派 claim 与 delivery 身份或结果冲突」。原型没有 claim 这层快照,单一 +/// 真相就地改,结构上不存在这个冲突——这里翻译的是同一个语义:比较的是「delivery 是 +/// 不是 receipt 的合法后继」,不是「两者永远全等」。 +/// +/// 放行面刻意压到最小:除 `contractStatus` 外每个字段都必须逐字不变,且方向唯一。 +fn static_delegate_structured_result_follows_claim_snapshot( + snapshot: Option<&StaticDelegateStructuredResult>, + current: Option<&StaticDelegateStructuredResult>, +) -> bool { + if snapshot == current { + return true; + } + let (Some(snapshot), Some(current)) = (snapshot, current) else { + return false; + }; + if snapshot.contract_status != StaticDelegateContractStatus::EvidenceReady + || current.contract_status != StaticDelegateContractStatus::UserRevisionRequested + { + return false; + } + let mut rebased = current.clone(); + rebased.contract_status = StaticDelegateContractStatus::EvidenceReady; + rebased == *snapshot +} + /// Mark an already claimed, evidence-ready planning delivery as waiting for a /// user-requested revision. Approval is the only producer of this durable /// status; keeping the transition here makes its evidence precondition and @@ -1097,7 +1162,10 @@ fn commit_static_delegate_claim_with_locks_with_budget_at( || delivery.acceptance_criteria != receipt.acceptance_criteria || delivery.expected_artifacts != receipt.expected_artifacts || delivery.repair_of_delegation_id != receipt.repair_of_delegation_id - || delivery.structured_result != receipt.structured_result + || !static_delegate_structured_result_follows_claim_snapshot( + receipt.structured_result.as_ref(), + delivery.structured_result.as_ref(), + ) { return Err(format!( "静态委派 claim 与 delivery 身份或结果冲突:{}", @@ -1216,6 +1284,21 @@ fn static_delegate_original_is_awaiting_clarification( /// /// 该状态只由后续审批工作包写入;本包只让 lineage 重放认识它,不能自行生成或 /// 把其它状态静默映射成它。 +/// 该原 delivery 是否正等着用户提出的修订(而不是质量返工)。 +/// +/// 用户修订和质量返工都带 `repairOfDelegationId`,但额度完全不同:`repair_depth` +/// 防的是 runaway agent,而用户修订每一轮都由人触发,人本身就是循环边界。委派 task +/// 末尾那句「你在这条链路上的位置」必须按这个判据分开渲染,否则用户第一次点修改就会 +/// 被告知「这是唯一返工轮」。 +pub(crate) fn static_delegate_original_awaits_user_revision_at( + root: &Path, + delegation_id: &str, +) -> Result { + Ok(read_static_delegate_delivery_at(root, delegation_id)? + .as_ref() + .is_some_and(static_delegate_original_is_user_revision_requested)) +} + fn static_delegate_original_is_user_revision_requested( delivery: &StaticDelegateDeliveryRecord, ) -> bool { @@ -2560,8 +2643,8 @@ mod tests { serde_json::json!({ "questions": [{ "id": "plan_round_1", - "header": "第1轮·关键决定", - "question": "当前要决定:影子能力在首个可玩闭环中的核心作用。它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", + "header": "第1轮·当前要决定:影子能力在首个可玩闭环中的核心作用", + "question": "它会同时决定关卡布局、操作手感与原型优先级,也决定第一批谜题按什么规则组合;现在确认可以避免把三种玩法都做浅,也避免原型做到一半再推翻核心规则。", "options": [ { "label": "A · 影子化为可独立移动的暗影分身", @@ -2602,12 +2685,12 @@ mod tests { // abtest-tide2A-2:尾巴是「 马会」。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_motivation","header":"第1轮·关键决定","question":"当前要决定:固定五岛海图的重复游玩动力采用哪种方案?现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, + r#"{"questions":[{"id":"replay_motivation","header":"第1轮·当前要决定:固定五岛海图的重复游玩动力","question":"现在确认它,才能锁定首个可玩闭环之外的得分与重开目标。","options":[{"label":"A · 推荐:固定布局冲榜","description":"每局地图与信件配置固定,玩家通过更优路线、潮汐 timing 和装卸顺序刷新送达数与总分;优点是实现最小、可读性强,代价是内容变化较少。"},{"label":"B · 轮换信件组合","description":"地图固定但每局从预设信件组合中轮换收件岛与期限;优点是重玩变化更明显,代价是需要额外平衡组合并降低可预测性。"},{"label":"需要原型验证","description":"用30~90分钟做可点击五岛地图与两种信件配置原型,让3名偏好轻策略的玩家各玩3局,观察是否主动重开及路线是否有差异;通过标准是多数玩家愿意重开且能说出改进路线。"}]}]} 马会"#, ), // verify-farm-4:尾巴是古吉拉特语字母、西里尔字母和中文垃圾词的混合物。 concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"replay_progression","header":"第2轮·关键决定","question":"当前要决定:自由经营农场的长期目标采用哪种组合?这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, + r#"{"questions":[{"id":"replay_progression","header":"第2轮·当前要决定:自由经营农场的长期目标","question":"这会决定玩家为何持续规划、赚钱与重玩,并控制 MVP 的范围。","options":[{"label":"A · 推荐:里程碑升级+成就","description":"以累计资金解锁少量新地块或设施,同时完成可选成就;优点是目标清晰又保留自由安排,代价是需要同时做基础升级与成就追踪。"},{"label":"B · 专注农场扩建","description":"只用经营收益逐步解锁地块与设施,成就仅作展示;优点是系统更聚焦、反馈直接,代价是挑战层次和重玩目标较少。"},{"label":"需要原型验证","description":"制作 30–90 分钟微型原型,让 2–3 名目标玩家试玩两种目标结构,观察他们是否主动设定计划、理解进展并愿意继续经营;多数玩家能完成一次扩建且愿意追求第二个目标即通过。"}]}]}સwerhu рҭ. 北京赛车? тру. [ ]"#, ), ]; for response in cases { @@ -2628,7 +2711,7 @@ mod tests { // verify-farm-2 现场原文,结尾是 `}]}` 而非 `}]}]}`。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·关键决定","question":"当前要决定:这款农场经营游戏的一局,玩家主要通过什么目标获得满足?现在先定核心闭环,才能控制 MVP 范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, + r#"{"questions":[{"id":"core_loop_goal","header":"第1轮·当前要决定:一局里玩家靠什么目标获得满足","question":"现在先定核心闭环,才能控制 MVP范围。","options":[{"label":"A · 推荐:短周期订单经营","description":"围绕播种、收获、加工并完成限时订单推进;目标清晰、反馈快,代价是自由建造与长期规划较少。"},{"label":"B · 自主农场成长","description":"围绕规划田地、逐步扩建并达成阶段里程碑;沉浸和成长感更强,代价是前期目标反馈较慢、系统边界更难控。"},{"label":"需要原型验证","description":"制作 30~90 分钟微型原型,包含种植、收获和一种目标;让 2~3 名目标玩家试玩,观察是否理解目标、是否愿意继续一轮;通过标准是多数玩家无需讲解即可完成闭环并主动开始第二轮。"}]}"#, ); let error = parse_static_delegate_user_input_request(Some(response)) .expect_err("an envelope that stops short of closing must not parse"); @@ -2832,8 +2915,8 @@ mod tests { // 实测形态:option 对象里多写了一个 `id` 字段。 let response = concat!( "AGC_NEEDS_USER_INPUT_V1\n", - "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·关键决定\",", - "\"question\":\"当前要决定:核心闭环形状。\",\"options\":[", + "{\"questions\":[{\"id\":\"core_loop\",\"header\":\"第1轮·当前要决定:核心闭环形状\",", + "\"question\":\"它决定首个可玩闭环长什么样。\",\"options\":[", "{\"id\":\"a\",\"label\":\"A · 甲方案\",\"description\":\"甲方案的后果\"},", "{\"id\":\"b\",\"label\":\"B · 乙方案\",\"description\":\"乙方案的后果\"},", "{\"id\":\"c\",\"label\":\"需要原型验证\",\"description\":\"做个微型原型看看\"}]}]}" diff --git a/apps/ai-game-creator-shell/src-tauri/src/main.rs b/apps/ai-game-creator-shell/src-tauri/src/main.rs index 25b23d44d..ab2dd513a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/main.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/main.rs @@ -312,6 +312,12 @@ struct AgentRuntimeState { /// Provider spend. #[serde(default)] plan_update_idle_rounds: u32, + /// Consecutive final replies this run had refused by a completion blocker. + /// Runtime-owned durable state for the same reason as the two counters + /// above: a blocker the model cannot satisfy is a livelock, and a runner + /// restart must not launder it back into unbounded Provider spend. + #[serde(default)] + stale_finalization_rounds: u32, #[serde(default)] max_loop_iterations: u32, #[serde(default)] diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs index 3e40c51b4..12eb9f5d0 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/conversation.rs @@ -1152,12 +1152,6 @@ fn append_local_conversation_message_for_session_internal_at( ) } }; - if archived { - return Err(format!( - "Agent Session 已归档,只能读取:{}", - normalized_session_id.as_deref().unwrap_or_default() - )); - } let role = role.trim(); if !matches!(role, "user" | "assistant" | "tool") { return Err("对话角色必须是 user、assistant 或 tool".to_string()); @@ -1226,6 +1220,12 @@ fn append_local_conversation_message_for_session_internal_at( } false } else { + if archived { + return Err(format!( + "Agent Session 已归档,只能读取:{}", + normalized_session_id.as_deref().unwrap_or_default() + )); + } append_jsonl_line_unlocked(&path, &line, "对话记录")?; true }; diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs index 5b7ebc4bf..4ec311a4a 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/collaboration/static_deliveries.rs @@ -515,8 +515,8 @@ fn planning_clarification_question_body_with_labels( let body = serde_json::json!({ "questions": [{ "id": question_id, - "header": format!("第{round}轮·关键决定"), - "question": format!("当前要决定:第{round}轮核心取舍。现在确认后才能继续收敛 Fast GDD。"), + "header": format!("第{round}轮·当前要决定:第{round}轮核心取舍"), + "question": format!("第{round}轮核心取舍现在确认后才能继续收敛 Fast GDD。"), "options": [ { "label": label_a, @@ -4421,6 +4421,42 @@ fn planning_clarification_user_revision_after_answer_preserves_round_for_revise_ &submitted_delivery.delegation_id, ) .expect("mark submitted delivery user-revision-requested"); + + // 审批改写 delivery 之后、派发修订委派之前,Supervisor 必然先调一次 + // `agent.run_status`,它会把这条已 observed 的 claim 整个重放一遍。claim 里的 + // structuredResult 仍是审批前那份 EvidenceReady 快照,而 delivery 已经是 + // UserRevisionRequested——这一步按全等判就会报「claim 与 delivery 身份或结果 + // 冲突」,Supervisor 从此拿不到回执,也就永远建不出下面那条修订委派。 + // + // 生产实测正是卡在这里:run_status 连续 failed、空转到第 43 轮。此前这个用例 + // 从 mark 直接跳到 dispatch,跳过的恰好是唯一会失败的那一步。 + let barrier = static_delegate_completion_barrier_at( + &fixture.root, + GAME_CREATOR_PROJECT_SUPERVISOR_AGENT_ID, + &fixture.supervisor.run_id, + ) + .expect("审批改写 delivery 后,claim 重放必须仍然成立"); + + // 屏障必须同时说出两件事:任务还不能收束,但**没有外部事件可等**。 + // + // 只判「不能收束」不够——修复前它正是这样:main_loop 把用户修订待办当成 + // 「有在等的委派」,park 成「等待专业 Agent 委派回执 / 回执全部 ready 后自动 + // 唤醒当前父 run」,而那条回执只能来自下面这条还没派出去的修订委派。生产实测 + // 8 分钟零事件。has_external_wait() 为假才能让本轮落进 user_revision 分支去 + // 调 agent.delegate。 + assert!( + !barrier.is_clear(), + "用户修订待办没派出续作前,父 run 不能被判为可收束" + ); + assert!( + !barrier.has_external_wait(), + "用户修订待办没有任何外部事件可等,park 住就是等自己派出的委派" + ); + assert!( + barrier.has_waiting(), + "自动恢复路径仍须收手:续作派出前不得跨过这条 Supervisor 决策边界" + ); + let revision_action_id = format!("planning-user-{action}-continuation"); let revision = dispatch_static_delegate_plain_repair( &fixture.root, diff --git a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs index 1e3b140d1..b77477437 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tests/runtime_state.rs @@ -235,19 +235,33 @@ fn runtime_task_reader_accepts_rare_persisted_phases() { "failed", "public-status-write-failed", ); + // 写侧不校验 phase,只有读侧校验:白名单漏登记一个 phase,落盘之后整份 journal + // 从那一行起再也读不出来,`agent.run_status` 对该 Agent 永久失败。这里用写方 + // 引用的同一个常量,漏登记会在这条用例上先红。 + let planning_session_projection_failed = runtime_task_json_line( + "planning-session-projection-failed-run", + "failed", + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED, + ); fs::write( &path, - format!("{brief}\n{parent_link_missing}\n{public_status_write_failed}\n"), + format!( + "{brief}\n{parent_link_missing}\n{public_status_write_failed}\n{planning_session_projection_failed}\n" + ), ) .expect("write rare persisted task phases"); let records = read_all_game_creator_agent_runtime_tasks(&path) .expect("known persisted task phases must remain readable"); - assert_eq!(records.len(), 3); + assert_eq!(records.len(), 4); assert_eq!(records[0].phase, "brief"); assert_eq!(records[1].phase, "parent-link-missing"); assert_eq!(records[2].phase, "public-status-write-failed"); + assert_eq!( + records[3].phase, + AGENT_RUNTIME_TASK_PHASE_PLANNING_SESSION_PROJECTION_FAILED + ); fs::remove_dir_all(root).ok(); } diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs index 62a90d607..8e470a17c 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/content_validation.rs @@ -295,8 +295,19 @@ fn validate_tool_plan_json_absolute_path_inputs( value: &serde_json::Value, duplicate_safe_json: bool, ) -> Result<(), String> { + if tool_name == crate::agent_native_tools::PLAN_SUBMIT_GDD_FUNCTION_NAME { + // plan.submit_gdd 只承载 GDD/决定文本,没有可执行的文件路径字段。 + // 其中出现的斜杠、示例路径等属于用户内容,不应按工具路径扫描。 + return Ok(()); + } let mut findings = Vec::new(); - collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + if let Some(runtime_tool) = native_tool_for_handoff_function(tool_name) { + collect_native_tool_absolute_path_findings(root, runtime_tool, value, &mut findings); + } else { + // 未知工具和 legacy tool-plan 没有可信字段 schema,继续保守扫描整个 + // arguments payload,避免在无法解释参数语义时放宽路径边界。 + collect_tool_plan_absolute_path_findings(root, value, None, "#", &mut findings); + } let Some(first) = findings.first() else { return Ok(()); }; @@ -313,6 +324,189 @@ fn validate_tool_plan_json_absolute_path_inputs( )) } +fn native_tool_for_handoff_function(tool_name: &str) -> Option<&'static str> { + crate::agent::agent_runtime_native_executable_tools() + .into_iter() + .find(|tool| { + crate::agent_native_tools::native_runtime_function_name(tool).as_deref() + == Some(tool_name) + }) +} + +fn collect_native_tool_absolute_path_findings( + root: &Path, + tool: &str, + arguments: &serde_json::Value, + findings: &mut Vec, +) { + let (input, input_pointer) = arguments + .get("input") + .map(|input| (input, "#/input")) + .unwrap_or((arguments, "#")); + match tool { + "project.search" | "file.list" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "file.read" | "file.write" | "file.patch" | "file.delete" => { + collect_native_string_field( + root, + input, + "path", + &format!("{input_pointer}/path"), + findings, + ); + } + "project.patchset" => { + if let Some(changes) = input.get("changes").and_then(serde_json::Value::as_array) { + for (index, change) in changes.iter().enumerate() { + let pointer = format!("{input_pointer}/changes/{index}/path"); + collect_native_string_field(root, change, "path", &pointer, findings); + } + } + } + "project.git_commit" | "image.inspect" => { + collect_native_string_array_field( + root, + input, + "paths", + &format!("{input_pointer}/paths"), + findings, + ); + } + "command.exec" | "command.start" => { + collect_native_string_field( + root, + input, + "cwd", + &format!("{input_pointer}/cwd"), + findings, + ); + if let Some(args) = input.get("args").and_then(serde_json::Value::as_array) { + for (index, argument) in args.iter().enumerate() { + let pointer = format!("{input_pointer}/args/{index}"); + collect_native_string_value(root, argument, &pointer, findings); + } + } + } + "project.verify" => { + collect_native_string_field( + root, + input, + "expectedCommand", + &format!("{input_pointer}/expectedCommand"), + findings, + ); + } + "canvas.asset_generate" => { + collect_native_string_field( + root, + input, + "outputPath", + &format!("{input_pointer}/outputPath"), + findings, + ); + } + "ui.workflow.run" => { + if let Some(pages) = input.get("pages").and_then(serde_json::Value::as_array) { + for (index, page) in pages.iter().enumerate() { + let pointer = format!("{input_pointer}/pages/{index}/applicationPath"); + collect_native_string_field(root, page, "applicationPath", &pointer, findings); + } + } + } + "task.create" => { + collect_native_string_array_field( + root, + input, + "artifacts", + &format!("{input_pointer}/artifacts"), + findings, + ); + } + "agent.delegate" => { + collect_native_string_array_field( + root, + input, + "expectedArtifacts", + &format!("{input_pointer}/expectedArtifacts"), + findings, + ); + } + "agent.spawn_isolated" => { + if let Some(children) = input.get("children").and_then(serde_json::Value::as_array) { + for (index, child) in children.iter().enumerate() { + let pointer = format!("{input_pointer}/children/{index}/writeScopes"); + collect_native_string_array_field( + root, + child, + "writeScopes", + &pointer, + findings, + ); + } + } + } + // 其它原生工具的输入是文本、ID、枚举或计数,不承载文件路径。 + _ => {} + } +} + +fn collect_native_string_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(value) = object.get(key) { + collect_native_string_value(root, value, pointer, findings); + } +} + +fn collect_native_string_array_field( + root: &Path, + object: &serde_json::Value, + key: &str, + pointer: &str, + findings: &mut Vec, +) { + if let Some(values) = object.get(key).and_then(serde_json::Value::as_array) { + for (index, value) in values.iter().enumerate() { + collect_native_string_value(root, value, &format!("{pointer}/{index}"), findings); + } + } +} + +fn collect_native_string_value( + root: &Path, + value: &serde_json::Value, + pointer: &str, + findings: &mut Vec, +) { + let Some(value) = value.as_str() else { + return; + }; + let Some(path_shape) = tool_plan_absolute_path_shape(value) else { + return; + }; + let relation_to_root = if matches!(path_shape, "exact-absolute" | "exact-platform-absolute") { + lexical_absolute_path_relation_to_root(root, value) + } else { + "not-applicable" + }; + findings.push(ToolPlanAbsolutePathFinding { + json_pointer: pointer.to_string(), + path_shape: path_shape.to_string(), + relation_to_root: relation_to_root.to_string(), + }); +} + fn collect_tool_plan_absolute_path_findings( root: &Path, value: &serde_json::Value, @@ -461,7 +655,7 @@ fn tool_plan_absolute_path_shape(value: &str) -> Option<&'static str> { fn tool_plan_function_class(tool_name: &str) -> String { if tool_name == crate::agent::AGENT_RUNTIME_TOOL_PLAN_FUNCTION_NAME { "legacy-tool-plan".to_string() - } else if let Some(tool) = super::ledger::runtime_tool_for_native_handoff_function(tool_name) { + } else if let Some(tool) = native_tool_for_handoff_function(tool_name) { format!("native:{tool}") } else { "other".to_string() diff --git a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs index 8361ddb25..23a988a2f 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/tool_plan_handoff/tests.rs @@ -797,30 +797,6 @@ fn tool_plan_handoff_reports_file_uri_and_flattened_path_shapes() { "exact-absolute" }, ), - ( - serde_json::json!({ - "reason": "修复页面", - "opaqueProviderField": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), - ( - serde_json::json!({ - "reason": "修复页面", - "12345678901234567890": "/tmp/private.html", - }), - "#/field", - if cfg!(windows) { - "exact-platform-absolute" - } else { - "exact-absolute" - }, - ), ] .into_iter() .enumerate() diff --git a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs index 017142cfb..496fe0615 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/user_input.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/user_input.rs @@ -41,10 +41,42 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MIN_OPTIONS: usize = 2; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS: usize = 3; const AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS: usize = 64; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS: usize = 12; +/// 立项策划澄清卡的 header 是「这一轮要定的是什么」本身,不是一个 12 字的标题格。 +/// +/// 策划子 Agent 每轮都是全新 run,跨轮只能靠委派正文里转述的既往问答认路;header 带 +/// 主题时它一眼能看出哪几条轴已经关掉。原型(`local-scripts/deisgn_agent`)就是这么 +/// 做的:header 上限 60 字、写成 `第 N 轮 · 当前要决定:…`,决定台账的 `topic` 直接取 +/// 它。本仓库把 header 压成固定 8 字的轮号计数器后这条通路就断了。 +/// +/// 放宽只对 `第{N}轮` 这一种形状生效(`plan_clarification_header_limit`)。通用问询今天 +/// 能过的 header 明天逐字照过——做游戏 / 做素材两条泳道拿到的仍是 12 字上限,这里没有 +/// 任何一条既有请求会因此改变结果。 +pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS: usize = 400; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS: usize = 60; pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS: usize = 240; +/// 该 header 能用到的字符上限。 +/// +/// 判据是形状而不是身份:这个函数在通用 `user.input_request` 解析路径上,六个调用点里 +/// 有两个(工具计划校验、动作摘要)拿不到 root,问不出「这封信是不是策划链路的」。形状 +/// 判据只放宽、从不收紧——非策划 header 一律走 12 字原路,策划 header 的真正定形由 +/// `planning_coordinator::validate_exact_plan_clarification_question` 逐字兜底。 +fn plan_clarification_header_limit(header: &str) -> usize { + let Some(rest) = header.trim_start().strip_prefix('第') else { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + }; + let rest = rest.trim_start(); + let digits = rest + .chars() + .take_while(char::is_ascii_digit) + .collect::(); + if digits.is_empty() || !rest[digits.len()..].trim_start().starts_with('轮') { + return AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS; + } + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS +} + /// 一份 schema 合法的澄清问询在线上最多可能有多长(字符)。 /// /// 存在的意义是给中转通道一个由 schema 推导的上限,而不是让它自己拍一个数。 @@ -60,8 +92,10 @@ pub(crate) const AGENT_RUNTIME_USER_INPUT_MAX_WIRE_CHARS: usize = { let per_option = AGENT_RUNTIME_USER_INPUT_MAX_OPTION_LABEL_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTION_DESCRIPTION_CHARS + OPTION_SYNTAX_CHARS; + // 取两种 header 里宽的那个:通道窄于 schema 的后果是一封完全合法的策划信封在父 run + // 认领回执时被拒、整条委派链阻断,正是这个常量当初要防的那件事。 let per_question = AGENT_RUNTIME_USER_INPUT_MAX_ID_CHARS - + AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS + + AGENT_RUNTIME_USER_INPUT_MAX_PLAN_HEADER_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_QUESTION_CHARS + AGENT_RUNTIME_USER_INPUT_MAX_OPTIONS * per_option + QUESTION_SYNTAX_CHARS; @@ -314,7 +348,7 @@ fn normalize_user_input_questions( } let header = normalize_single_line_user_input_text( &question.header, - AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS, + plan_clarification_header_limit(&question.header), &format!("user.input_request question {} header", question_index + 1), )?; let question_text = normalize_single_line_user_input_text( @@ -1347,6 +1381,27 @@ mod tests { }] } + /// 放宽 header 上限只对策划澄清卡那一种形状生效,且只放宽、不收紧。 + /// + /// 两个方向都得钉:同一条 31 字的 header,带 `第N轮·` 前缀要过(策划卡装的是决定 + /// 主题本身),不带就必须照旧被 12 字挡下——否则这次改动就顺手把做游戏 / 做素材 + /// 的通用问询也放宽了,而那两条泳道本轮不该有任何行为变化。 + #[test] + fn only_the_plan_clarification_header_shape_gets_the_wider_limit() { + let long_topic = "当前要决定:一局里玩家靠什么目标获得满足"; + assert!(long_topic.chars().count() > AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS); + + let mut plan_header = valid_questions(); + plan_header[0].header = format!("第1轮·{long_topic}"); + assert!(normalize_user_input_questions(plan_header).is_ok()); + + let mut generic_header = valid_questions(); + generic_header[0].header = long_topic.to_string(); + assert!(normalize_user_input_questions(generic_header) + .expect_err("通用 header 不得因为策划分支被放宽") + .contains(&AGENT_RUNTIME_USER_INPUT_MAX_HEADER_CHARS.to_string())); + } + #[test] fn user_input_questions_require_unique_snake_case_ids_and_two_options() { assert!(normalize_user_input_questions(valid_questions()).is_ok()); @@ -1475,8 +1530,8 @@ mod tests { }; let question = AgentRuntimeUserInputQuestion { id: "route_choice".to_string(), - header: "第1轮·关键决定".to_string(), - question: "当前要决定:首版路线。".to_string(), + header: "第1轮·当前要决定:首版路线".to_string(), + question: "它决定第一批关卡按什么规则组合。".to_string(), options: vec![ AgentRuntimeUserInputOption { label: "接受推荐".to_string(), diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index c46441dfc..e265d1e82 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -697,7 +697,7 @@ export function App({ } catch (error) { // 方案 §18.3 要求 decision 返回后以 hydrate 对权威文件的重验为准,失败分支同样 // 适用:不重灌就会让卡片停在已失效的 pending 身份上,三个决定按钮仍可点,且 - // `recoveryPending` 永远翻不成真、「重试恢复」入口不渲染,卡内没有出路。 + // `recoveryPending` 永远翻不成真、「重试同步」入口不渲染,卡内没有出路。 // 两句顺序不能反——`hydratePlanGddState` 入口会 `setPlanGddError(null)`, // 先写错误再 hydrate 等于把这条错误擦掉。它自身从不抛出,不需要再包一层。 await hydratePlanGddState(targetProjectPath); @@ -10910,7 +10910,8 @@ export function App({ } workspaceStatus={workspaceStatus} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} @@ -11007,7 +11008,8 @@ export function App({ projectSupervisorRuntimeError={projectSupervisorRuntimeError} projectSupervisorTransientReply={projectSupervisorTransientReply} planGddState={planGddState} - planGddHydrateBusy={planGddHydrateBusy || planGddDecisionBusy} + planGddHydrateBusy={planGddHydrateBusy} + planGddDecisionBusy={planGddDecisionBusy} planGddError={planGddError} onPlanGddRefresh={() => void hydratePlanGddState()} onPlanGddDecision={decidePlanGdd} diff --git a/apps/ai-game-creator-shell/src/app/types.ts b/apps/ai-game-creator-shell/src/app/types.ts index 892887c72..23474fb71 100644 --- a/apps/ai-game-creator-shell/src/app/types.ts +++ b/apps/ai-game-creator-shell/src/app/types.ts @@ -256,7 +256,11 @@ export interface PlanGddStateViewV1 { id: string; topic: string; state: 'confirmed' | 'default_pending' | 'prototype_pending'; - answerSource: 'user_option' | 'user_freeform' | 'default'; + answerSource: + | 'user_option' + | 'user_freeform' + | 'user_revision' + | 'default'; round: number; answerSummary: string; basis: null; diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx index 0d9681e01..628f6f517 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/GddApprovalCard.tsx @@ -34,7 +34,8 @@ function planGddMarkdownDisplayPath(projectPath: string) { type GddApprovalCardProps = { state: PlanGddStateViewV1 | null; - busy: boolean; + hydrateBusy: boolean; + decisionBusy: boolean; error: string | null; onRefresh: () => void; onDecision: ( @@ -76,7 +77,8 @@ export function PlanGddSurface({ state, active = false, projectPath, - busy, + hydrateBusy, + decisionBusy, error, onRefresh, onDecision, @@ -101,7 +103,8 @@ export function PlanGddSurface({ {showCard ? ( { // 方案 §18.2:`recoveryPending` 期间只允许重试同一 ID,不允许提交决定。触发按钮 // 已经由 `canDecide` 门住,但弹层是打开后才可能被后台 hydrate 翻掉资格的, // 所以提交口要自己再判一次,不能只靠按钮 disabled。 - if (!canDecide || !commentAction || !comment.trim()) { + if (decisionDisabled || !commentAction || !comment.trim()) { return; } void onDecision(commentAction, comment.trim()) @@ -417,9 +422,9 @@ export function GddApprovalCard({ {state.recoveryPending ? (
- 审批状态正在恢复,请保持当前审批版本不变。 -
) : null} @@ -434,23 +439,23 @@ export function GddApprovalCard({
diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx index 3dde6bd9e..1e22942f0 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectSupervisorView.tsx @@ -61,6 +61,7 @@ type ProjectSupervisorViewProps = RuntimePanelProps & { workspaceStatus: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -94,6 +95,7 @@ export function ProjectSupervisorView({ workspaceStatus, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -114,7 +116,8 @@ export function ProjectSupervisorView({ state={planGddState} active={isPlanningLaneRuntime(runtimePanelProps.runtime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} diff --git a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx index 0144ba194..f06471eb9 100644 --- a/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx +++ b/apps/ai-game-creator-shell/src/features/project-workspace/ProjectWorkspaceChatPane.tsx @@ -182,6 +182,7 @@ type ProjectWorkspaceChatPaneProps = { projectSupervisorTransientReply: string; planGddState: PlanGddStateViewV1 | null; planGddHydrateBusy: boolean; + planGddDecisionBusy: boolean; planGddError: string | null; onPlanGddRefresh: () => void; onPlanGddDecision: ( @@ -273,6 +274,7 @@ export function ProjectWorkspaceChatPane({ projectSupervisorTransientReply, planGddState, planGddHydrateBusy, + planGddDecisionBusy, planGddError, onPlanGddRefresh, onPlanGddDecision, @@ -369,7 +371,8 @@ export function ProjectWorkspaceChatPane({ state={planGddState} active={isPlanningLaneRuntime(projectSupervisorRuntime)} projectPath={projectPath} - busy={planGddHydrateBusy} + hydrateBusy={planGddHydrateBusy} + decisionBusy={planGddDecisionBusy} error={planGddError} onRefresh={onPlanGddRefresh} onDecision={onPlanGddDecision} diff --git a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts index f4a214ed6..ac8ac9143 100644 --- a/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts +++ b/apps/ai-game-creator-shell/src/view/project-development/resourceCanvasLayoutModel.ts @@ -1322,8 +1322,7 @@ export function resourceCanvasContentBounds( ...positions.map( (position) => position.x + - resourceCanvasCardSize(position.resourceId, cardSizeByResourceId) - .width, + resourceCanvasCardSize(position.resourceId, cardSizeByResourceId).width, ), ); const maxY = Math.max( diff --git a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx index cc53b7efb..8779ec102 100644 --- a/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx +++ b/apps/ai-game-creator-shell/tests/agentRuntimeUserInputCard.test.tsx @@ -20,7 +20,7 @@ function clarificationRequest(): AgentRuntimeUserInputRequest { questions: [ { id: 'q1', - header: '第1轮·关键决定', + header: '第1轮·当前要决定:首版路线', question: '这局游戏的重玩动力是什么?', options: [ { label: '分数驱动', description: '刷新纪录后重开' }, @@ -46,7 +46,9 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { , ); - const textarea = screen.getByLabelText('第1轮·关键决定 其他回答'); + const textarea = screen.getByLabelText( + '第1轮·当前要决定:首版路线 其他回答', + ); fireEvent.change(textarea, { target: { value: '玩家自己写的答案' } }); expect((textarea as HTMLTextAreaElement).value).toBe('玩家自己写的答案'); @@ -64,7 +66,7 @@ describe('AgentRuntimeUserInputCard 澄清输入', () => { fireEvent.click(screen.getByText('分数驱动')); const textarea = screen.getByLabelText( - '第1轮·关键决定 其他回答', + '第1轮·当前要决定:首版路线 其他回答', ) as HTMLTextAreaElement; expect(textarea.value).toBe('分数驱动'); diff --git a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts index cb3e568a0..6a4ca9104 100644 --- a/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts +++ b/apps/ai-game-creator-shell/tests/appSurface/plan-gdd.suite.ts @@ -124,8 +124,8 @@ export function registerPlanGddApprovalTests() { hydrateCallsBeforeDecision, ); }); - // 重灌后「重试恢复」入口出现——这是 recoveryPending 下唯一被允许的动作。 - await screen.findByRole('button', { name: '重试恢复' }); + // 重灌后「重试同步」入口出现——这是 recoveryPending 下唯一被允许的动作。 + await screen.findByRole('button', { name: '重试同步' }); // 而且重灌不能把决定失败的原因擦掉:hydrate 入口会 setPlanGddError(null), // 两句顺序写反这条断言就红。 expect(screen.getByRole('alert').textContent).toContain( diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index c569689d5..491f52a40 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -16,6 +16,23 @@ --- +## 2026-08-27 `plan.submit_gdd` 拒绝无审批决定的 `user_revision` + +- 背景:结构校验允许 `round=0 + user_revision + confirmed`,提交闸原先只做结构、身份和 Session CAS。Provider 可在首次 collecting、澄清续跑或提交前质量返工里把未确认项标成用户审批修改,审批卡显示「已确认」。 +- 决策:新版本 create 时,payload 含 `user_revision` 则当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;否则 `PLAN_INVALID_REQUEST`。同 `submissionId` replay 不重判。不恢复 session 前缀逐项相等,不把 `user_revision` 与审批意见正文对齐,也不在这次处理 `round≥1` 的 `user_option` 伪造。 +- 影响范围:`planning_submit.rs` 提交闸;Fast GDD 技术方案第 5.1 / 8.2 / 12 节。 +- 验证方式:首次 collecting 带 invented-confirmation 必须拒绝且不落 GDD;reject continuation 再交 `user_revision` 的 v2 仍成功。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`。 + +## 2026-08-28 planning continuation 必须沿当前 delivery 游标推进 + +- 背景:`lastDecisionRef.action=revise/reject` 在用户修订后的质量返工中必须继续有效,但仅凭该历史指针无法证明当前 `agent.delegate` 选择的是本次 planning session 的当前分支。 +- 决策:不新增用户修订授权字段,也不在 `plan.submit_gdd` 重复遍历 approval receipt/GDD lineage。已有 planning session 创建新 child 时,`repairOfDelegationId` 必须直接等于旧 session 的 `latestDelegationId`;不一致即在 Provider 启动前以 `PLAN_NEEDS_RECONCILIATION` 拒绝。合法用户修订及其后质量返工继续保留 `lastDecisionRef`,成功提交新的 GDD 后仍由 submit successor 清理该指针。 +- 影响范围:`planning_coordinator.rs` continuation 投影门;Fast GDD 技术方案第 8.2 节和提交步骤;不改变静态委派通用返工合同或 `PlanSessionV1` schema。 +- 验证方式:新增当前游标 continuation 正向/旧 delivery 负向回归;CI 继续验证首次伪造 `user_revision` 拒绝、用户修订后质量返工提交成功及现有澄清/返工 lineage。 +- 关联文档:`docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md`、`apps/ai-game-creator-shell/src-tauri/src/agent/runtime_protocol/planning_coordinator.rs`。 + +## 2026-08-27 退款 emergency spool 容量溢出保持可恢复 ## 2026-08-27 退款 emergency spool 容量溢出保持可恢复 - 背景:本机 emergency spool 仅作为 SpacetimeDB 完全不可达时的最后恢复路径,原有 `MAX_BYTES` 分支会直接返回 `Dropped`,导致扣费已经完成但没有可重放记录。 @@ -48,6 +65,15 @@ - 验证方式:核对 CLI 版本和 commit,重新生成 Rust bindings,运行 `npm run check:spacetime-schema`、相关 Cargo check / tests、server provision 工具测试、dev 调度测试、encoding 和 diff 门禁。 - 关联文档:`docs/【后端架构】server-rs与SpacetimeDB数据契约-2026-05-15.md`、`docs/【开发运维】本地开发验证与生产运维-2026-05-15.md`。 +## 2026-08-26 Fast GDD 修订后先取证再允许再次委派 + +- **现象**:GDD v1 经用户选择“修改”后,策划子 Agent 正确提交 v2,但 plan 根 Supervisor 的 `Delegated` 阶段仍同时广告 `agent.delegate` 与审批前置工具;模型可能在 Acceptance Graph 重新取证前重复创建修订 delivery,随后被 `PLAN_PROVIDER_USAGE_DEFERRED` 拦停。 +- **决策**:plan 根阶段增加轻量的 `AwaitingAcceptanceEvidence` 状态。当前根最新 GDD 无 approval receipt/pending、session `latestSubmittedRef` 精确指向该提交、delivery 已由根认领且 Acceptance Graph 返回 `NeedsEvidence` 时,只广告 `file.read`、`agent.acceptance_update`、`agent.run_status`;只有用户真正对最新审批卡选择修改/退回后,才恢复 `agent.delegate`。 +- **边界**:不放宽 Provider usage 门禁,不重构 delegation/repair lineage,不自动生成证据或审批 pending;审批 pending 仍只由既有 acceptance gate 在 `agent.acceptance_update` 成功后创建。 +- **验证**:新增一条阶段工具面回归,并通过 15 条 M1C-2a acceptance gate 定向测试、plan root 原生工具目录测试、`cargo check --all-targets`、格式与 diff 检查。 +- **锁边界修正(2026-08-27)**:阶段判定拆为 `plan_root_supervisor_stage_at_locked` 与负责取得一次项目锁的外层入口;Provider tool-plan builder 已持有项目锁时直接复用 locked 入口。Acceptance Evidence 判据和阶段工具面不变,禁止在持锁调用链中再次获取 `.agent/project.lock`。 +- **回归验证**:planning submit 定向测试 68 passed、Provider request builder 定向测试 17 passed、Tauri `cargo check` 与 `cargo fmt --check` 通过。 + ## 2026-08-24 AGC Direct 媒体能力只通过客户端语义工具开放 - 背景:资源页已经补齐视频、角色动画、音效和背景音乐的 create/derive 能力,但 Direct Codex 只能准备标准美术包,无法查询已登记源资源或表达新增媒体意图。直接开放 Tauri invoke 会把项目路径、revision、operation、幂等键、登录态和事务权力交给模型。 @@ -7769,6 +7795,20 @@ CI 上 `background_agent_runtime_recovers_stale_running_before_pending_task` 在 - 安全:DirectProject 使用真实 `game/` writable root、`approvalPolicy=never`,原生命令网络保持关闭,联网资料继续走受控 `agc_web_search`;Codex 子 Agent、Apps、插件、hooks、图片生成、Goals、Workspace Dependencies、Tool Suggestion 与未审计浏览器/电脑控制继续关闭。配置了 AGC LLM Key 或可解析的 `OPENAI_API_KEY` 登录态时,真实 provider 凭据只留在 AGC 本地代理;前者仍走已配置上游,后者只走 OpenAI 官方 API,Codex 仅获得连接级随机代理令牌。无法安全代理的 OAuth `auth.json` 继续关闭原生 shell/unified exec。app-server 使用隔离 `CODEX_HOME`,shell 用 `shell_environment_policy` glob 排除 provider key、proxy、loopback bridge 和受控开关。 - 上下文:Direct 系统提示词只保留身份、cwd、边界和 Skill 索引;不再预注入项目源码快照、项目提示词或 Skill 正文。浏览器工具回传结构化事实,不强制固定三次整改循环;Codex 自行解释证据并决定是否继续。sandbox writableRoots 不提供 deny-read,`.agent`/`../assets` 的不可读约束需靠行为合同和真实 smoke 验证。 +## 2026-08-27 GDD 修改后历史 receipt 不得污染当前审批恢复 + +- 现象:GDD“修改”已成功生成下一版本且当前 pending 身份正确,但 hydrate 持续返回 `recoveryPending=true`,审批卡显示“审批状态正在恢复”。 +- 原因:恢复扫描会重放全部历史 approval receipt;旧版本 receipt 仍拿当前单例 approval pending 做 identity 比对。修改后当前 pending 已属于新版本,旧 receipt 的 identity 不同是正常状态,却被误记为投影缺口。 +- 决策:receipt 的 index、Markdown、audit、submit observation、session 等投影继续允许全量恢复;approval pending 只由 lineage 最新 GDD 的 receipt 读取、更新和清理。历史 receipt 不得检查或改写当前 pending,也不得因此提升 `recoveryPending`。 +- 审批意见消息按 receipt 的 `rootRunId` 解析到原 Supervisor task 所属会话恢复;不会按当前 active session 重新路由。已存在于归档会话的幂等消息允许重放且不新增消息,缺失消息仍保持恢复失败,不静默写入其他会话。 +- 验证:沿用现有审批恢复与 planning submit 定向测试;未新增独立测试,避免为非代表性 fixture 引入额外状态构造。 + +## 2026-08-27 审批修订以最新用户意见更新 GDD 决定快照 + +- `decisions` 表示当前 GDD 版本的决定快照,不再作为新提交必须逐项复制的 session 历史前缀。审批修订可以修改、推翻、删除或新增决定;Runtime 只校验结构、身份、CAS、版本和原型验证项双射,不做自然语言修改范围门禁。 +- 新增 `answerSource=user_revision`,用于标记来自审批修改意见的当前决定,按 `round=0` 记录;`default` 仍只表示未提问的默认建议,澄清来源仍使用 `user_option` / `user_freeform`。 +- planning Prompt 约束为:以当前 GDD 为基线,仅修改用户意见明确涉及的内容及保持内部一致性所必需的派生内容,未涉及内容保持不变;意见与旧决定冲突时以最新意见为准。 + ## 2026-08-24 AGC UI 原型桥接与自主 UI workflow - 决策:`ui-prototype` 图片与 `UI` JSON 编辑资源保持两种正式类型。Agent 通过受控 `ui.workflow.run` 按 `prepare -> recognize -> status -> finalize` 创建页面资源、关联源图、持久化 UI State 和 manifest 阶段;`recognize` 直接复用 UI Editor 的 provider-backed 结构识别、多树合并与组件绑定命令,按 `reference-ready -> structure-ready -> merge-ready -> binding-ready` 逐阶段写入并推进项目 revision。页面可显式关联已登记图片/图标和字体,图片/图标按 5 项一批绑定,字体安全元数据进入绑定上下文且未知引用失败关闭。Runtime 回执携带 `revisionAdvanceCount`;Provider 未配置、请求失败、工具调用缺失、结果不匹配、未产出可渲染组件或仍有待审节点时保留最近真实阶段,禁止用 deterministic seed 冒充语义处理完成。 diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index 004fda8de..bdf1d0eb5 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -2,6 +2,27 @@ > 当前口径:本文件保留可复用的排障经验;历史条目的旧路由、旧版本和已删除文档仅作根因背景,不得据此恢复退役入口。当前命令、路由和 schema 以代码与 `docs/README.md` 为准。 +## 2026-08-27 Provider 成功 handoff 失败时需要保留本地私有原始响应 + +- **现象**:Provider 已返回响应,但 tool-plan handoff 因绝对路径或其它内容安全校验失败,Runtime 只留下 `failureKind`、哈希和被压平的 JSON pointer;排障时无法确认实际工具名和完整 arguments。 +- **处理**:项目 `.agent`、Agent DB 和公共 event 继续只写安全摘要;额外在应用私有配置目录的 `diagnostics/provider-reconciliation//.json` 保存本次响应、tool calls 和校验错误,供本机人工排障。该文件不参与恢复/重试、不复制到项目、不进入 Git,单文件限制 1 MiB,写入失败不改变 reconciliation 语义。 +- **排查顺序**:先读 Runtime 状态里的 `localDiagnostic` 相对引用,再在应用私有目录读取诊断,核对 requestId、requestSlot、tool name 和失败 pointer;不要为了取得原文而放宽 handoff 的安全门。 + +## 2026-08-27 阶段判定不能在持锁的 Provider builder 中再次获取项目锁 + +- **现象**:GDD 修订取证阶段新增后,重新启动策划时前两步表面成功,但父 Supervisor 在收到 `project-planning` 回执、生成下一轮工具计划时失败:`项目正在被其他写操作占用:$PROJECT_ROOT\\.agent\\project.lock`。 +- **原因**:`provider_tool_plan` 在构建请求前已持有 `.agent/project.lock`;`plan_root_supervisor_stage_at` 又调用会自行取锁的 Acceptance Evidence 包装入口。同一进程的文件锁不可重入,持锁调用被误判为外部竞争,等待约 10 秒后失败。问题与 Provider、代理端口或 GDD 内容无关。 +- **处理**:所有需要一致快照的状态读取保留在项目锁内;阶段判定提供明确的 `*_locked` 内部入口,外层入口仅供未持锁调用方取得一次锁。Provider builder 显式接收并校验当前锁后调用 locked 阶段判定,不引入可重入锁,也不移除 Acceptance Evidence 门禁。 +- **排查顺序**:先看失败 Run 的事件顺序是否为 `delegate receipt ready → 生成工具计划 → 阶段判定项目锁失败`,再检查调用方是否已持有 Provider plan project lock;不要因为错误文案包含“其他写操作”就先扩大锁等待或放宽 Provider usage。 +- **验证**:`cargo check`、`cargo fmt --check`、planning submit 68 passed、Provider request builder 17 passed;阶段测试同时覆盖未持锁包装入口和持锁 locked 入口。 + +## 2026-08-26 GDD 新版本提交后不能沿用“已有委派”工具面 + +- **现象**:`plan_root_supervisor_stage_at` 只按是否存在 delivery 判定 `Delegated`。用户修订产生的新 GDD 仍未完成当前根 Run 的 `file.read → agent.acceptance_update` 取证时,模型会看到 `agent.delegate`,可能重复派发同一条策划链。 +- **原因**:自然语言 playbook 已规定“证据不足先取证、用户修改后才返工”,但阶段工具白名单没有把这条 durable 状态固化。 +- **处理**:阶段判定复用现有 acceptance gate 的 GDD/session/delivery/graph identity 检查,增加无副作用的 `AwaitingAcceptanceEvidence` 阶段;`PLAN_PROVIDER_USAGE_DEFERRED` 保持 fail-closed,不通过放宽 Provider 使用量门禁解决。 +- **排查顺序**:先看最新 `gdd.vN.json`、`session.latestSubmittedRef`、delivery 是否 `ClaimedByParent`,再看 Acceptance Graph 是否 `NeedsEvidence`;若仍可见 `agent.delegate`,优先检查 plan root 阶段快照,而不是修改 acceptance gate 或 Provider 门禁。 + ## 2026-08-15 把校验往链路前面挪,改的不是严格程度而是作用域 - 现象:CI 全量 5 条失败,看上去毫不相干(两条 Goal 续跑停在 `needs-reconciliation`、一条交接用例断言错误文案、一条恢复用例把不可读 state 的错误抛了出来、一条 Linux-only 用例错误码对不上),实际只有 3 个根因,且三者是**同一个形状**:新增或既有的检查被放在了链路更靠前的位置,于是它的语义作用域被悄悄放大或提前,而不是「变严」。 @@ -4940,6 +4961,12 @@ - 部分旧包补充:rollback 的规范图/背景图必须保存旧字节与旧 manifest entry,不能把这两项缺失隐式当成空内容;显式 `regenerate` 因此只在这两项可信可回滚时开放。历史主图集、私有回执、公开清单或 canonical 切片可以缺失,但八个严格路径与受管顶层 asset identity 必须逐项冻结其真实 `Present/Some` 或 `Missing/None` 状态,补偿也必须恢复相同存在性。不要因为旧美术包缺切片而阻断重生成,也不要把本轮新建的严格文件误记成旧文件。 - 对话扫描与 claim 补充:历史中出现 `User A / User B / Assistant B` 时,B 已回答不代表 A 已回答,扫描必须继续寻找 A。成功 Direct 回复在 Rust 返回前已经落盘,前端冗余 append 失败不能据此重跑;普通错误回复的显式落盘失败时,恢复 claim 要保持到 React fallback writer 的同一 messageId append 明确收敛。writer 成功或明确失败后才释放;失败路径要停止该消息的自动迟到重试,再由显式 `/history` 复用原 stable turn。终态后及时删除 claim,避免 Set 无界增长。 +## GDD 历史审批回执误触发当前恢复提示(2026-08-27) + +- 现象:修改 GDD 后新版本标题和内容已正确落盘,但审批卡一直显示“审批状态正在恢复”。 +- 原因:`approval pending` 是当前 lineage 最新 GDD 的单例投影;恢复扫描却让每个历史 receipt 都拿它做 identity 比对。旧 receipt 与新 pending 不同并不表示损坏。 +- 处理:历史 receipt 只修复自身投影;只有最新 GDD 的 receipt 才能校验、更新或清理当前 approval pending。不要在前端隐藏 `recoveryPending`,也不要取消最新版本的 identity fail-closed 检查。 + ## Native shell CI 不能在测试阶段重新解析 Cargo registry(2026-08-26) - 现象:原生壳 job 的依赖预取成功后,AGC 检查仍在 `platform-llm` 测试阶段重新更新 registry index,并因 `symphonia` 下载的 TLS EOF 失败。 diff --git a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md index 4a255f578..08be69b7d 100644 --- a/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md +++ b/docs/technical/【技术方案】AI游戏创作Agent Runtime V1.1-2026-07-12.md @@ -1482,6 +1482,8 @@ npm run ai-game-creator-shell:agent-runtime:supervisor-swarm-final-reply-transie V1.43 不放宽 V1.41 的文本型 `game-creator-provider-handoff.v1`,而是为 `requestKind=tool-plan` 增加独立私有账本 `.agent/runtime/tool-plan-handoffs//.json`,schema 固定为 `game-creator-tool-plan-handoff.v1`。同一 Agent/run 账本按 `(loopIteration, repairAttempt)` 单调保存已成功的 `repair-0..N` Provider 响应,每条绑定完整 retry identity、实际物理 `providerRequestId`、真实 request slot/attempt、Provider/model、去除 thinking 后的响应、thinking 归一化哈希/计数、完整 function call envelope、usage、响应指纹和创建时间。账本使用既有 `0600`、原子替换、父目录同步、`.previous` 恢复和写后完整回读;未知字段、乱序/缺口、重复 slot 冲突、超限、危险可执行路径、密钥或配置痕迹一律失败关闭。 +当成功响应因 handoff 校验失败而进入 `needs-reconciliation` 时,Runtime 额外在应用私有数据目录的 `diagnostics/provider-reconciliation//.json` 写入一次本地诊断。该诊断只服务人工排障,不参与恢复、重试或业务状态判断,可保留本次 Provider 响应、tool call arguments 和原始校验错误;项目 `.agent`、Agent DB、公共 event、CLI 与报告只保留安全摘要及该私有诊断的相对引用。诊断文件限制为 1 MiB,使用原子写入;应用配置目录不可用或诊断写入失败时,不改变既有 fail-closed reconciliation 语义。 + ### 提交、重放与所有权 - 每个 tool-plan 物理请求的顺序固定为:Provider 成功 -> tool-plan handoff 追加并回读 -> 同一实际 requestId lifecycle `completed` -> 解析/格式修复或动作预检。function arguments 只存在于私有 handoff 与后续 pending/action batch。protocol/repair 公共审计共同保存 `agentId/taskId/sessionId/runId/source/loopIteration/repairAttempt/requestSlot/responseFingerprint/providerRequestIdSha256/protocol`;protocol 只额外保存 `functionCallCount/callIdSha256s/functionNames/responseIdSha256/responseIdChars` 和既有 normalization 字段,其中 function names 必须由 catalog 绑定;repair 只额外保存 attempt/maxAttempts、协议错误/preview 哈希与字符数及 `callIdSha256/functionNameSha256`。公共 task、event、Agent DB、CLI 和报告不得保存原始 callId/callIds/responseId/providerRequestId。两类审计都在 Agent DB append 锁内按完整 Agent/task/Session/run/source/slot 身份做全历史 compare-and-append,不能以受限尾部读取替代幂等。 diff --git a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md index 4d4fd681f..b48a05c30 100644 --- a/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md +++ b/docs/technical/【技术方案】立项策划Agent(Fast GDD)-2026-08-10.md @@ -173,7 +173,7 @@ D9/D10 描述的「manifest ready-task 调度器在 Supervisor 下游启动策 | hydrate read-model schema | `plan-gdd-state-view.v1` | | GDD 状态 | `draft \| ready_for_approval \| revision_requested \| approved \| rejected \| superseded` | | 单项决定状态 | `confirmed \| default_pending \| prototype_pending` | -| 回答来源 | `user_option \| user_freeform \| default` | +| 回答来源 | `user_option \| user_freeform \| user_revision \| default` | | 审计 recordType | `agent.runtime.plan.gdd_decided` | | planning typed 指纹文本 | `sha256-serde-json-v2:<64 位小写十六进制>` | | 现役 action/profile binding digest | `<64 位小写十六进制>`,无前缀 | @@ -402,7 +402,7 @@ Runtime 注入并强校验以下精确结构: - **轮次计数与上限**:本轮是第几轮由委派链上的 `clarification_round` 派生值决定(沿 `repair_of_delegation_id` 上溯推断,见第 23.5 节),上限 3;不再由 session 自行累加 `roundsUsed`。session 仍是 decisions 数组与 GDD 草稿内容的权威,但**不再是轮次状态机的权威**。 > **随之而来的合同影响 —— 2026-08-13 已全部收口。** 第 3 节注册表的 `plan-decision-checkpoint.v1` 与 request kind、第 8.6 节 `plan-session.v1` 的 `activeQuestion` / `roundsUsed` / `supersededCheckpointHandoffs`、第 9 节的 checkpoint domain 与 `supersededCheckpointProviderRequestIds`、第 12 节的 checkpoint stale 状态机、第 14 节与 activeQuestion 相关的恢复行,均已随本节重写一并删除或改写;第 9.1 节 golden vector 已按新 identity 重新生成(3857 bytes,`a59856de7e…`)。 -- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。 +- 用户明确输入优先于 Agent 默认;默认建议必须标为 `default_pending`,手感、节奏、镜头、可读性或重玩差异等需要验证的结论标为 `prototype_pending`。审批阶段的用户修改意见使用 `answerSource=user_revision`、`round=0`;Runtime 仅在 session 带 revise/reject 的 `lastDecisionRef` 时接受该来源。 - session revision 1 由 Runtime 先写入固定 `initial-request` 决定:topic=`初始需求`、state=`confirmed`、answerSource=`user_freeform`、round=0、answerSummary 精确等于规范化后的 1~400 scalar 初始用户需求。Provider 不能改写或省略这条来源记录;超过上限的初始输入先要求用户收束,不能截断。 ### 5.2 决策卡 @@ -663,7 +663,7 @@ Provider 只能提交设计内容,不能提交或覆盖任何 Runtime 身份 该 input 及所有嵌套类型都使用 `deny_unknown_fields`;`game.platformFacts`、任意 `basis`、`projectId/gddId/version/submissionId/approvalRequestId`、action/run/session identity、时间与任何 fingerprint 一旦出现在 Provider input 中即返回 `PLAN_INVALID_REQUEST`。Runtime 在发出本轮 Provider request 前把当前 `sessionRevision/sessionFingerprint` 绑定进内部执行上下文,在项目锁内验证该 CAS 后,才把 project、GDD、版本、durable action、source/profile、session/run、时间、固定 `platformFacts`、全部 `basis:null` 与 fingerprint 注入 `plan-gdd.v1`。字段数量和文本限制按第 8.3 节对应 durable 字段执行。 -input 中必须逐项包含并精确等于 source session 的全部 `decisionsSummary` 和 `prototypeValidationItems`,不得改变决定的 id/topic/state/answerSource/round/answerSummary,也不得改变原型项正文或顺序;每个已提问决定因此具有可验证的 1~3 轮来源。额外 decision 只允许是未提问默认:`default_pending + default + round=0`,且不能为它伪造 prototype item。唯一允许的 `confirmed + user_freeform + round=0` 是 Runtime 创建的固定 `initial-request`,其 answerSummary 精确等于初始用户需求。直接出稿因此可以合法使用 `roundsUsed=0`,但仍至少提交该初始 decision。 +input 是当前 GDD 的完整快照,不要求与 source session 的 `decisionsSummary` 和 `prototypeValidationItems` 逐项相等。审批修订可用 `user_revision + round=0` 修改、删除或新增决定;未涉及内容由 Agent 以当前 GDD 为基线保持不变。Runtime 仍校验决定结构、原型项双射、`initial-request` 首项、身份和 CAS。payload 出现 `answerSource=user_revision` 时,当前 session 的 `lastDecisionRef.action` 必须是 `revise` 或 `reject`;首次提交、澄清续跑和没有待处理用户修订的普通质量返工返回 `PLAN_INVALID_REQUEST`。用户修订周期内的质量返工可以继续携带 `user_revision`,但其 planning child 必须直接继承当前 session 的 `latestDelegationId`,不得从旧 delivery 另起分支。该闸只作用于新版本 create,同 `submissionId` replay 不重判。唯一固定的 `confirmed + user_freeform + round=0` 是 Runtime 创建的 `initial-request`。 ### 8.3 `plan-gdd.v1` @@ -1152,15 +1152,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc 4. Provider transient failure/物理中断但 session、context 和 request slot 未变时,才沿用同一 base ID 的 attempt 派生规则。已知 retryable transport/upstream failure 先把旧 attempt durable 闭合为 `failed`;Runner/进程恢复只有在 boot/owner/lease 证据证明旧物理请求不再存活且无 handoff/batch 时,才闭合为 `interrupted`。旧终态写入、同步并回读成功后,才能创建 attempt N+1 的新 `started`;不能原地复用同一 providerRequestId,也不能让两个 started attempt 并存。无法证明旧请求已终止时进入 recovery required,不自动重发。每个 attempt 始终有独立 `started → completed|failed|interrupted` lifecycle。 5. 除第 1~2 项明确允许的同 binding `started + ready batch` 崩溃组合,以及上文 delivery 问题落盘/答案绑定的已消费证明外,binding 缺失/损坏、lifecycle 与 batch 不一致、同 revision 下 requestContextFingerprint 漂移、session 不是合法 successor,或 batch 已进入执行/等待状态时返回 `PLAN_NEEDS_RECONCILIATION`。此路径不自动删除、不补默认 binding、不重绑、不重试。 -严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST`、`PLAN_SESSION_DECISIONS_MISMATCH`(2026-08-21 补,见下)或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 - -**(2026-08-21)台账逐项比对失败从 `PLAN_SESSION_CAS_CONFLICT` 拆出为 `PLAN_SESSION_DECISIONS_MISMATCH`,并纳入上述可重试分类。** 第 8.2 节「input 必须逐项包含并精确等于 source session 的 `decisionsSummary` 与 `prototypeValidationItems`」这条校验(实现为 `planning_submit.rs` 的 `session_decisions_match_input`)原先与三条真 CAS 判据(`sessionRevision` 溢出、session 已被其它动作推进、Runtime source revision/fingerprint 无效)共用一个错误码,因此被 `plan_submit_error_is_business_rejection` 漏掉,一次不匹配即 `needs-reconciliation` 硬阻断整个策划子 Agent。 - -两者性质本就不同,按本节自己的判据即可区分:真 CAS 说明 **durable 权威**已变或已坏,重交同一份 input 不可能成功;台账不匹配时权威完好,错的是**本次 Provider input**——策划子 Agent 把决策摘要抄漏、抄错,或多追加了一条非 `default_pending` 决定。后者正是本节划归「本次 Provider input / 候选 GDD」的那一类。 - -**不变量未放松**:不匹配照样拒绝、照样不产生任何事实,只是拒绝的后果从「叫人核对」变成「回灌 rejected observation 让 Provider 改稿」,仍受同一个 5 次 durable 预算约束,第 5 次照常终态失败。伪造用户确认(追加 `confirmed + user_option`)等第 8.2 节禁止的写法一条都没有变得可行。 - -**触发这次拆分的实测**:策划子 Agent 连续三次 submit 撞形状层(`PLAN_INVALID_REQUEST`),每次都按回灌的理由改对一部分——机制运转正常;第四次形状终于合法,随即撞上台账比对这一支,直接 `needs-reconciliation`,整条链路零产物收场。即**越接近提交成功越容易撞上不给重试的门**,这与「5 次预算让 Provider 自行收敛」的设计意图直接冲突。 +严格 submit input 被 Runtime 以 `PLAN_INVALID_REQUEST` 或由该输入导出的候选 GDD `PLAN_SIZE_LIMIT` 拒绝时,当前策划子 run 最多产生 **5 次** `plan.submit_gdd / rejected` observation:前 4 次关闭原 sole-action batch 后可在同一 run 续跑,让 Provider 根据最后一条 observation 修正;第 5 次仍须先完整落盘 rejected observation,再把该 run 终态失败,**不得**请求第 6 次 Provider tool-plan。该分类只针对本次 Provider input / 候选 GDD;读取既有不可变 GDD 或 receipt 时出现同名大小上限、既有 lineage 已达版本上限,或任何其它 durable authority 异常,一律是 `PLAN_NEEDS_RECONCILIATION`,不得消耗 Provider 重试额度。计数是 Runtime state 的 durable、每个 child run 独立的字段,进程重启不能清零;只有新建的策划 child run 才从 0 开始。它不依赖前端、Prompt 文字或 Provider 自报,且普通工具 observation 不计入。 第 2 项的自动前滚必须与 session successor、batch supersede/cleanup 和 replacement request 的 started 写入都在项目锁内按幂等步骤恢复;任一断点重启后只能继续相同步骤。这样合法 steer 能确定性替换旧输出,而身份污染不会被“自动恢复”掩盖。 @@ -1169,7 +1161,7 @@ GDD handler 只能从已验证 batch binding 复制 `sourceSessionRevision/sourc main loop 不能把 submit 当成普通 action dispatch:在 durable action identity 建立后、生成普通 command ID 或进入 action executor 前,必须进入 `plan.submit_gdd` 专用分支。该分支重验 exact plan identity,执行下列提交与投影。**(2026-08-14 按 M1B-2 实现边界收口)** 本包只负责校验、定版、写不可变 GDD、重建 index、渲染 `game/fast_gdd.md`、安装 session successor 并终止策划子 run;**不创建 `.agent/planning/pending.json` / `gdd-approval` planning pending,不创建审批卡,也不把 Supervisor 或策划子 run 投影为审批等待**。`gdd-approval` pending 与 Supervisor 等待态属于 `M1C-1`,还要受第 13.0 节 `M1C-2a` 验收取证门约束。原 submit 在进入专用分支前已经建立的 generic `game-creator-pending-action.v5` standalone pending 与 `game-creator-provider-action-batch.v4` action batch 必须原样保留,作为后续 receipt/terminal observation 的同 action 恢复锚点;GDD create 成功不等于该 action 已 observed。 1. 解析第 8.2 节 strict input;在项目锁内重读 project identity、策划子 run 与委派根身份、Provider request 所绑定的 session CAS、canonical GDD 链及原 submit 的 generic v5 standalone pending / v4 batch anchors。不信任 Provider payload 中不存在也不允许出现的版本、时间、平台事实或身份;M1B-2 不读取或创建尚未实现的 approval receipt / planning pending。 -2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 必须先逐项等于 source session 的完整决定前缀,前缀之后只允许追加 `state=default_pending + answerSource=default + round=0` 的未提问默认决定,任何伪造为用户已确认的额外决定都按 session CAS 冲突拒绝。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 +2. 验证文本上限、轮次、决定状态和 prototype item 一一对应;`decisions` 按本次完整 GDD 快照校验,不与旧 session 内容逐项比较。`round=0` 的非首项决定只能是 `answerSource=default`(默认建议)或 `answerSource=user_revision`(审批修改),分别对应允许的状态集合。`user_revision` 还要求当前 session 已有 `lastDecisionRef.action ∈ {revise, reject}`;没有该引用时不得把未确认项标成审批修改。已有 session 创建新的 planning child 时,`repairOfDelegationId` 必须精确等于旧 session 的 `latestDelegationId`;这条 continuation 游标约束在 Provider 请求前生效,防止旧 delivery 重新成为当前分支。Runtime 注入固定 platformFacts 和所有 `basis:null`,以当前 durable actionId/裸 action fingerprint 作为 submission identity。 3. M1B-2 尚无 receipt writer:只要已有任一 GDD,新的不同 submissionId 就返回 `PLAN_PENDING_GDD_EXISTS`;同 submissionId 只允许按历史 binding replay。`M1C-1` 接入有效 approve/revise/reject receipt 后,才把边界扩为“最新版本已有 receipt 才允许下一版本”。 4. 当前 M1B-2 的首次版本固定为 1;未来版本仍只能取最后一个连续有效版本加一,范围 1~128,不允许缺号或扫描任意文件补号。 5. 新提交由 Runtime 生成并冻结 `approvalRequestId/createdAtUtc`,填充全部 durable identity、source session binding 和时间,计算 GDD fingerprint,以第 10.1 节算法 create-only 发布 `gdd.v{N}.json`。同 submissionId replay 必须先找到并严格读取既有 GDD,复用其中 Runtime 生成的版本、request/time 与 identity 后再比较,不能用新时间制造假冲突。 @@ -1449,7 +1441,6 @@ type PlanGddError = { | 'PLAN_UNSUPPORTED_KNOWLEDGE_BASIS' | 'PLAN_CORRUPT_AUTHORITY' | 'PLAN_SESSION_CAS_CONFLICT' - | 'PLAN_SESSION_DECISIONS_MISMATCH' | 'PLAN_SESSION_RECOVERY_REQUIRED' | 'PLAN_NEEDS_RECONCILIATION' | 'PLAN_DURABILITY_FAILED' diff --git a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md index 064bae248..eaca00df3 100644 --- a/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md +++ b/docs/【开发运维】本地开发验证与生产运维-2026-05-15.md @@ -368,7 +368,7 @@ npm run check:spacetime-schema `rustfmt.toml` 固定 Edition 2024 的格式化口径。Rust 源码统一使用 `cargo fmt --all --manifest-path server-rs/Cargo.toml` 格式化,并用 `npm run check:rustfmt` 做只读校验;Codex 提交前门禁、API 生产构建和 -SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查;SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地说明中。 +SpacetimeDB module 生产构建都会执行同一检查,避免不同开发机或构建节点反复产生格式差异。Web 生产构建还会执行 production-ops、ESLint、主站与后台类型检查,以及排除已下线旧玩法后的当前 Vitest;API 生产构建追加 production-ops、DDD/schema/runtime-access 和 api-server 全 target 编译检查;SpacetimeDB module 生产构建追加 production-ops、DDD/schema/runtime-access 和管理员 procedure smoke。上述门禁由 `npm run check:production-ops` 反查,不能只保留在本地说明中。对需要跨格式保持稳定的脚本片段,门禁按去除空白后的源码片段匹配,避免仅因换行或格式化差异误报。 ## 前端改动验收 diff --git a/scripts/check-database-backup-to-oss.mjs b/scripts/check-database-backup-to-oss.mjs index 689295ca6..b42f5dbfc 100644 --- a/scripts/check-database-backup-to-oss.mjs +++ b/scripts/check-database-backup-to-oss.mjs @@ -1,12 +1,24 @@ #!/usr/bin/env node -import {spawnSync} from 'node:child_process'; -import {createHash} from 'node:crypto'; -import {chmodSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, statSync, symlinkSync, writeFileSync} from 'node:fs'; -import {tmpdir} from 'node:os'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readlinkSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; import path from 'node:path'; -import {Readable} from 'node:stream'; -import {gunzipSync, gzipSync} from 'node:zlib'; +import { Readable } from 'node:stream'; +import { gunzipSync, gzipSync } from 'node:zlib'; import { buildAuthorization, @@ -27,13 +39,15 @@ import { } from './database-backup-to-oss.mjs'; const BACKUP_SCRIPT = path.resolve('scripts/database-backup-to-oss.mjs'); -const tmpRoot = mkdtempSync(path.join(tmpdir(), 'genarrative-database-backup-check-')); +const tmpRoot = mkdtempSync( + path.join(tmpdir(), 'genarrative-database-backup-check-'), +); const failures = []; try { await main(); } finally { - rmSync(tmpRoot, {recursive: true, force: true}); + rmSync(tmpRoot, { recursive: true, force: true }); } if (failures.length > 0) { @@ -78,80 +92,149 @@ async function main() { function assertDeferredArchiveDiscoveryIsBoundedAndDeterministic() { const root = path.join(tmpRoot, 'deferred-archive-discovery'); - mkdirSync(root, {recursive: true}); - const createCandidate = ({name, status, database = 'test-db', withArchive = true}) => { + mkdirSync(root, { recursive: true }); + const createCandidate = ({ + name, + status, + database = 'test-db', + withArchive = true, + }) => { const archivePath = path.join(root, `${name}.tar.gz`); const manifestPath = `${archivePath}.manifest.json`; if (withArchive) { writeFileSync(archivePath, name); } - writeFileSync(manifestPath, `${JSON.stringify({ - backupKind: 'spacetimedb-data-dir', - database, - archivePath, - uploadStatus: status, - })}\n`); - return {archivePath, manifestPath}; + writeFileSync( + manifestPath, + `${JSON.stringify({ + backupKind: 'spacetimedb-data-dir', + database, + archivePath, + uploadStatus: status, + })}\n`, + ); + return { archivePath, manifestPath }; }; - const later = createCandidate({name: 'test-db-20260731T020000Z', status: 'pending'}); - const earlier = createCandidate({name: 'test-db-20260731T010000Z', status: 'deferred'}); - const uploaded = createCandidate({name: 'test-db-20260731T000000Z', status: 'uploaded'}); - createCandidate({name: 'other-db-20260731T000000Z', status: 'deferred', database: 'other-db'}); - const missing = createCandidate({name: 'test-db-20260730T230000Z', status: 'deferred', withArchive: false}); + const later = createCandidate({ + name: 'test-db-20260731T020000Z', + status: 'pending', + }); + const earlier = createCandidate({ + name: 'test-db-20260731T010000Z', + status: 'deferred', + }); + const uploaded = createCandidate({ + name: 'test-db-20260731T000000Z', + status: 'uploaded', + }); + createCandidate({ + name: 'other-db-20260731T000000Z', + status: 'deferred', + database: 'other-db', + }); + const missing = createCandidate({ + name: 'test-db-20260730T230000Z', + status: 'deferred', + withArchive: false, + }); - const result = discoverDeferredArchiveUploads({workDir: root, database: 'test-db'}); + const result = discoverDeferredArchiveUploads({ + workDir: root, + database: 'test-db', + }); assertEqual( - result.archives.map(({archivePath}) => archivePath).join(','), + result.archives.map(({ archivePath }) => archivePath).join(','), [earlier.archivePath, later.archivePath].join(','), 'deferred/pending 扫描必须只返回同库现存归档,并按文件名稳定排序。', ); - assertEqual(result.missingArchives.length, 1, '缺失归档的 deferred 清单必须单独报告。'); - assertEqual(result.missingArchives[0].manifestPath, missing.manifestPath, '缺失归档报告必须保留精确 manifest。'); - const cleanupResult = discoverDeferredArchiveUploads({workDir: root, database: 'test-db', includeUploaded: true}); assertEqual( - cleanupResult.archives.map(({archivePath}) => archivePath).join(','), + result.missingArchives.length, + 1, + '缺失归档的 deferred 清单必须单独报告。', + ); + assertEqual( + result.missingArchives[0].manifestPath, + missing.manifestPath, + '缺失归档报告必须保留精确 manifest。', + ); + const cleanupResult = discoverDeferredArchiveUploads({ + workDir: root, + database: 'test-db', + includeUploaded: true, + }); + assertEqual( + cleanupResult.archives.map(({ archivePath }) => archivePath).join(','), [uploaded.archivePath, earlier.archivePath, later.archivePath].join(','), '未要求保留本地归档时,补偿扫描必须同时收敛上传后未清理的本地归档。', ); - const cliDryRun = spawnSync(process.execPath, [ - BACKUP_SCRIPT, - '--upload-deferred-dir', root, - '--database', 'test-db', - '--bucket', 'test-bucket', - '--endpoint', 'oss-cn-shanghai.aliyuncs.com', - '--access-key-id', 'test-id', - '--access-key-secret', 'test-secret', - '--keep-local', - '--dry-run', - ], {encoding: 'utf8'}); - assertStatus(cliDryRun, 0, 'deferred 补偿扫描 dry-run 必须可通过统一 CLI 入口执行。'); - assertIncludes(cliDryRun.stdout, 'count=2', 'deferred 补偿扫描 CLI 必须报告待处理归档数量。'); - assertTrue(existsSync(earlier.archivePath) && existsSync(later.archivePath), 'dry-run 不得删除 deferred 本地归档。'); + const cliDryRun = spawnSync( + process.execPath, + [ + BACKUP_SCRIPT, + '--upload-deferred-dir', + root, + '--database', + 'test-db', + '--bucket', + 'test-bucket', + '--endpoint', + 'oss-cn-shanghai.aliyuncs.com', + '--access-key-id', + 'test-id', + '--access-key-secret', + 'test-secret', + '--keep-local', + '--dry-run', + ], + { encoding: 'utf8' }, + ); + assertStatus( + cliDryRun, + 0, + 'deferred 补偿扫描 dry-run 必须可通过统一 CLI 入口执行。', + ); + assertIncludes( + cliDryRun.stdout, + 'count=2', + 'deferred 补偿扫描 CLI 必须报告待处理归档数量。', + ); + assertTrue( + existsSync(earlier.archivePath) && existsSync(later.archivePath), + 'dry-run 不得删除 deferred 本地归档。', + ); const unsafeRoot = path.join(tmpRoot, 'deferred-archive-unsafe'); - mkdirSync(unsafeRoot, {recursive: true}); + mkdirSync(unsafeRoot, { recursive: true }); const escapedArchive = path.join(tmpRoot, 'outside.tar.gz'); writeFileSync(escapedArchive, 'outside'); writeFileSync( path.join(unsafeRoot, 'test-db-unsafe.tar.gz.manifest.json'), - `${JSON.stringify({database: 'test-db', archivePath: escapedArchive, uploadStatus: 'deferred'})}\n`, + `${JSON.stringify({ database: 'test-db', archivePath: escapedArchive, uploadStatus: 'deferred' })}\n`, ); assertThrows( - () => discoverDeferredArchiveUploads({workDir: unsafeRoot, database: 'test-db'}), + () => + discoverDeferredArchiveUploads({ + workDir: unsafeRoot, + database: 'test-db', + }), '路径与清单不匹配', 'deferred 扫描必须拒绝目录外归档或 manifest 名不匹配。', ); const symlinkRoot = path.join(tmpRoot, 'deferred-archive-symlink'); - mkdirSync(symlinkRoot, {recursive: true}); + mkdirSync(symlinkRoot, { recursive: true }); const symlinkArchive = path.join(symlinkRoot, 'test-db-symlink.tar.gz'); symlinkSync(escapedArchive, symlinkArchive); writeFileSync( `${symlinkArchive}.manifest.json`, - `${JSON.stringify({database: 'test-db', archivePath: symlinkArchive, uploadStatus: 'deferred'})}\n`, + `${JSON.stringify({ database: 'test-db', archivePath: symlinkArchive, uploadStatus: 'deferred' })}\n`, ); assertThrows( - () => discoverDeferredArchiveUploads({workDir: symlinkRoot, database: 'test-db'}), + () => + discoverDeferredArchiveUploads({ + workDir: symlinkRoot, + database: 'test-db', + }), '非符号链接的普通文件', 'deferred 扫描必须拒绝符号链接归档。', ); @@ -165,22 +248,36 @@ function createDirectOssHarness() { const objects = new Map(); const uploadedKeys = []; const verifiedKeys = []; - const uploadFn = async ({archivePath, objectKey, archiveSha256}) => { + const uploadFn = async ({ archivePath, objectKey, archiveSha256 }) => { const body = readFileSync(archivePath); const sha256 = createHash('sha256').update(body).digest('hex'); - assertEqual(sha256, archiveSha256, `direct file ${objectKey} 的上传 SHA 必须来自实际内容。`); - objects.set(objectKey, {body, contentLength: body.length, sha256}); + assertEqual( + sha256, + archiveSha256, + `direct file ${objectKey} 的上传 SHA 必须来自实际内容。`, + ); + objects.set(objectKey, { body, contentLength: body.length, sha256 }); uploadedKeys.push(objectKey); - return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:00.000Z'}; + return { + objectKey, + contentLength: body.length, + archiveSha256: sha256, + verifiedAt: '2026-07-16T01:00:00.000Z', + }; }; - const uploadManifestFn = async ({manifestPath, objectKey}) => { + const uploadManifestFn = async ({ manifestPath, objectKey }) => { const body = readFileSync(manifestPath); const sha256 = createHash('sha256').update(body).digest('hex'); - objects.set(objectKey, {body, contentLength: body.length, sha256}); + objects.set(objectKey, { body, contentLength: body.length, sha256 }); uploadedKeys.push(objectKey); - return {objectKey, contentLength: body.length, archiveSha256: sha256, verifiedAt: '2026-07-16T01:00:01.000Z'}; + return { + objectKey, + contentLength: body.length, + archiveSha256: sha256, + verifiedAt: '2026-07-16T01:00:01.000Z', + }; }; - const verifyFn = async ({objectKey, contentLength, archiveSha256}) => { + const verifyFn = async ({ objectKey, contentLength, archiveSha256 }) => { verifiedKeys.push(objectKey); const object = objects.get(objectKey); if (!object) { @@ -188,12 +285,22 @@ function createDirectOssHarness() { error.status = 404; throw error; } - if (object.contentLength !== contentLength || object.sha256 !== archiveSha256) { + if ( + object.contentLength !== contentLength || + object.sha256 !== archiveSha256 + ) { throw new Error(`mismatch ${objectKey}`); } - return {verifiedAt: '2026-07-16T01:00:02.000Z'}; + return { verifiedAt: '2026-07-16T01:00:02.000Z' }; + }; + return { + objects, + uploadedKeys, + verifiedKeys, + uploadFn, + uploadManifestFn, + verifyFn, }; - return {objects, uploadedKeys, verifiedKeys, uploadFn, uploadManifestFn, verifyFn}; } async function assertDirectSmallFileUsesSinglePut() { @@ -209,13 +316,19 @@ async function assertDirectSmallFileUsesSinglePut() { for await (const chunk of options.body) { uploadedBytes += chunk.length; } - return new Response('', {status: 200, headers: {etag: '"single-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"single-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(body.length), - 'x-oss-meta-file-sha256': sha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(body.length), + 'x-oss-meta-file-sha256': sha256, + }, + }); } throw new Error(`unexpected method ${options.method}`); }; @@ -231,12 +344,20 @@ async function assertDirectSmallFileUsesSinglePut() { bandwidthLimiter: createUploadBandwidthLimiter(64 * 1024), }); assertEqual(result.uploadMode, 'single', '小型逐文件对象必须使用单次 PUT。'); - assertEqual(methods.join(','), 'PUT,HEAD', '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。'); + assertEqual( + methods.join(','), + 'PUT,HEAD', + '小型逐文件对象只能执行 PUT 后 HEAD 验真,不得进入 multipart。', + ); assertEqual(uploadedBytes, body.length, '逐文件带宽限制流不得丢失上传内容。'); } async function assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors() { - assertEqual(createUploadBandwidthLimiter('0'), null, '上传带宽限制为 0 时必须关闭。'); + assertEqual( + createUploadBandwidthLimiter('0'), + null, + '上传带宽限制为 0 时必须关闭。', + ); assertThrows( () => createUploadBandwidthLimiter('1023'), '必须为空、0 或 >= 1024 的整数', @@ -257,120 +378,265 @@ async function assertUploadBandwidthLimiterSharesBudgetAndPropagatesErrors() { return totalBytes; }; const [firstBytes, secondBytes] = await Promise.all([ - consume(limiter.wrap(Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], {objectMode: false}))), - consume(limiter.wrap(Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], {objectMode: false}))), + consume( + limiter.wrap( + Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], { + objectMode: false, + }), + ), + ), + consume( + limiter.wrap( + Readable.from([Buffer.alloc(1024), Buffer.alloc(1024)], { + objectMode: false, + }), + ), + ), ]); - assertEqual(firstBytes + secondBytes, 4096, '共享上传限速器不得丢失并发流内容。'); - assertEqual(delays.join(','), '1000,2000,3000,4000', '两个并发上传流必须共享同一个累计带宽预算。'); + assertEqual( + firstBytes + secondBytes, + 4096, + '共享上传限速器不得丢失并发流内容。', + ); + assertEqual( + delays.join(','), + '1000,2000,3000,4000', + '两个并发上传流必须共享同一个累计带宽预算。', + ); let sourceError = null; try { - await consume(limiter.wrap(Readable.from((async function* failingSource() { - yield Buffer.alloc(1); - throw new Error('source-read-failed'); - })(), {objectMode: false}))); + await consume( + limiter.wrap( + Readable.from( + (async function* failingSource() { + yield Buffer.alloc(1); + throw new Error('source-read-failed'); + })(), + { objectMode: false }, + ), + ), + ); } catch (error) { sourceError = error; } - assertIncludes(sourceError?.message, 'source-read-failed', '限速流必须向上传请求透传源读取错误。'); + assertIncludes( + sourceError?.message, + 'source-read-failed', + '限速流必须向上传请求透传源读取错误。', + ); } async function assertDirectFilesPreservePathsAndIncrementWithoutDuplicateUpload() { const root = path.join(tmpRoot, 'direct-files-incremental'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects'), {recursive: true}); - mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); - mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + mkdirSync( + path.join( + dataDir, + 'replicas', + '1', + 'snapshots', + '00000000000000000010.snapshot_dir', + 'objects', + ), + { recursive: true }, + ); + mkdirSync(path.join(dataDir, 'empty-directory'), { recursive: true }); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), { recursive: true }); symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); writeFileSync(path.join(dataDir, 'control-db'), 'control'); writeFileSync( - path.join(dataDir, 'replicas', '1', 'snapshots', '00000000000000000010.snapshot_dir', 'objects', 'object.bin'), + path.join( + dataDir, + 'replicas', + '1', + 'snapshots', + '00000000000000000010.snapshot_dir', + 'objects', + 'object.bin', + ), 'snapshot object', ); const harness = createDirectOssHarness(); const options = { - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }; - const collected = await collectDirectFileEntries({dataDir, database: 'test-db', objectPrefix: 'database-backups'}); + const collected = await collectDirectFileEntries({ + dataDir, + database: 'test-db', + objectPrefix: 'database-backups', + }); assertTrue( - collected.files.some(({path: filePath}) => filePath === 'replicas/1/snapshots/00000000000000000010.snapshot_dir/objects/object.bin'), + collected.files.some( + ({ path: filePath }) => + filePath === + 'replicas/1/snapshots/00000000000000000010.snapshot_dir/objects/object.bin', + ), 'files catalog 必须原样保留 snapshot 内文件的相对路径。', ); - assertTrue(collected.directories.includes('empty-directory'), 'files catalog 必须保留空目录。'); assertTrue( - collected.symlinks.some(({path: symlinkPath, target}) => symlinkPath === 'bin/current' && target === '2.6.0'), + collected.directories.includes('empty-directory'), + 'files catalog 必须保留空目录。', + ); + assertTrue( + collected.symlinks.some( + ({ path: symlinkPath, target }) => + symlinkPath === 'bin/current' && target === '2.6.0', + ), 'files catalog 必须保留指向 data-dir 内部的相对符号链接。', ); const first = await runDirectFilesBackup(options); assertEqual(first.uploadedCount, 2, '首次 files full 应上传全部普通文件。'); - assertTrue(!Object.hasOwn(first.catalog, 'dataDir'), '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。'); - assertTrue(first.statePath.endsWith('.json.gz'), 'files state 必须使用 gzip 压缩文件。'); + assertTrue( + !Object.hasOwn(first.catalog, 'dataDir'), + '远端 files catalog 不得绑定 staging 主机的绝对 data-dir。', + ); + assertTrue( + first.statePath.endsWith('.json.gz'), + 'files state 必须使用 gzip 压缩文件。', + ); const compactState = readGzipJson(first.statePath); - assertEqual(compactState.schemaVersion, 2, 'files state 必须使用去重后的 v2 契约。'); - assertTrue(!Object.hasOwn(compactState.baselineCatalog, 'files'), 'baseline ref 不得重复嵌入 files。'); - assertTrue(!Object.hasOwn(compactState.latestCatalog, 'files'), 'latest ref 不得重复嵌入 files。'); - assertTrue(!existsSync(first.catalogPath), '本地 full catalog 原始 JSON 应在成功后压缩。'); - assertTrue(existsSync(`${first.catalogPath}.gz`), '本地应保留压缩后的 latest full catalog 供增量复用。'); + assertEqual( + compactState.schemaVersion, + 2, + 'files state 必须使用去重后的 v2 契约。', + ); + assertTrue( + !Object.hasOwn(compactState.baselineCatalog, 'files'), + 'baseline ref 不得重复嵌入 files。', + ); + assertTrue( + !Object.hasOwn(compactState.latestCatalog, 'files'), + 'latest ref 不得重复嵌入 files。', + ); + assertTrue( + !existsSync(first.catalogPath), + '本地 full catalog 原始 JSON 应在成功后压缩。', + ); + assertTrue( + existsSync(`${first.catalogPath}.gz`), + '本地应保留压缩后的 latest full catalog 供增量复用。', + ); const latestObjectKey = 'database-backups/test-db/latest.json'; - const latest = JSON.parse(harness.objects.get(latestObjectKey).body.toString('utf8')); - assertEqual(latest.latestFullCatalog.catalogId, first.catalogId, 'latest pointer 必须指向已验真的最新 full catalog。'); - assertTrue(!Object.hasOwn(latest.latestFullCatalog, 'files'), 'latest full ref 不得嵌入 files 数组。'); - assertTrue(latest.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'latest history ref 不得嵌入 files 数组。'); - const immutableUploadsAfterFirst = harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length; + const latest = JSON.parse( + harness.objects.get(latestObjectKey).body.toString('utf8'), + ); + assertEqual( + latest.latestFullCatalog.catalogId, + first.catalogId, + 'latest pointer 必须指向已验真的最新 full catalog。', + ); + assertTrue( + !Object.hasOwn(latest.latestFullCatalog, 'files'), + 'latest full ref 不得嵌入 files 数组。', + ); + assertTrue( + latest.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), + 'latest history ref 不得嵌入 files 数组。', + ); + const immutableUploadsAfterFirst = harness.uploadedKeys.filter( + (objectKey) => objectKey !== latestObjectKey, + ).length; const repeated = await runDirectFilesBackup(options); assertEqual(repeated.uploadedCount, 0, '相同目录重复运行不得重复上传文件。'); assertEqual( - harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey).length, + harness.uploadedKeys.filter((objectKey) => objectKey !== latestObjectKey) + .length, immutableUploadsAfterFirst, '相同 catalog 重跑不得重复 PUT 文件或 catalog,但应覆盖验真 latest pointer。', ); - rmSync(`${first.catalogPath}.gz`, {force: false}); + rmSync(`${first.catalogPath}.gz`, { force: false }); writeFileSync(path.join(dataDir, 'control-db'), 'control changed'); writeFileSync(path.join(dataDir, 'new-program.bin'), 'new program'); const incremental = await runDirectFilesBackup(options); - assertEqual(incremental.uploadedCount, 2, '增量 files full 只应上传新增和变化文件。'); - assertEqual(incremental.reusedCount, 1, '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。'); + assertEqual( + incremental.uploadedCount, + 2, + '增量 files full 只应上传新增和变化文件。', + ); + assertEqual( + incremental.reusedCount, + 1, + '本地 full catalog 缓存缺失时仍应通过 OSS HEAD 复用未变化文件。', + ); } async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { const root = path.join(tmpRoot, 'direct-files-state-migration'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(dataDir, {recursive: true}); + mkdirSync(dataDir, { recursive: true }); writeFileSync(path.join(dataDir, 'control-db'), 'control'); const harness = createDirectOssHarness(); const options = { - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }; const first = await runDirectFilesBackup(options); const compactState = readGzipJson(first.statePath); - const catalog = JSON.parse(gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8')); + const catalog = JSON.parse( + gunzipSync(readFileSync(`${first.catalogPath}.gz`)).toString('utf8'), + ); const legacyCatalogRef = { ...compactState.latestCatalog, files: catalog.files, symlinks: catalog.symlinks, }; const legacyStatePath = first.statePath.slice(0, -3); - writeFileSync(legacyStatePath, `${JSON.stringify({ - ...compactState, - schemaVersion: 1, - baselineCatalog: legacyCatalogRef, - latestCatalog: legacyCatalogRef, - }, null, 2)}\n`); - rmSync(first.statePath, {force: false}); + writeFileSync( + legacyStatePath, + `${JSON.stringify( + { + ...compactState, + schemaVersion: 1, + baselineCatalog: legacyCatalogRef, + latestCatalog: legacyCatalogRef, + }, + null, + 2, + )}\n`, + ); + rmSync(first.statePath, { force: false }); const migrated = await runDirectFilesBackup(options); - assertTrue(migrated.unchanged, '旧 state 迁移不得改变相同 full catalog 的零上传语义。'); - assertTrue(existsSync(migrated.statePath), '旧 state 成功运行后必须生成压缩 state。'); - assertTrue(!existsSync(legacyStatePath), '压缩 state 原子落盘后应删除旧未压缩 state。'); + assertTrue( + migrated.unchanged, + '旧 state 迁移不得改变相同 full catalog 的零上传语义。', + ); + assertTrue( + existsSync(migrated.statePath), + '旧 state 成功运行后必须生成压缩 state。', + ); + assertTrue( + !existsSync(legacyStatePath), + '压缩 state 原子落盘后应删除旧未压缩 state。', + ); const migratedState = readGzipJson(migrated.statePath); assertEqual(migratedState.schemaVersion, 2, '旧 state 必须迁移到 v2。'); - assertTrue(!Object.hasOwn(migratedState.latestCatalog, 'files'), '迁移后 state 不得保留重复 files 清单。'); + assertTrue( + !Object.hasOwn(migratedState.latestCatalog, 'files'), + '迁移后 state 不得保留重复 files 清单。', + ); const latestCatalogPath = `${migrated.catalogPath}.gz`; const validCatalogBody = readFileSync(latestCatalogPath); @@ -389,7 +655,10 @@ async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { ); writeFileSync(latestCatalogPath, validCatalogBody); - writeFileSync(legacyStatePath, `${JSON.stringify({...migratedState, schemaVersion: 1})}\n`); + writeFileSync( + legacyStatePath, + `${JSON.stringify({ ...migratedState, schemaVersion: 1 })}\n`, + ); writeFileSync(migrated.statePath, 'not-a-gzip-state'); let corruptStateFailure = null; try { @@ -397,15 +666,21 @@ async function assertDirectFilesMigratesLegacyStateAndPrunesEmbeddedCatalogs() { } catch (error) { corruptStateFailure = error; } - assertTrue(corruptStateFailure instanceof Error, '压缩 state 损坏时必须失败。'); - assertTrue(existsSync(legacyStatePath), '压缩 state 损坏时不得静默回退并删除旧 state。'); + assertTrue( + corruptStateFailure instanceof Error, + '压缩 state 损坏时必须失败。', + ); + assertTrue( + existsSync(legacyStatePath), + '压缩 state 损坏时不得静默回退并删除旧 state。', + ); } async function assertDirectFilesConcurrencyIsBounded() { const root = path.join(tmpRoot, 'direct-files-concurrency'); const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); - mkdirSync(dataDir, {recursive: true}); + mkdirSync(dataDir, { recursive: true }); for (let index = 0; index < 12; index += 1) { writeFileSync(path.join(dataDir, `file-${index}.bin`), `content-${index}`); } @@ -440,8 +715,13 @@ async function assertDirectFilesConcurrencyIsBounded() { } async function assertDirectHistoryPublishesCatalogBeforeCleanup() { - const fixture = createHistoryFixture('direct-files-history-cleanup', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('direct-files-history-cleanup', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const harness = createDirectOssHarness(); const common = { dataDir: fixture.dataDir, @@ -453,15 +733,29 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { uploadFn: harness.uploadFn, verifyFn: harness.verifyFn, }; - const baseline = await runDirectFilesBackup({...common, mode: 'full', uploadManifestFn: harness.uploadManifestFn}); - const legacyResultFile = path.join(fixture.workDir, 'legacy-full-result.json'); - const legacyCatalogWithoutSymlinks = {...baseline.catalog}; + const baseline = await runDirectFilesBackup({ + ...common, + mode: 'full', + uploadManifestFn: harness.uploadManifestFn, + }); + const legacyResultFile = path.join( + fixture.workDir, + 'legacy-full-result.json', + ); + const legacyCatalogWithoutSymlinks = { ...baseline.catalog }; delete legacyCatalogWithoutSymlinks.symlinks; - writeFileSync(legacyResultFile, `${JSON.stringify({ - ...baseline, - catalog: legacyCatalogWithoutSymlinks, - }, null, 2)}\n`); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + writeFileSync( + legacyResultFile, + `${JSON.stringify( + { + ...baseline, + catalog: legacyCatalogWithoutSymlinks, + }, + null, + 2, + )}\n`, + ); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); let failure = null; try { await runDirectFilesBackup({ @@ -474,9 +768,16 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } catch (error) { failure = error; } - assertIncludes(failure?.message ?? '', 'synthetic direct catalog failure', 'direct history catalog 发布失败必须向上返回。'); + assertIncludes( + failure?.message ?? '', + 'synthetic direct catalog failure', + 'direct history catalog 发布失败必须向上返回。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `direct history catalog 发布失败不得删除: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `direct history catalog 发布失败不得删除: ${candidate.path}`, + ); } let pointerFailure = null; @@ -495,9 +796,16 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } catch (error) { pointerFailure = error; } - assertIncludes(pointerFailure?.message ?? '', 'synthetic latest pointer HEAD failure', 'latest pointer HEAD 验真失败必须向上返回。'); + assertIncludes( + pointerFailure?.message ?? '', + 'synthetic latest pointer HEAD failure', + 'latest pointer HEAD 验真失败必须向上返回。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `latest pointer 发布失败不得删除: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `latest pointer 发布失败不得删除: ${candidate.path}`, + ); } const resultFile = path.join(fixture.workDir, 'history-result.json'); @@ -507,33 +815,80 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { resultFile, uploadManifestFn: harness.uploadManifestFn, }); - assertEqual(success.uploadedCount, 0, 'history 文件已在 full CAS baseline 时不应重复上传内容。'); + assertEqual( + success.uploadedCount, + 0, + 'history 文件已在 full CAS baseline 时不应重复上传内容。', + ); for (const file of success.catalog.files) { assertTrue( harness.verifiedKeys.includes(file.objectKey), `history 清理前必须逐个验真 baseline 复用对象: ${file.path}`, ); } - assertEqual(success.cleanup?.deletedCount, plan.candidates.length, 'catalog 和 baseline 验真后才应清理全部安全候选。'); + assertEqual( + success.cleanup?.deletedCount, + plan.candidates.length, + 'catalog 和 baseline 验真后才应清理全部安全候选。', + ); const state = readGzipJson(success.statePath); - assertEqual(state.schemaVersion, 2, 'files state 必须迁移为去重后的 v2 契约。'); - assertTrue(!Object.hasOwn(state.latestCatalog, 'files'), 'files state latest ref 不得重复嵌入 files。'); - assertTrue(state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), 'files state history ref 不得重复嵌入 files。'); - assertTrue(!existsSync(success.catalogPath), '已上传并验真的 history catalog 本地 JSON 应被清理。'); - assertTrue(!existsSync(`${success.catalogPath}.gz`), 'history catalog 本地压缩副本也不应保留。'); + assertEqual( + state.schemaVersion, + 2, + 'files state 必须迁移为去重后的 v2 契约。', + ); + assertTrue( + !Object.hasOwn(state.latestCatalog, 'files'), + 'files state latest ref 不得重复嵌入 files。', + ); + assertTrue( + state.historyCatalogs.every((catalog) => !Object.hasOwn(catalog, 'files')), + 'files state history ref 不得重复嵌入 files。', + ); + assertTrue( + !existsSync(success.catalogPath), + '已上传并验真的 history catalog 本地 JSON 应被清理。', + ); + assertTrue( + !existsSync(`${success.catalogPath}.gz`), + 'history catalog 本地压缩副本也不应保留。', + ); const diskResult = JSON.parse(readFileSync(resultFile, 'utf8')); - assertTrue(!Object.hasOwn(diskResult.catalog, 'files'), 'files result 文件不得重复写入完整 files 清单。'); - assertEqual(diskResult.catalog.fileCount, success.fileCount, '紧凑 result 仍应保留文件计数。'); - const compactedLegacyResult = JSON.parse(readFileSync(legacyResultFile, 'utf8')); - assertTrue(!Object.hasOwn(compactedLegacyResult.catalog, 'files'), '旧 result 中重复的 files 清单应在成功运行后压缩。'); - assertEqual(compactedLegacyResult.catalog.symlinkCount, 0, '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。'); - assertTrue((success.metadataCleanup?.compactedResultCount ?? 0) >= 1, 'metadata 清理应报告已压缩旧 result。'); + assertTrue( + !Object.hasOwn(diskResult.catalog, 'files'), + 'files result 文件不得重复写入完整 files 清单。', + ); + assertEqual( + diskResult.catalog.fileCount, + success.fileCount, + '紧凑 result 仍应保留文件计数。', + ); + const compactedLegacyResult = JSON.parse( + readFileSync(legacyResultFile, 'utf8'), + ); + assertTrue( + !Object.hasOwn(compactedLegacyResult.catalog, 'files'), + '旧 result 中重复的 files 清单应在成功运行后压缩。', + ); + assertEqual( + compactedLegacyResult.catalog.symlinkCount, + 0, + '缺少 symlinks 的旧 result 应按零个符号链接兼容迁移。', + ); + assertTrue( + (success.metadataCleanup?.compactedResultCount ?? 0) >= 1, + 'metadata 清理应报告已压缩旧 result。', + ); const historyCatalogObjectKey = state.historyCatalogs[0].objectKey; harness.objects.delete(historyCatalogObjectKey); let brokenHistoryFailure = null; try { - await runDirectFilesBackup({...common, mode: 'history', uploadManifestFn: harness.uploadManifestFn}); + await runDirectFilesBackup({ + ...common, + mode: 'history', + uploadManifestFn: harness.uploadManifestFn, + }); } catch (error) { brokenHistoryFailure = error; } @@ -545,8 +900,13 @@ async function assertDirectHistoryPublishesCatalogBeforeCleanup() { } async function assertDirectHistoryWithoutCandidatesPublishesLatest() { - const fixture = createHistoryFixture('direct-files-history-empty', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [10], segments: [0]}); + const fixture = createHistoryFixture('direct-files-history-empty', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [10], + segments: [0], + }); const harness = createDirectOssHarness(); const common = { dataDir: fixture.dataDir, @@ -559,13 +919,19 @@ async function assertDirectHistoryWithoutCandidatesPublishesLatest() { uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, }; - await runDirectFilesBackup({...common, mode: 'full'}); + await runDirectFilesBackup({ ...common, mode: 'full' }); const latestObjectKey = 'database-backups/test-db/latest.json'; harness.objects.delete(latestObjectKey); - const result = await runDirectFilesBackup({...common, mode: 'history'}); + const result = await runDirectFilesBackup({ ...common, mode: 'history' }); assertEqual(result.candidateCount, 0, 'fixture 应没有可归档 history 候选。'); - assertTrue(harness.objects.has(latestObjectKey), 'history 无候选时仍必须从现有 state 发布 latest pointer。'); - assertTrue(harness.verifiedKeys.includes(latestObjectKey), 'history 无候选时 latest pointer 仍必须 HEAD 验真。'); + assertTrue( + harness.objects.has(latestObjectKey), + 'history 无候选时仍必须从现有 state 发布 latest pointer。', + ); + assertTrue( + harness.verifiedKeys.includes(latestObjectKey), + 'history 无候选时 latest pointer 仍必须 HEAD 验真。', + ); } async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { @@ -573,23 +939,39 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { const dataDir = path.join(root, 'stdb'); const workDir = path.join(root, 'work'); const restoreDir = path.join(root, 'restore'); - mkdirSync(path.join(dataDir, 'empty-directory'), {recursive: true}); - mkdirSync(path.join(dataDir, 'config'), {recursive: true}); - mkdirSync(path.join(dataDir, 'bin', '2.6.0'), {recursive: true}); + mkdirSync(path.join(dataDir, 'empty-directory'), { recursive: true }); + mkdirSync(path.join(dataDir, 'config'), { recursive: true }); + mkdirSync(path.join(dataDir, 'bin', '2.6.0'), { recursive: true }); symlinkSync('2.6.0', path.join(dataDir, 'bin', 'current')); const keyPath = path.join(dataDir, 'config', 'id_ecdsa'); writeFileSync(keyPath, 'private key fixture'); chmodSync(keyPath, 0o640); const harness = createDirectOssHarness(); await runDirectFilesBackup({ - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }); writeFileSync(keyPath, 'updated private key fixture'); chmodSync(keyPath, 0o640); const latestFull = await runDirectFilesBackup({ - mode: 'full', dataDir, workDir, database: 'test-db', bucket: 'backup-bucket', objectPrefix: 'database-backups', - uploadOptions: {}, uploadFn: harness.uploadFn, uploadManifestFn: harness.uploadManifestFn, verifyFn: harness.verifyFn, + mode: 'full', + dataDir, + workDir, + database: 'test-db', + bucket: 'backup-bucket', + objectPrefix: 'database-backups', + uploadOptions: {}, + uploadFn: harness.uploadFn, + uploadManifestFn: harness.uploadManifestFn, + verifyFn: harness.verifyFn, }); const restored = await restoreDirectFilesBackup({ statePath: latestFull.statePath, @@ -597,8 +979,9 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { database: 'test-db', bucket: 'backup-bucket', uploadOptions: {}, - downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), - downloadFileFn: async ({objectKey, destinationPath}) => { + downloadBufferFn: async ({ objectKey }) => + Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({ objectKey, destinationPath }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -606,27 +989,56 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { writeFileSync(destinationPath, object.body); }, }); - assertEqual(restored.downloadedCount, 1, 'files restore 必须从对象存储下载 catalog 中的普通文件。'); - assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'files restore 必须按最新 full catalog 的原相对路径恢复内容。'); - assertTrue(existsSync(path.join(restoreDir, 'empty-directory')), 'files restore 必须重建空目录。'); - assertEqual(statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, 0o640, 'files restore 必须恢复文件权限。'); - assertTrue(lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), 'files restore 必须重建符号链接。'); - assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'files restore 必须保留符号链接目标。'); + assertEqual( + restored.downloadedCount, + 1, + 'files restore 必须从对象存储下载 catalog 中的普通文件。', + ); + assertEqual( + readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), + 'updated private key fixture', + 'files restore 必须按最新 full catalog 的原相对路径恢复内容。', + ); + assertTrue( + existsSync(path.join(restoreDir, 'empty-directory')), + 'files restore 必须重建空目录。', + ); + assertEqual( + statSync(path.join(restoreDir, 'config', 'id_ecdsa')).mode & 0o7777, + 0o640, + 'files restore 必须恢复文件权限。', + ); + assertTrue( + lstatSync(path.join(restoreDir, 'bin', 'current')).isSymbolicLink(), + 'files restore 必须重建符号链接。', + ); + assertEqual( + readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), + '2.6.0', + 'files restore 必须保留符号链接目标。', + ); - rmSync(restoreDir, {recursive: true, force: true}); - const legacyRestoreStatePath = path.join(workDir, 'legacy-restore-state.json'); - writeFileSync(legacyRestoreStatePath, `${JSON.stringify({ - ...readGzipJson(latestFull.statePath), - schemaVersion: 1, - })}\n`); + rmSync(restoreDir, { recursive: true, force: true }); + const legacyRestoreStatePath = path.join( + workDir, + 'legacy-restore-state.json', + ); + writeFileSync( + legacyRestoreStatePath, + `${JSON.stringify({ + ...readGzipJson(latestFull.statePath), + schemaVersion: 1, + })}\n`, + ); const legacyRestored = await restoreDirectFilesBackup({ statePath: legacyRestoreStatePath, restoreDir, database: 'test-db', bucket: 'backup-bucket', uploadOptions: {}, - downloadBufferFn: async ({objectKey}) => Buffer.from(harness.objects.get(objectKey)?.body ?? ''), - downloadFileFn: async ({objectKey, destinationPath}) => { + downloadBufferFn: async ({ objectKey }) => + Buffer.from(harness.objects.get(objectKey)?.body ?? ''), + downloadFileFn: async ({ objectKey, destinationPath }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -634,10 +1046,14 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { writeFileSync(destinationPath, object.body); }, }); - assertEqual(legacyRestored.downloadedCount, 1, 'files restore 必须继续兼容 v1 JSON state。'); + assertEqual( + legacyRestored.downloadedCount, + 1, + 'files restore 必须继续兼容 v1 JSON state。', + ); - rmSync(restoreDir, {recursive: true, force: true}); - const downloadBufferFn = async ({objectKey}) => { + rmSync(restoreDir, { recursive: true, force: true }); + const downloadBufferFn = async ({ objectKey }) => { const object = harness.objects.get(objectKey); if (!object) { throw new Error(`missing ${objectKey}`); @@ -645,7 +1061,7 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { return Buffer.from(object.body); }; let objectDownloadCount = 0; - const downloadFileFn = async ({objectKey, destinationPath}) => { + const downloadFileFn = async ({ objectKey, destinationPath }) => { objectDownloadCount += 1; const object = harness.objects.get(objectKey); if (!object) { @@ -664,10 +1080,26 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { downloadFileFn, verifyFn: harness.verifyFn, }); - assertEqual(dryRun.catalogId, latestFull.catalogId, 'OSS-only dry-run 必须选择 latestFullCatalog。'); - assertEqual(dryRun.fileCount, 1, 'OSS-only dry-run 应返回 full catalog 文件数。'); - assertEqual(dryRun.symlinkCount, 1, 'OSS-only dry-run 应返回 full catalog 符号链接数。'); - assertEqual(dryRun.totalSizeBytes, String(Buffer.byteLength('updated private key fixture')), 'OSS-only dry-run 应返回总字节数。'); + assertEqual( + dryRun.catalogId, + latestFull.catalogId, + 'OSS-only dry-run 必须选择 latestFullCatalog。', + ); + assertEqual( + dryRun.fileCount, + 1, + 'OSS-only dry-run 应返回 full catalog 文件数。', + ); + assertEqual( + dryRun.symlinkCount, + 1, + 'OSS-only dry-run 应返回 full catalog 符号链接数。', + ); + assertEqual( + dryRun.totalSizeBytes, + String(Buffer.byteLength('updated private key fixture')), + 'OSS-only dry-run 应返回总字节数。', + ); assertEqual(objectDownloadCount, 0, 'OSS-only dry-run 不得下载数据对象。'); assertTrue(!existsSync(restoreDir), 'OSS-only dry-run 不得创建恢复目录。'); @@ -681,16 +1113,36 @@ async function assertDirectFilesRestoreDownloadsCatalogAndObjects() { downloadFileFn, verifyFn: harness.verifyFn, }); - assertEqual(latestRestored.catalogId, latestFull.catalogId, 'OSS-only restore 必须选择 latestFullCatalog。'); - assertEqual(latestRestored.downloadedCount, 1, 'OSS-only restore 应下载 latest full catalog 的数据对象。'); - assertEqual(readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), 'updated private key fixture', 'OSS-only restore 应还原最新 full 内容。'); - assertEqual(readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), '2.6.0', 'OSS-only restore 应还原符号链接。'); + assertEqual( + latestRestored.catalogId, + latestFull.catalogId, + 'OSS-only restore 必须选择 latestFullCatalog。', + ); + assertEqual( + latestRestored.downloadedCount, + 1, + 'OSS-only restore 应下载 latest full catalog 的数据对象。', + ); + assertEqual( + readFileSync(path.join(restoreDir, 'config', 'id_ecdsa'), 'utf8'), + 'updated private key fixture', + 'OSS-only restore 应还原最新 full 内容。', + ); + assertEqual( + readlinkSync(path.join(restoreDir, 'bin', 'current'), 'utf8'), + '2.6.0', + 'OSS-only restore 应还原符号链接。', + ); } function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { - assertEqual(buildCanonicalQuery({uploads: null}), 'uploads', 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。'); assertEqual( - buildCanonicalQuery({uploadId: 'abc+/= xyz', partNumber: 12}), + buildCanonicalQuery({ uploads: null }), + 'uploads', + 'InitiateMultipartUpload 必须使用无等号的 uploads 参数。', + ); + assertEqual( + buildCanonicalQuery({ uploadId: 'abc+/= xyz', partNumber: 12 }), 'partNumber=12&uploadId=abc%2B%2F%3D%20xyz', 'multipart query 必须按 key 排序并使用 RFC3986 编码。', ); @@ -721,9 +1173,13 @@ function assertCanonicalQueryAndAuthorizationIncludeMultipartParameters() { accessKeySecret: 'test-access-secret', headers, date, - queries: {partNumber: 12, uploadId: 'abc+/= xyz'}, + queries: { partNumber: 12, uploadId: 'abc+/= xyz' }, }); - assertNotEqual(withQuery, withoutQuery, 'multipart query 必须参与 V4 Authorization 计算。'); + assertNotEqual( + withQuery, + withoutQuery, + 'multipart query 必须参与 V4 Authorization 计算。', + ); assertEqual( withQuery, 'OSS4-HMAC-SHA256 Credential=test-access-key/20260713/cn-shanghai/oss/aliyun_v4_request,AdditionalHeaders=host,Signature=9323dd3b7272b52f416c4d32115fcc00460eaccdcdaf011575c2502a63a27b1f', @@ -743,8 +1199,16 @@ function assertInsufficientSpaceStopsBeforeServiceChanges() { ]); assertStatus(result, 1, '空间不足时必须失败。'); - assertIncludes(result.stdout, '备份空间预检', '空间不足失败前应打印空间预检。'); - assertIncludes(result.stderr, '剩余空间不足', '空间不足失败应说明剩余空间不足。'); + assertIncludes( + result.stdout, + '备份空间预检', + '空间不足失败前应打印空间预检。', + ); + assertIncludes( + result.stderr, + '剩余空间不足', + '空间不足失败应说明剩余空间不足。', + ); assertFileMissing(fixture.systemctlLog, '空间不足时不能调用 systemctl。'); assertFileMissing(fixture.tarLog, '空间不足时不能调用 tar。'); } @@ -786,7 +1250,11 @@ function assertArchiveFailureStillRestoresDependentServices() { ]); assertStatus(result, 1, 'tar 失败时备份脚本必须失败。'); - assertIncludes(result.stderr, 'fake tar failure', 'tar 失败原因应保留在错误输出中。'); + assertIncludes( + result.stderr, + 'fake tar failure', + 'tar 失败原因应保留在错误输出中。', + ); const systemctlLog = readFile(fixture.systemctlLog); const expectedCommands = [ 'systemctl stop spacetimedb.service', @@ -814,7 +1282,7 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { Buffer.alloc(17, 'c'), ]); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; @@ -823,30 +1291,50 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { const uploadId = 'upload+/= id'; const fetchImpl = async (url, options) => { const body = await readRequestBody(options.body); - requests.push({url, method: options.method, headers: options.headers, body}); + requests.push({ + url, + method: options.method, + headers: options.headers, + body, + }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response(`${uploadId}`, {status: 200}); + return new Response( + `${uploadId}`, + { status: 200 }, + ); } if (options.method === 'PUT') { const partNumber = Number(parsedUrl.searchParams.get('partNumber')); if (partNumber === 1) { firstPartAttempts += 1; if (firstPartAttempts === 1) { - return new Response('ServiceUnavailable', {status: 503}); + return new Response( + 'ServiceUnavailable', + { status: 503 }, + ); } } - return new Response('', {status: 200, headers: {etag: `"etag-${partNumber}"`}}); + return new Response('', { + status: 200, + headers: { etag: `"etag-${partNumber}"` }, + }); } if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) { - return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"complete-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -868,32 +1356,86 @@ async function assertMultipartUploadRetriesAndVerifiesRemoteLength() { randomFn: () => 0, }); - assertEqual(result.uploadMode, 'multipart', '上传结果必须记录 multipart 模式。'); + assertEqual( + result.uploadMode, + 'multipart', + '上传结果必须记录 multipart 模式。', + ); assertEqual(result.partCount, 3, 'multipart 应按配置大小切成三段。'); - assertEqual(result.contentLength, payload.length, '上传结果应保留完整归档长度。'); - assertEqual(result.etag, 'complete-etag', '上传结果应保留 CompleteMultipartUpload ETag。'); + assertEqual( + result.contentLength, + payload.length, + '上传结果应保留完整归档长度。', + ); + assertEqual( + result.etag, + 'complete-etag', + '上传结果应保留 CompleteMultipartUpload ETag。', + ); assertEqual(firstPartAttempts, 2, '503 后应仅重试失败的第一段。'); assertEqual(retryDelays.length, 1, '一次可重试失败应触发一次退避。'); const initiateRequest = requests[0]; - assertTrue(initiateRequest.url.endsWith('?uploads'), 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。'); - assertTrue(!initiateRequest.url.endsWith('?uploads='), 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。'); - assertEqual(initiateRequest.headers['x-oss-meta-archive-sha256'], payloadSha256, 'multipart 对象必须保存本地归档 SHA-256 元数据。'); - const firstPartRequests = requests.filter(({method, url}) => method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1'); - assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。'); - assertBufferEqual(firstPartRequests[0].body, payload.subarray(0, partSizeBytes), '第一段原请求内容必须完整。'); - assertBufferEqual(firstPartRequests[1].body, payload.subarray(0, partSizeBytes), '第一段重试必须重新创建并完整读取 stream。'); assertTrue( - firstPartRequests[0].url.includes('?partNumber=1&uploadId=upload%2B%2F%3D%20id'), + initiateRequest.url.endsWith('?uploads'), + 'InitiateMultipartUpload URL 必须使用裸 uploads 参数。', + ); + assertTrue( + !initiateRequest.url.endsWith('?uploads='), + 'InitiateMultipartUpload URL 不能把裸参数写成 uploads=。', + ); + assertEqual( + initiateRequest.headers['x-oss-meta-archive-sha256'], + payloadSha256, + 'multipart 对象必须保存本地归档 SHA-256 元数据。', + ); + const firstPartRequests = requests.filter( + ({ method, url }) => + method === 'PUT' && new URL(url).searchParams.get('partNumber') === '1', + ); + assertEqual(firstPartRequests.length, 2, '第一段应产生原请求和一次重试。'); + assertBufferEqual( + firstPartRequests[0].body, + payload.subarray(0, partSizeBytes), + '第一段原请求内容必须完整。', + ); + assertBufferEqual( + firstPartRequests[1].body, + payload.subarray(0, partSizeBytes), + '第一段重试必须重新创建并完整读取 stream。', + ); + assertTrue( + firstPartRequests[0].url.includes( + '?partNumber=1&uploadId=upload%2B%2F%3D%20id', + ), 'UploadPart URL 必须使用排序并编码后的 canonical query。', ); - const completeRequest = requests.find(({method, url}) => method === 'POST' && new URL(url).searchParams.has('uploadId')); - assertIncludes(completeRequest?.body.toString('utf8') ?? '', '1"etag-1"', 'Complete XML 应包含第一段 ETag。'); - assertIncludes(completeRequest?.body.toString('utf8') ?? '', '3"etag-3"', 'Complete XML 应包含最后一段 ETag。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 后必须执行签名 HEAD 验证。'); + const completeRequest = requests.find( + ({ method, url }) => + method === 'POST' && new URL(url).searchParams.has('uploadId'), + ); + assertIncludes( + completeRequest?.body.toString('utf8') ?? '', + '1"etag-1"', + 'Complete XML 应包含第一段 ETag。', + ); + assertIncludes( + completeRequest?.body.toString('utf8') ?? '', + '3"etag-3"', + 'Complete XML 应包含最后一段 ETag。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'Complete 后必须执行签名 HEAD 验证。', + ); for (const request of requests) { - assertTrue(String(request.headers.authorization ?? '').startsWith('OSS4-HMAC-SHA256 '), `${request.method} 请求必须携带 V4 Authorization。`); + assertTrue( + String(request.headers.authorization ?? '').startsWith( + 'OSS4-HMAC-SHA256 ', + ), + `${request.method} 请求必须携带 V4 Authorization。`, + ); } } @@ -903,31 +1445,43 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { const partSizeBytes = 100 * 1024; const payload = Buffer.alloc(partSizeBytes + 1, 'x'); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('mismatch-upload', {status: 200}); + return new Response( + 'mismatch-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"`}}); + return new Response('', { + status: 200, + headers: { etag: `"etag-${parsedUrl.searchParams.get('partNumber')}"` }, + }); } if (options.method === 'POST') { - return new Response('', {status: 200, headers: {etag: '"complete-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"complete-etag"' }, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length - 1), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length - 1), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -955,40 +1509,59 @@ async function assertHeadLengthMismatchAbortsMultipartUpload() { } assertTrue(uploadError instanceof Error, 'HEAD 长度不一致时上传必须失败。'); - assertIncludes(uploadError?.message ?? '', 'HEAD 验证长度不一致', 'HEAD 长度不一致错误应保留本地和远端长度。'); - const abortRequest = requests.find(({method}) => method === 'DELETE'); - assertTrue(Boolean(abortRequest), 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。'); - assertTrue(abortRequest?.url.endsWith('?uploadId=mismatch-upload'), 'AbortMultipartUpload 必须携带同一 uploadId。'); + assertIncludes( + uploadError?.message ?? '', + 'HEAD 验证长度不一致', + 'HEAD 长度不一致错误应保留本地和远端长度。', + ); + const abortRequest = requests.find(({ method }) => method === 'DELETE'); + assertTrue( + Boolean(abortRequest), + 'HEAD 长度不一致后必须 best-effort AbortMultipartUpload。', + ); + assertTrue( + abortRequest?.url.endsWith('?uploadId=mismatch-upload'), + 'AbortMultipartUpload 必须携带同一 uploadId。', + ); } async function assertHeadShaMismatchAbortsMultipartUpload() { const root = path.join(tmpRoot, 'multipart-head-sha-mismatch'); const archivePath = path.join(root, 'backup.tar.gz'); const payload = Buffer.alloc(100 * 1024, 's'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('sha-mismatch-upload', {status: 200}); + return new Response( + 'sha-mismatch-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: '"part-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"part-etag"' }, + }); } if (options.method === 'POST') { - return new Response('', {status: 200}); + return new Response('', { status: 200 }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': '0'.repeat(64), - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': '0'.repeat(64), + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -1011,9 +1584,16 @@ async function assertHeadShaMismatchAbortsMultipartUpload() { } catch (error) { uploadError = error; } - assertIncludes(uploadError?.message ?? '', 'SHA-256 不一致', 'HEAD SHA-256 不一致时上传必须失败。'); + assertIncludes( + uploadError?.message ?? '', + 'SHA-256 不一致', + 'HEAD SHA-256 不一致时上传必须失败。', + ); assertTrue( - requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=sha-mismatch-upload')), + requests.some( + ({ method, url }) => + method === 'DELETE' && url.endsWith('?uploadId=sha-mismatch-upload'), + ), 'HEAD SHA-256 不一致后必须 best-effort AbortMultipartUpload。', ); } @@ -1021,9 +1601,14 @@ async function assertHeadShaMismatchAbortsMultipartUpload() { async function assertManifestUploadUsesShaAndHeadVerification() { const root = path.join(tmpRoot, 'manifest-upload'); const manifestPath = path.join(root, 'backup.manifest.json'); - const body = Buffer.from(JSON.stringify({uploadStatus: 'uploaded', catalog: 'x'.repeat(150 * 1024)})); + const body = Buffer.from( + JSON.stringify({ + uploadStatus: 'uploaded', + catalog: 'x'.repeat(150 * 1024), + }), + ); const bodySha256 = createHash('sha256').update(body).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(manifestPath, body); const requests = []; let limitedChunkCount = 0; @@ -1041,37 +1626,68 @@ async function assertManifestUploadUsesShaAndHeadVerification() { randomFn: () => 0, bandwidthLimiter: { wrap(readable) { - return Readable.from((async function* observeLimitedManifest() { - for await (const chunk of readable) { - limitedChunkCount += 1; - limitedBytes += chunk.length; - yield chunk; - } - })(), {objectMode: false}); + return Readable.from( + (async function* observeLimitedManifest() { + for await (const chunk of readable) { + limitedChunkCount += 1; + limitedBytes += chunk.length; + yield chunk; + } + })(), + { objectMode: false }, + ); }, }, fetchImpl: async (url, options) => { const requestBody = await readRequestBody(options.body); - requests.push({url, method: options.method, headers: options.headers, body: requestBody}); + requests.push({ + url, + method: options.method, + headers: options.headers, + body: requestBody, + }); if (options.method === 'PUT') { - return new Response(null, {status: 200}); + return new Response(null, { status: 200 }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'x-oss-meta-file-size': String(body.length), - 'x-oss-meta-archive-sha256': bodySha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'x-oss-meta-file-size': String(body.length), + 'x-oss-meta-archive-sha256': bodySha256, + }, + }); } throw new Error(`unexpected request: ${options.method} ${url}`); }, }); - assertEqual(result.archiveSha256, bodySha256, 'manifest 上传结果必须记录本地 SHA-256。'); - assertBufferEqual(requests.find(({method}) => method === 'PUT')?.body, body, 'manifest PUT 必须上传完整 JSON。'); - assertEqual(limitedBytes, body.length, 'manifest 必须完整经过上传带宽限制流。'); - assertTrue(limitedChunkCount > 1, '大型 manifest 必须分块经过限速器,不能整块突发上传。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'manifest PUT 后必须执行 HEAD 验真。'); assertEqual( - requests.find(({method}) => method === 'PUT')?.headers['x-oss-meta-file-size'], + result.archiveSha256, + bodySha256, + 'manifest 上传结果必须记录本地 SHA-256。', + ); + assertBufferEqual( + requests.find(({ method }) => method === 'PUT')?.body, + body, + 'manifest PUT 必须上传完整 JSON。', + ); + assertEqual( + limitedBytes, + body.length, + 'manifest 必须完整经过上传带宽限制流。', + ); + assertTrue( + limitedChunkCount > 1, + '大型 manifest 必须分块经过限速器,不能整块突发上传。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'manifest PUT 后必须执行 HEAD 验真。', + ); + assertEqual( + requests.find(({ method }) => method === 'PUT')?.headers[ + 'x-oss-meta-file-size' + ], String(body.length), 'manifest PUT 必须记录原始字节数,供动态压缩 HEAD 缺少 content-length 时验真。', ); @@ -1080,22 +1696,25 @@ async function assertManifestUploadUsesShaAndHeadVerification() { async function assertMissingPartEtagAbortsMultipartUpload() { const root = path.join(tmpRoot, 'multipart-missing-etag'); const archivePath = path.join(root, 'backup.tar.gz'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, Buffer.alloc(100 * 1024, 'e')); const requests = []; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('missing-etag-upload', {status: 200}); + return new Response( + 'missing-etag-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200}); + return new Response('', { status: 200 }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -1120,9 +1739,16 @@ async function assertMissingPartEtagAbortsMultipartUpload() { uploadError = error; } - assertIncludes(uploadError?.message ?? '', '响应缺少 ETag', 'UploadPart 缺少 ETag 时必须失败。'); + assertIncludes( + uploadError?.message ?? '', + '响应缺少 ETag', + 'UploadPart 缺少 ETag 时必须失败。', + ); assertTrue( - requests.some(({method, url}) => method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload')), + requests.some( + ({ method, url }) => + method === 'DELETE' && url.endsWith('?uploadId=missing-etag-upload'), + ), 'UploadPart 缺少 ETag 后必须 AbortMultipartUpload。', ); } @@ -1132,36 +1758,47 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { const archivePath = path.join(root, 'backup.tar.gz'); const payload = Buffer.alloc(100 * 1024, 'c'); const payloadSha256 = createHash('sha256').update(payload).digest('hex'); - mkdirSync(root, {recursive: true}); + mkdirSync(root, { recursive: true }); writeFileSync(archivePath, payload); const requests = []; let completeAttempts = 0; const fetchImpl = async (url, options) => { await readRequestBody(options.body); - requests.push({url, method: options.method}); + requests.push({ url, method: options.method }); const parsedUrl = new URL(url); if (options.method === 'POST' && parsedUrl.search === '?uploads') { - return new Response('ambiguous-upload', {status: 200}); + return new Response( + 'ambiguous-upload', + { status: 200 }, + ); } if (options.method === 'PUT') { - return new Response('', {status: 200, headers: {etag: '"part-etag"'}}); + return new Response('', { + status: 200, + headers: { etag: '"part-etag"' }, + }); } if (options.method === 'POST' && parsedUrl.searchParams.has('uploadId')) { completeAttempts += 1; if (completeAttempts === 1) { throw new TypeError('socket closed after remote complete'); } - return new Response('NoSuchUpload', {status: 404}); + return new Response('NoSuchUpload', { + status: 404, + }); } if (options.method === 'HEAD') { - return new Response(null, {status: 200, headers: { - 'content-length': String(payload.length), - 'x-oss-meta-archive-sha256': payloadSha256, - }}); + return new Response(null, { + status: 200, + headers: { + 'content-length': String(payload.length), + 'x-oss-meta-archive-sha256': payloadSha256, + }, + }); } if (options.method === 'DELETE') { - return new Response(null, {status: 204}); + return new Response(null, { status: 204 }); } throw new Error(`unexpected request: ${options.method} ${url}`); }; @@ -1184,13 +1821,23 @@ async function assertCompleteResponseAmbiguityUsesHeadVerification() { }); assertEqual(completeAttempts, 2, 'Complete 网络错误后应按策略重试。'); - assertEqual(result.contentLength, payload.length, 'Complete 结果不确定时应以 HEAD 长度验真收口。'); - assertTrue(requests.some(({method}) => method === 'HEAD'), 'Complete 结果不确定时必须执行 HEAD 验真。'); - assertTrue(!requests.some(({method}) => method === 'DELETE'), 'HEAD 已证实对象完整时不得 Abort 已完成上传。'); + assertEqual( + result.contentLength, + payload.length, + 'Complete 结果不确定时应以 HEAD 长度验真收口。', + ); + assertTrue( + requests.some(({ method }) => method === 'HEAD'), + 'Complete 结果不确定时必须执行 HEAD 验真。', + ); + assertTrue( + !requests.some(({ method }) => method === 'DELETE'), + 'HEAD 已证实对象完整时不得 Abort 已完成上传。', + ); } function assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas() { - const dev = createHistoryFixture('history-dev-layout', {nestedData: false}); + const dev = createHistoryFixture('history-dev-layout', { nestedData: false }); createReplicaHistory(dev.replicasDir, '1', { snapshots: [0, 187, 279], segments: [0, 188, 280], @@ -1199,108 +1846,276 @@ function assertHistoryDiscoversDevAndProductionLayoutsWithMultipleReplicas() { snapshots: [50, 99], segments: [0, 51, 100], }); - const devPlan = discoverHistoryPlan({dataDir: dev.dataDir}); - assertEqual(devPlan.replicas.length, 2, 'history 应逐 replica 计算安全边界。'); - assertEqual(devPlan.candidates.length, 7, '多 replica history 候选数量必须符合 snapshot/segment 边界。'); + const devPlan = discoverHistoryPlan({ dataDir: dev.dataDir }); + assertEqual( + devPlan.replicas.length, + 2, + 'history 应逐 replica 计算安全边界。', + ); + assertEqual( + devPlan.candidates.length, + 7, + '多 replica history 候选数量必须符合 snapshot/segment 边界。', + ); assertTrue( - devPlan.candidates.some(({path}) => path === 'replicas/1/clog/00000000000000000000.stdb.log'), + devPlan.candidates.some( + ({ path }) => path === 'replicas/1/clog/00000000000000000000.stdb.log', + ), 'dev 布局应识别边界 segment 之前的 commitlog。', ); assertTrue( - !devPlan.candidates.some(({path}) => path.includes('00000000000000000188.stdb.log')), + !devPlan.candidates.some(({ path }) => + path.includes('00000000000000000188.stdb.log'), + ), '跨越 latest snapshot 的边界 segment 必须保留。', ); assertTrue( - !devPlan.candidates.some(({path}) => path.includes('00000000000000000279.snapshot_dir')), + !devPlan.candidates.some(({ path }) => + path.includes('00000000000000000279.snapshot_dir'), + ), '每个 replica 的 latest snapshot 必须保留。', ); - const production = createHistoryFixture('history-production-layout', {nestedData: true}); + const production = createHistoryFixture('history-production-layout', { + nestedData: true, + }); createReplicaHistory(production.replicasDir, '7', { snapshots: [10, 20], segments: [0, 11, 21], }); - const productionPlan = discoverHistoryPlan({dataDir: production.dataDir}); - assertEqual(productionPlan.replicasDir, 'data/replicas', 'history 必须兼容 /stdb/data/replicas 布局。'); - assertEqual(productionPlan.candidates.length, 3, 'production 布局应识别一个旧 snapshot 与一对旧 commitlog 文件。'); + const productionPlan = discoverHistoryPlan({ dataDir: production.dataDir }); + assertEqual( + productionPlan.replicasDir, + 'data/replicas', + 'history 必须兼容 /stdb/data/replicas 布局。', + ); + assertEqual( + productionPlan.candidates.length, + 3, + 'production 布局应识别一个旧 snapshot 与一对旧 commitlog 文件。', + ); const importResult = runHistoryDryRun(dev); - assertStatus(importResult, 0, 'history dry-run 应能从已有 uploaded baseline manifest 导入 state。'); - assertTrue(existsSync(dev.statePath), 'history dry-run 应持久化导入后的 baseline state。'); - assertIncludes(importResult.stdout, 'history dry-run', 'history dry-run 应明确说明不会上传或删除。'); + assertStatus( + importResult, + 0, + 'history dry-run 应能从已有 uploaded baseline manifest 导入 state。', + ); + assertTrue( + existsSync(dev.statePath), + 'history dry-run 应持久化导入后的 baseline state。', + ); + assertIncludes( + importResult.stdout, + 'history dry-run', + 'history dry-run 应明确说明不会上传或删除。', + ); for (const candidate of devPlan.candidates) { - assertTrue(existsSync(path.join(dev.dataDir, candidate.path)), `history dry-run 不得删除候选: ${candidate.path}`); + assertTrue( + existsSync(path.join(dev.dataDir, candidate.path)), + `history dry-run 不得删除候选: ${candidate.path}`, + ); } } function assertHistorySkipsReplicaWithoutSnapshotAndRejectsMalformedNames() { - const noSnapshot = createHistoryFixture('history-no-snapshot', {nestedData: false}); - createReplicaHistory(noSnapshot.replicasDir, '1', {snapshots: [], segments: [0]}); - const plan = discoverHistoryPlan({dataDir: noSnapshot.dataDir}); - assertEqual(plan.candidates.length, 0, '没有 snapshot 的 replica 不得产生可删除候选。'); - assertEqual(plan.replicas[0]?.reason, 'no-snapshot', '没有 snapshot 时应记录明确跳过原因。'); + const noSnapshot = createHistoryFixture('history-no-snapshot', { + nestedData: false, + }); + createReplicaHistory(noSnapshot.replicasDir, '1', { + snapshots: [], + segments: [0], + }); + const plan = discoverHistoryPlan({ dataDir: noSnapshot.dataDir }); + assertEqual( + plan.candidates.length, + 0, + '没有 snapshot 的 replica 不得产生可删除候选。', + ); + assertEqual( + plan.replicas[0]?.reason, + 'no-snapshot', + '没有 snapshot 时应记录明确跳过原因。', + ); - const incompleteSnapshot = createHistoryFixture('history-incomplete-snapshot', {nestedData: false}); - const incompleteReplica = createReplicaHistory(incompleteSnapshot.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000020.snapshot_dir')); - mkdirSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir')); - writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.snapshot_dir', '00000000000000000030.snapshot_bsatn'), 'locked'); - writeFileSync(path.join(incompleteReplica.snapshotsDir, '00000000000000000030.lock'), `${process.pid}\n`); - const incompletePlan = discoverHistoryPlan({dataDir: incompleteSnapshot.dataDir}); - assertEqual(incompletePlan.replicas[0]?.latestSnapshot, '10', '缺少 snapshot_bsatn 或仍有 lockfile 的目录不得成为 latest snapshot。'); + const incompleteSnapshot = createHistoryFixture( + 'history-incomplete-snapshot', + { nestedData: false }, + ); + const incompleteReplica = createReplicaHistory( + incompleteSnapshot.replicasDir, + '1', + { snapshots: [0, 10], segments: [0, 1, 11] }, + ); + mkdirSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000020.snapshot_dir', + ), + ); + mkdirSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000030.snapshot_dir', + ), + ); + writeFileSync( + path.join( + incompleteReplica.snapshotsDir, + '00000000000000000030.snapshot_dir', + '00000000000000000030.snapshot_bsatn', + ), + 'locked', + ); + writeFileSync( + path.join(incompleteReplica.snapshotsDir, '00000000000000000030.lock'), + `${process.pid}\n`, + ); + const incompletePlan = discoverHistoryPlan({ + dataDir: incompleteSnapshot.dataDir, + }); + assertEqual( + incompletePlan.replicas[0]?.latestSnapshot, + '10', + '缺少 snapshot_bsatn 或仍有 lockfile 的目录不得成为 latest snapshot。', + ); assertTrue( - !incompletePlan.candidates.some(({path}) => path.includes('00000000000000000010.snapshot_dir')), + !incompletePlan.candidates.some(({ path }) => + path.includes('00000000000000000010.snapshot_dir'), + ), '最后一个完整且未锁定的 snapshot 必须保留。', ); - const malformedLog = createHistoryFixture('history-malformed-log', {nestedData: false}); - const malformedLogReplica = createReplicaHistory(malformedLog.replicasDir, '1', {snapshots: [10], segments: [0, 11]}); - writeFileSync(path.join(malformedLogReplica.clogDir, 'broken.stdb.log'), 'broken'); + const malformedLog = createHistoryFixture('history-malformed-log', { + nestedData: false, + }); + const malformedLogReplica = createReplicaHistory( + malformedLog.replicasDir, + '1', + { snapshots: [10], segments: [0, 11] }, + ); + writeFileSync( + path.join(malformedLogReplica.clogDir, 'broken.stdb.log'), + 'broken', + ); assertThrows( - () => discoverHistoryPlan({dataDir: malformedLog.dataDir}), + () => discoverHistoryPlan({ dataDir: malformedLog.dataDir }), 'commitlog 文件名不符合预期', '异常 commitlog 名称必须阻断整个清理计划。', ); } function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { - const missingBaseline = createHistoryFixture('history-missing-baseline', {nestedData: false}); - createReplicaHistory(missingBaseline.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - rmSync(missingBaseline.baselineManifestPath, {force: true}); - const missingResult = runHistoryCommand(missingBaseline, ['--dry-run'], {includeBaselineManifest: false}); - assertStatus(missingResult, 1, 'history 没有 baseline state 或 imported manifest 时必须失败。'); - assertIncludes(missingResult.stderr, '缺少已验真 baseline state', 'baseline 门禁失败应给出明确错误。'); + const missingBaseline = createHistoryFixture('history-missing-baseline', { + nestedData: false, + }); + createReplicaHistory(missingBaseline.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + rmSync(missingBaseline.baselineManifestPath, { force: true }); + const missingResult = runHistoryCommand(missingBaseline, ['--dry-run'], { + includeBaselineManifest: false, + }); + assertStatus( + missingResult, + 1, + 'history 没有 baseline state 或 imported manifest 时必须失败。', + ); + assertIncludes( + missingResult.stderr, + '缺少已验真 baseline state', + 'baseline 门禁失败应给出明确错误。', + ); - const wrongKind = createHistoryFixture('history-wrong-baseline-kind', {nestedData: false}); - createReplicaHistory(wrongKind.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - const wrongKindManifest = JSON.parse(readFileSync(wrongKind.baselineManifestPath, 'utf8')); + const wrongKind = createHistoryFixture('history-wrong-baseline-kind', { + nestedData: false, + }); + createReplicaHistory(wrongKind.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + const wrongKindManifest = JSON.parse( + readFileSync(wrongKind.baselineManifestPath, 'utf8'), + ); wrongKindManifest.backupKind = 'spacetimedb-history'; - writeFileSync(wrongKind.baselineManifestPath, `${JSON.stringify(wrongKindManifest)}\n`); + writeFileSync( + wrongKind.baselineManifestPath, + `${JSON.stringify(wrongKindManifest)}\n`, + ); const wrongKindResult = runHistoryDryRun(wrongKind); - assertStatus(wrongKindResult, 1, 'history archive manifest 不得被导入为 full baseline。'); - assertIncludes(wrongKindResult.stderr, 'backupKind 必须是 spacetimedb-data-dir', 'baseline 类型不匹配应失败关闭。'); + assertStatus( + wrongKindResult, + 1, + 'history archive manifest 不得被导入为 full baseline。', + ); + assertIncludes( + wrongKindResult.stderr, + 'backupKind 必须是 spacetimedb-data-dir', + 'baseline 类型不匹配应失败关闭。', + ); - const deterministic = createHistoryFixture('history-deterministic-batch', {nestedData: false}); - createReplicaHistory(deterministic.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const deterministic = createHistoryFixture('history-deterministic-batch', { + nestedData: false, + }); + createReplicaHistory(deterministic.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(deterministic); const firstResultFile = path.join(deterministic.workDir, 'defer-first.json'); - const secondResultFile = path.join(deterministic.workDir, 'defer-second.json'); - const first = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', firstResultFile]); - const second = runHistoryCommand(deterministic, ['--defer-upload', '--result-file', secondResultFile]); + const secondResultFile = path.join( + deterministic.workDir, + 'defer-second.json', + ); + const first = runHistoryCommand(deterministic, [ + '--defer-upload', + '--result-file', + firstResultFile, + ]); + const second = runHistoryCommand(deterministic, [ + '--defer-upload', + '--result-file', + secondResultFile, + ]); assertStatus(first, 0, '第一次 history defer 应成功生成归档。'); - assertStatus(second, 0, '相同候选重复 history defer 应幂等复用 batch identity。'); + assertStatus( + second, + 0, + '相同候选重复 history defer 应幂等复用 batch identity。', + ); const firstPayload = JSON.parse(readFileSync(firstResultFile, 'utf8')); const secondPayload = JSON.parse(readFileSync(secondResultFile, 'utf8')); - assertEqual(firstPayload.batchId, secondPayload.batchId, '相同 baseline 与候选必须生成确定性 batchId。'); - assertEqual(firstPayload.objectKey, secondPayload.objectKey, '相同 batch 重跑不得制造新的 OSS object key。'); - const archiveListing = spawnSync('tar', ['-tzf', firstPayload.archivePath], {encoding: 'utf8'}); + assertEqual( + firstPayload.batchId, + secondPayload.batchId, + '相同 baseline 与候选必须生成确定性 batchId。', + ); + assertEqual( + firstPayload.objectKey, + secondPayload.objectKey, + '相同 batch 重跑不得制造新的 OSS object key。', + ); + const archiveListing = spawnSync('tar', ['-tzf', firstPayload.archivePath], { + encoding: 'utf8', + }); assertStatus(archiveListing, 0, 'history 归档应可被 tar 正常读取。'); - assertIncludes(archiveListing.stdout, path.basename(firstPayload.manifestPath), 'history 归档内部必须携带安全候选 manifest。'); + assertIncludes( + archiveListing.stdout, + path.basename(firstPayload.manifestPath), + 'history 归档内部必须携带安全候选 manifest。', + ); - const dryRunPending = createHistoryFixture('history-dry-run-pending-cleanup', {nestedData: false}); - createReplicaHistory(dryRunPending.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const dryRunPending = createHistoryFixture( + 'history-dry-run-pending-cleanup', + { nestedData: false }, + ); + createReplicaHistory(dryRunPending.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const dryRunState = importHistoryState(dryRunPending); - const dryRunPlan = discoverHistoryPlan({dataDir: dryRunPending.dataDir}); + const dryRunPlan = discoverHistoryPlan({ dataDir: dryRunPending.dataDir }); dryRunState.batches.push({ batchId: 'pending-cleanup', objectKey: 'database-backups/test-db/history/pending-cleanup.tar.gz', @@ -1312,57 +2127,117 @@ function assertHistoryRequiresBaselineAndProducesDeterministicDeferredBatch() { }); writeFileSync(dryRunPending.statePath, `${JSON.stringify(dryRunState)}\n`); const pendingDryRunResult = runHistoryDryRun(dryRunPending); - assertStatus(pendingDryRunResult, 0, '存在待清理 uploaded batch 时 history dry-run 仍应只读成功。'); + assertStatus( + pendingDryRunResult, + 0, + '存在待清理 uploaded batch 时 history dry-run 仍应只读成功。', + ); for (const candidate of dryRunPlan.candidates) { - assertTrue(existsSync(path.join(dryRunPending.dataDir, candidate.path)), `history dry-run 不得恢复执行待清理 batch: ${candidate.path}`); + assertTrue( + existsSync(path.join(dryRunPending.dataDir, candidate.path)), + `history dry-run 不得恢复执行待清理 batch: ${candidate.path}`, + ); } } function assertHistoryBackupLockRejectsLiveAndStaleOwners() { - const liveOwner = createHistoryFixture('history-live-lock', {nestedData: false}); - createReplicaHistory(liveOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const liveOwner = createHistoryFixture('history-live-lock', { + nestedData: false, + }); + createReplicaHistory(liveOwner.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(liveOwner); const liveLockPath = path.join(liveOwner.workDir, 'test-db.backup.lock'); writeFileSync(liveLockPath, `${process.pid}\n`); const liveResult = runHistoryCommand(liveOwner, ['--defer-upload']); - assertStatus(liveResult, 1, '仍存活进程持有 backup lock 时必须拒绝并发备份。'); - assertIncludes(liveResult.stderr, '已有数据库备份进程持有锁', '并发备份失败应报告 lock owner pid。'); + assertStatus( + liveResult, + 1, + '仍存活进程持有 backup lock 时必须拒绝并发备份。', + ); + assertIncludes( + liveResult.stderr, + '已有数据库备份进程持有锁', + '并发备份失败应报告 lock owner pid。', + ); - const staleOwner = createHistoryFixture('history-stale-lock', {nestedData: false}); - createReplicaHistory(staleOwner.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const staleOwner = createHistoryFixture('history-stale-lock', { + nestedData: false, + }); + createReplicaHistory(staleOwner.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); importHistoryState(staleOwner); const staleLockPath = path.join(staleOwner.workDir, 'test-db.backup.lock'); writeFileSync(staleLockPath, '2147483647\n'); const staleResult = runHistoryCommand(staleOwner, ['--defer-upload']); - assertStatus(staleResult, 1, '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。'); - assertIncludes(staleResult.stderr, '拒绝自动抢锁', '失效 backup lock 应要求人工核对 multipart 与进程。'); - assertTrue(existsSync(staleLockPath), '失效 backup lock 未经人工核对不得自动删除。'); + assertStatus( + staleResult, + 1, + '失效 owner pid 的 backup lock 也必须失败关闭,避免并发抢锁。', + ); + assertIncludes( + staleResult.stderr, + '拒绝自动抢锁', + '失效 backup lock 应要求人工核对 multipart 与进程。', + ); + assertTrue( + existsSync(staleLockPath), + '失效 backup lock 未经人工核对不得自动删除。', + ); } function assertHistoryStatDriftPreventsAnyCleanup() { - const fixture = createHistoryFixture('history-stat-drift', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); - const driftCandidate = plan.candidates.find(({kind}) => kind === 'commitlog'); - const untouchedCandidate = plan.candidates.find(({kind}) => kind === 'snapshot'); - writeFileSync(path.join(fixture.dataDir, driftCandidate.path), 'changed-after-plan'); + const fixture = createHistoryFixture('history-stat-drift', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); + const driftCandidate = plan.candidates.find( + ({ kind }) => kind === 'commitlog', + ); + const untouchedCandidate = plan.candidates.find( + ({ kind }) => kind === 'snapshot', + ); + writeFileSync( + path.join(fixture.dataDir, driftCandidate.path), + 'changed-after-plan', + ); assertThrows( - () => cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}), + () => + cleanupHistoryCandidates({ + dataDir: fixture.dataDir, + candidates: plan.candidates, + }), 'stat 漂移', '任一候选 stat 漂移时必须在删除任何文件前失败。', ); - assertTrue(existsSync(path.join(fixture.dataDir, untouchedCandidate.path)), 'stat 漂移失败时不得删除其他候选。'); + assertTrue( + existsSync(path.join(fixture.dataDir, untouchedCandidate.path)), + 'stat 漂移失败时不得删除其他候选。', + ); } async function assertHistoryUploadFailureDoesNotDeleteSources() { - const fixture = createHistoryFixture('history-upload-failure', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-upload-failure', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); const archivePath = path.join(fixture.workDir, 'history.tar.gz'); const manifestPath = `${archivePath}.manifest.json`; writeFileSync(archivePath, 'history archive'); - const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + const manifest = createHistoryManifest({ fixture, state, plan, archivePath }); writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); let uploadError = null; @@ -1383,18 +2258,40 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { } catch (error) { uploadError = error; } - assertIncludes(uploadError?.message ?? '', 'synthetic upload failure', 'history 应保留上传失败原因。'); + assertIncludes( + uploadError?.message ?? '', + 'synthetic upload failure', + 'history 应保留上传失败原因。', + ); for (const candidate of plan.candidates) { - assertTrue(existsSync(path.join(fixture.dataDir, candidate.path)), `上传失败不得删除 history 源文件: ${candidate.path}`); + assertTrue( + existsSync(path.join(fixture.dataDir, candidate.path)), + `上传失败不得删除 history 源文件: ${candidate.path}`, + ); } const stateAfterFailure = JSON.parse(readFileSync(fixture.statePath, 'utf8')); - assertEqual(stateAfterFailure.batches.length, 0, '上传失败不得把 batch 标记为 uploaded。'); + assertEqual( + stateAfterFailure.batches.length, + 0, + '上传失败不得把 batch 标记为 uploaded。', + ); - const manifestFailure = createHistoryFixture('history-manifest-upload-failure', {nestedData: false}); - createReplicaHistory(manifestFailure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const manifestFailure = createHistoryFixture( + 'history-manifest-upload-failure', + { nestedData: false }, + ); + createReplicaHistory(manifestFailure.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const manifestFailureState = importHistoryState(manifestFailure); - const manifestFailurePlan = discoverHistoryPlan({dataDir: manifestFailure.dataDir}); - const manifestFailureArchive = path.join(manifestFailure.workDir, 'history.tar.gz'); + const manifestFailurePlan = discoverHistoryPlan({ + dataDir: manifestFailure.dataDir, + }); + const manifestFailureArchive = path.join( + manifestFailure.workDir, + 'history.tar.gz', + ); const manifestFailurePath = `${manifestFailureArchive}.manifest.json`; writeFileSync(manifestFailureArchive, 'history archive'); const manifestFailurePayload = createHistoryManifest({ @@ -1403,7 +2300,10 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { plan: manifestFailurePlan, archivePath: manifestFailureArchive, }); - writeFileSync(manifestFailurePath, `${JSON.stringify(manifestFailurePayload)}\n`); + writeFileSync( + manifestFailurePath, + `${JSON.stringify(manifestFailurePayload)}\n`, + ); let manifestUploadError = null; try { await uploadHistoryArchiveWithCleanup({ @@ -1426,21 +2326,33 @@ async function assertHistoryUploadFailureDoesNotDeleteSources() { } catch (error) { manifestUploadError = error; } - assertIncludes(manifestUploadError?.message ?? '', 'synthetic manifest upload failure', 'sidecar manifest 上传失败应阻断清理。'); + assertIncludes( + manifestUploadError?.message ?? '', + 'synthetic manifest upload failure', + 'sidecar manifest 上传失败应阻断清理。', + ); for (const candidate of manifestFailurePlan.candidates) { - assertTrue(existsSync(path.join(manifestFailure.dataDir, candidate.path)), `manifest 上传失败不得删除源文件: ${candidate.path}`); + assertTrue( + existsSync(path.join(manifestFailure.dataDir, candidate.path)), + `manifest 上传失败不得删除源文件: ${candidate.path}`, + ); } } async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { - const fixture = createHistoryFixture('history-upload-success', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-upload-success', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); const archivePath = path.join(fixture.workDir, 'history.tar.gz'); const manifestPath = `${archivePath}.manifest.json`; writeFileSync(archivePath, 'history archive'); - const manifest = createHistoryManifest({fixture, state, plan, archivePath}); + const manifest = createHistoryManifest({ fixture, state, plan, archivePath }); writeFileSync(manifestPath, `${JSON.stringify(manifest)}\n`); let baselineVerifyCount = 0; const result = await uploadHistoryArchiveWithCleanup({ @@ -1460,7 +2372,7 @@ async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { partSizeBytes: 102400, verifiedAt: '2026-07-16T00:10:00.000Z', }), - manifestUploadFn: async ({objectKey}) => ({ + manifestUploadFn: async ({ objectKey }) => ({ objectKey, contentLength: 512, archiveSha256: 'd'.repeat(64), @@ -1468,30 +2380,54 @@ async function assertHistorySuccessfulUploadCleansAndIsIdempotent() { }), verifyFn: async () => { baselineVerifyCount += 1; - return {verifiedAt: '2026-07-16T00:10:02.000Z'}; + return { verifiedAt: '2026-07-16T00:10:02.000Z' }; }, }); - assertEqual(baselineVerifyCount, 2, 'history 删除源文件前必须重新验真 full baseline 与 sidecar。'); - assertEqual(result.cleanup.deletedCount, plan.candidates.length, '验真上传成功后应删除全部安全候选。'); + assertEqual( + baselineVerifyCount, + 2, + 'history 删除源文件前必须重新验真 full baseline 与 sidecar。', + ); + assertEqual( + result.cleanup.deletedCount, + plan.candidates.length, + '验真上传成功后应删除全部安全候选。', + ); for (const candidate of plan.candidates) { - assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `验真成功后应删除 history 源文件: ${candidate.path}`); + assertTrue( + !existsSync(path.join(fixture.dataDir, candidate.path)), + `验真成功后应删除 history 源文件: ${candidate.path}`, + ); } - const repeatedCleanup = cleanupHistoryCandidates({dataDir: fixture.dataDir, candidates: plan.candidates}); - assertEqual(repeatedCleanup.alreadyMissingCount, plan.candidates.length, '重复清理同一 uploaded batch 应幂等。'); + const repeatedCleanup = cleanupHistoryCandidates({ + dataDir: fixture.dataDir, + candidates: plan.candidates, + }); + assertEqual( + repeatedCleanup.alreadyMissingCount, + plan.candidates.length, + '重复清理同一 uploaded batch 应幂等。', + ); } async function assertHistoryResumeReverifiesArchiveAndManifest() { - const fixture = createHistoryFixture('history-resume-verification', {nestedData: false}); - createReplicaHistory(fixture.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const fixture = createHistoryFixture('history-resume-verification', { + nestedData: false, + }); + createReplicaHistory(fixture.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const state = importHistoryState(fixture); - const plan = discoverHistoryPlan({dataDir: fixture.dataDir}); + const plan = discoverHistoryPlan({ dataDir: fixture.dataDir }); state.batches.push({ batchId: 'resume-batch', objectKey: 'database-backups/test-db/history/resume.tar.gz', contentLength: 100, archiveSha256: 'e'.repeat(64), verifiedAt: '2026-07-16T00:30:00.000Z', - manifestObjectKey: 'database-backups/test-db/history/resume.tar.gz.manifest.json', + manifestObjectKey: + 'database-backups/test-db/history/resume.tar.gz.manifest.json', manifestContentLength: 200, manifestArchiveSha256: 'f'.repeat(64), manifestVerifiedAt: '2026-07-16T00:30:01.000Z', @@ -1506,21 +2442,36 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() { state, dataDir: fixture.dataDir, verificationOptions: {}, - verifyFn: async ({objectKey}) => { + verifyFn: async ({ objectKey }) => { verifiedKeys.push(objectKey); - return {verifiedAt: '2026-07-16T00:31:00.000Z'}; + return { verifiedAt: '2026-07-16T00:31:00.000Z' }; }, }); - assertEqual(verifiedKeys.length, 2, '续清理前必须重新验真 history archive 与 sidecar manifest。'); + assertEqual( + verifiedKeys.length, + 2, + '续清理前必须重新验真 history archive 与 sidecar manifest。', + ); for (const candidate of plan.candidates) { - assertTrue(!existsSync(path.join(fixture.dataDir, candidate.path)), `续清理验真后应删除候选: ${candidate.path}`); + assertTrue( + !existsSync(path.join(fixture.dataDir, candidate.path)), + `续清理验真后应删除候选: ${candidate.path}`, + ); } - const failure = createHistoryFixture('history-resume-verification-failure', {nestedData: false}); - createReplicaHistory(failure.replicasDir, '1', {snapshots: [0, 10], segments: [0, 1, 11]}); + const failure = createHistoryFixture('history-resume-verification-failure', { + nestedData: false, + }); + createReplicaHistory(failure.replicasDir, '1', { + snapshots: [0, 10], + segments: [0, 1, 11], + }); const failureState = importHistoryState(failure); - const failurePlan = discoverHistoryPlan({dataDir: failure.dataDir}); - failureState.batches.push({...state.batches[0], candidates: failurePlan.candidates}); + const failurePlan = discoverHistoryPlan({ dataDir: failure.dataDir }); + failureState.batches.push({ + ...state.batches[0], + candidates: failurePlan.candidates, + }); writeFileSync(failure.statePath, `${JSON.stringify(failureState)}\n`); let resumeError = null; try { @@ -1536,87 +2487,140 @@ async function assertHistoryResumeReverifiesArchiveAndManifest() { } catch (error) { resumeError = error; } - assertIncludes(resumeError?.message ?? '', 'synthetic resume HEAD failure', '续清理 OSS 复核失败应保留错误。'); + assertIncludes( + resumeError?.message ?? '', + 'synthetic resume HEAD failure', + '续清理 OSS 复核失败应保留错误。', + ); for (const candidate of failurePlan.candidates) { - assertTrue(existsSync(path.join(failure.dataDir, candidate.path)), `续清理验真失败不得删除候选: ${candidate.path}`); + assertTrue( + existsSync(path.join(failure.dataDir, candidate.path)), + `续清理验真失败不得删除候选: ${candidate.path}`, + ); } } -function createHistoryFixture(name, {nestedData}) { +function createHistoryFixture(name, { nestedData }) { const root = path.join(tmpRoot, name); const dataDir = path.join(root, 'stdb'); - const replicasDir = nestedData ? path.join(dataDir, 'data', 'replicas') : path.join(dataDir, 'replicas'); + const replicasDir = nestedData + ? path.join(dataDir, 'data', 'replicas') + : path.join(dataDir, 'replicas'); const workDir = path.join(root, 'work'); const statePath = path.join(workDir, 'history-state.json'); const baselineManifestPath = path.join(workDir, 'baseline.manifest.json'); - mkdirSync(replicasDir, {recursive: true}); - mkdirSync(workDir, {recursive: true}); - writeFileSync(baselineManifestPath, `${JSON.stringify({ - backupKind: 'spacetimedb-data-dir', - uploadStatus: 'uploaded', - database: 'test-db', + mkdirSync(replicasDir, { recursive: true }); + mkdirSync(workDir, { recursive: true }); + writeFileSync( + baselineManifestPath, + `${JSON.stringify( + { + backupKind: 'spacetimedb-data-dir', + uploadStatus: 'uploaded', + database: 'test-db', + dataDir, + bucket: 'backup-bucket', + objectKey: 'database-backups/test-db/baseline.tar.gz', + manifestObjectKey: + 'database-backups/test-db/baseline.tar.gz.manifest.json', + contentLength: 1234, + archiveSha256: 'a'.repeat(64), + manifestContentLength: 512, + manifestArchiveSha256: '9'.repeat(64), + manifestVerifiedAt: '2026-07-16T00:00:00.500Z', + verifiedAt: '2026-07-16T00:00:00.000Z', + uploadedAt: '2026-07-16T00:00:01.000Z', + }, + null, + 2, + )}\n`, + ); + return { + root, dataDir, - bucket: 'backup-bucket', - objectKey: 'database-backups/test-db/baseline.tar.gz', - manifestObjectKey: 'database-backups/test-db/baseline.tar.gz.manifest.json', - contentLength: 1234, - archiveSha256: 'a'.repeat(64), - manifestContentLength: 512, - manifestArchiveSha256: '9'.repeat(64), - manifestVerifiedAt: '2026-07-16T00:00:00.500Z', - verifiedAt: '2026-07-16T00:00:00.000Z', - uploadedAt: '2026-07-16T00:00:01.000Z', - }, null, 2)}\n`); - return {root, dataDir, replicasDir, workDir, statePath, baselineManifestPath}; + replicasDir, + workDir, + statePath, + baselineManifestPath, + }; } -function createReplicaHistory(replicasDir, replicaId, {snapshots, segments}) { +function createReplicaHistory(replicasDir, replicaId, { snapshots, segments }) { const replicaDir = path.join(replicasDir, replicaId); const snapshotsDir = path.join(replicaDir, 'snapshots'); const clogDir = path.join(replicaDir, 'clog'); - mkdirSync(snapshotsDir, {recursive: true}); - mkdirSync(clogDir, {recursive: true}); + mkdirSync(snapshotsDir, { recursive: true }); + mkdirSync(clogDir, { recursive: true }); for (const transaction of snapshots) { const name = `${String(transaction).padStart(20, '0')}.snapshot_dir`; const snapshotDir = path.join(snapshotsDir, name); - mkdirSync(path.join(snapshotDir, 'objects'), {recursive: true}); - writeFileSync(path.join(snapshotDir, `${String(transaction).padStart(20, '0')}.snapshot_bsatn`), `snapshot-${transaction}`); - writeFileSync(path.join(snapshotDir, 'objects', 'object.bin'), `object-${transaction}`); + mkdirSync(path.join(snapshotDir, 'objects'), { recursive: true }); + writeFileSync( + path.join( + snapshotDir, + `${String(transaction).padStart(20, '0')}.snapshot_bsatn`, + ), + `snapshot-${transaction}`, + ); + writeFileSync( + path.join(snapshotDir, 'objects', 'object.bin'), + `object-${transaction}`, + ); } for (const transaction of segments) { const prefix = String(transaction).padStart(20, '0'); - writeFileSync(path.join(clogDir, `${prefix}.stdb.log`), `log-${transaction}`); - writeFileSync(path.join(clogDir, `${prefix}.stdb.ofs`), `ofs-${transaction}`); + writeFileSync( + path.join(clogDir, `${prefix}.stdb.log`), + `log-${transaction}`, + ); + writeFileSync( + path.join(clogDir, `${prefix}.stdb.ofs`), + `ofs-${transaction}`, + ); } - return {replicaDir, snapshotsDir, clogDir}; + return { replicaDir, snapshotsDir, clogDir }; } function runHistoryDryRun(fixture) { const resultFile = path.join(fixture.workDir, 'dry-run-result.json'); - return runHistoryCommand(fixture, [ - '--result-file', resultFile, - '--dry-run', - ]); + return runHistoryCommand(fixture, ['--result-file', resultFile, '--dry-run']); } -function runHistoryCommand(fixture, extraArgs = [], {includeBaselineManifest = true} = {}) { +function runHistoryCommand( + fixture, + extraArgs = [], + { includeBaselineManifest = true } = {}, +) { const baselineManifestArgs = includeBaselineManifest ? ['--baseline-manifest', fixture.baselineManifestPath] : []; - return spawnSync(process.execPath, [ - BACKUP_SCRIPT, - '--mode', 'history', - '--data-dir', fixture.dataDir, - '--work-dir', fixture.workDir, - '--database', 'test-db', - '--bucket', 'backup-bucket', - '--endpoint', 'oss-cn-shanghai.aliyuncs.com', - '--access-key-id', 'test-access-key', - '--access-key-secret', 'test-access-secret', - '--baseline-state', fixture.statePath, - ...baselineManifestArgs, - ...extraArgs, - ], {encoding: 'utf8'}); + return spawnSync( + process.execPath, + [ + BACKUP_SCRIPT, + '--mode', + 'history', + '--data-dir', + fixture.dataDir, + '--work-dir', + fixture.workDir, + '--database', + 'test-db', + '--bucket', + 'backup-bucket', + '--endpoint', + 'oss-cn-shanghai.aliyuncs.com', + '--access-key-id', + 'test-access-key', + '--access-key-secret', + 'test-access-secret', + '--baseline-state', + fixture.statePath, + ...baselineManifestArgs, + ...extraArgs, + ], + { encoding: 'utf8' }, + ); } function importHistoryState(fixture) { @@ -1625,7 +2629,7 @@ function importHistoryState(fixture) { return JSON.parse(readFileSync(fixture.statePath, 'utf8')); } -function createHistoryManifest({fixture, state, plan, archivePath}) { +function createHistoryManifest({ fixture, state, plan, archivePath }) { return { schemaVersion: 1, backupKind: 'spacetimedb-history', @@ -1666,9 +2670,13 @@ function createFixture(name) { const workDir = path.join(root, 'work'); const systemctlLog = path.join(root, 'systemctl.log'); const tarLog = path.join(root, 'tar.log'); - mkdirSync(binDir, {recursive: true}); - mkdirSync(dataDir, {recursive: true}); - writeFileSync(path.join(dataDir, 'sample.bin'), 'sample backup payload\n', 'utf8'); + mkdirSync(binDir, { recursive: true }); + mkdirSync(dataDir, { recursive: true }); + writeFileSync( + path.join(dataDir, 'sample.bin'), + 'sample backup payload\n', + 'utf8', + ); writeExecutable( path.join(binDir, 'systemctl'), `#!/usr/bin/env bash @@ -1684,7 +2692,7 @@ echo 'fake tar failure' >&2 exit 2 `, ); - return {root, binDir, dataDir, workDir, systemctlLog, tarLog}; + return { root, binDir, dataDir, workDir, systemctlLog, tarLog }; } function runBackup(fixture, extraArgs = []) { @@ -1719,7 +2727,7 @@ function runBackup(fixture, extraArgs = []) { function writeExecutable(filePath, content) { writeFileSync(filePath, content, 'utf8'); - spawnSync('chmod', ['0755', filePath], {encoding: 'utf8'}); + spawnSync('chmod', ['0755', filePath], { encoding: 'utf8' }); } function readFile(filePath) { @@ -1743,7 +2751,9 @@ function assertIncludes(content, expected, reason) { function assertEqual(actual, expected, reason) { if (actual !== expected) { - failures.push(`${reason} 预期: ${String(expected)},实际: ${String(actual)}`); + failures.push( + `${reason} 预期: ${String(expected)},实际: ${String(actual)}`, + ); } } @@ -1775,7 +2785,9 @@ function assertThrows(callback, expectedMessage, reason) { function assertBufferEqual(actual, expected, reason) { if (!Buffer.isBuffer(actual) || !actual.equals(expected)) { - failures.push(`${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`); + failures.push( + `${reason} 预期 ${expected.length} bytes,实际 ${actual?.length ?? ''} bytes。`, + ); } } diff --git a/scripts/check-production-ops-guardrails.mjs b/scripts/check-production-ops-guardrails.mjs index bacd4b949..15fa3b1ad 100644 --- a/scripts/check-production-ops-guardrails.mjs +++ b/scripts/check-production-ops-guardrails.mjs @@ -973,12 +973,14 @@ const checks = [ { file: 'scripts/database-backup-to-oss.mjs', includes: 'assertSufficientWorkDirSpace({dataDir, workDir, args, env})', + normalizeWhitespace: true, reason: '生产冷备份必须先做工作目录剩余空间预检,避免停库后写满磁盘。', }, { file: 'scripts/database-backup-to-oss.mjs', includes: 'restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath})', + normalizeWhitespace: true, reason: '生产冷备份打包失败时也必须恢复 SpacetimeDB 及依赖服务。', }, { @@ -7504,9 +7506,23 @@ const databaseTargetSourceStashes = [ let failed = false; +function includesGuardrail(content, check) { + if (!check.includes) { + return true; + } + if (!check.normalizeWhitespace) { + return content.includes(check.includes); + } + const normalizeSourceFragment = (value) => + value.replace(/\s+/gu, '').replace(/,([})])/gu, '$1'); + const normalizedContent = normalizeSourceFragment(content); + const normalizedExpected = normalizeSourceFragment(check.includes); + return normalizedContent.includes(normalizedExpected); +} + for (const check of checks) { const content = readFileSync(check.file, 'utf8'); - if (check.includes && !content.includes(check.includes)) { + if (!includesGuardrail(content, check)) { failed = true; console.error( `[check:production-ops] ${check.file} 缺少 ${check.includes}。${check.reason}`, diff --git a/scripts/container-worker-smoke.mjs b/scripts/container-worker-smoke.mjs index abbeea6ce..881224570 100644 --- a/scripts/container-worker-smoke.mjs +++ b/scripts/container-worker-smoke.mjs @@ -1,4 +1,4 @@ -import {spawn} from 'node:child_process'; +import { spawn } from 'node:child_process'; import { chmodSync, copyFileSync, @@ -13,7 +13,11 @@ import path from 'node:path'; const [, , rawCommand = 'help', ...rawArgs] = process.argv; const projectRoot = process.cwd(); -const composeFile = path.join('deploy', 'container', 'docker-compose.loadtest.yml'); +const composeFile = path.join( + 'deploy', + 'container', + 'docker-compose.loadtest.yml', +); const smokeDir = path.join('deploy', 'container', 'worker-smoke'); const envPath = path.join(smokeDir, 'api-server.env'); const statePath = path.join(smokeDir, 'state.json'); @@ -22,13 +26,17 @@ const localImageDockerfilePath = path.join(localImageDir, 'Dockerfile.local'); const localImageBinaryPath = path.join(localImageDir, 'api-server'); const localCargoTargetDir = path.join('server-rs', 'target-worker-smoke'); const localSpacetimeImageDir = path.join(smokeDir, 'spacetimedb-image'); -const localSpacetimeDockerfilePath = path.join(localSpacetimeImageDir, 'Dockerfile.local'); +const localSpacetimeDockerfilePath = path.join( + localSpacetimeImageDir, + 'Dockerfile.local', +); const localSpacetimeBinaryPath = path.join(localSpacetimeImageDir, 'spacetime'); const localSpacetimeStandalonePath = path.join( localSpacetimeImageDir, 'spacetimedb-standalone', ); -const projectName = process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke'; +const projectName = + process.env.GENARRATIVE_WORKER_SMOKE_PROJECT || 'genarrative-worker-smoke'; const defaultDatabase = process.env.GENARRATIVE_WORKER_SMOKE_DATABASE || 'genarrative-worker-smoke'; @@ -68,7 +76,7 @@ async function main() { printHelp(false); return; case 'init': - await ensureStateAndEnv({force: rawArgs.includes('--force')}); + await ensureStateAndEnv({ force: rawArgs.includes('--force') }); return; case 'build': await ensureStateAndEnv(); @@ -99,7 +107,7 @@ async function main() { return; case 'api-update': await ensureStateAndEnv(); - await apiOnlyUpdate({build: rawArgs.includes('--build')}); + await apiOnlyUpdate({ build: rawArgs.includes('--build') }); return; case 'scale': await ensureStateAndEnv(); @@ -114,7 +122,7 @@ async function main() { await dockerCompose(['ps', ...rawArgs]); return; case 'down': - await ensureStateAndEnv({create: false}); + await ensureStateAndEnv({ create: false }); await dockerCompose(['down', ...rawArgs]); return; case 'smoke': @@ -128,9 +136,9 @@ async function main() { async function runSmoke() { if (rawArgs.includes('--force')) { await ensureStateAndEnv(); - await dockerComposeCapture(['down', '-v'], {allowFailure: true}); + await dockerComposeCapture(['down', '-v'], { allowFailure: true }); } - const state = await ensureStateAndEnv({force: rawArgs.includes('--force')}); + const state = await ensureStateAndEnv({ force: rawArgs.includes('--force') }); await assertSavedPortsAvailableForNewProject(state); console.log( `[worker-smoke] 使用隔离环境 project=${projectName} database=${state.database}`, @@ -147,10 +155,10 @@ async function runSmoke() { const beforeWorkerIds = await getContainerIds('external-generation-worker'); console.log(`[worker-smoke] worker 容器: ${beforeWorkerIds.join(', ')}`); - const firstJobId = await enqueueSmokeJob({label: 'before-api-update'}); + const firstJobId = await enqueueSmokeJob({ label: 'before-api-update' }); await waitForJobConsumed(firstJobId); - await apiOnlyUpdate({build: false}); + await apiOnlyUpdate({ build: false }); const afterWorkerIds = await getContainerIds('external-generation-worker'); if (beforeWorkerIds.join('\n') !== afterWorkerIds.join('\n')) { throw new Error( @@ -159,10 +167,12 @@ async function runSmoke() { } console.log('[worker-smoke] api-only 更新未重建 worker 容器。'); - const secondJobId = await enqueueSmokeJob({label: 'after-api-update'}); + const secondJobId = await enqueueSmokeJob({ label: 'after-api-update' }); await waitForJobConsumed(secondJobId); await printQueueStatus(); - console.log('[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。'); + console.log( + '[worker-smoke] smoke 通过:worker 独立消费队列,API-only 更新未停止 worker。', + ); } async function buildRuntimeImages() { @@ -196,13 +206,19 @@ async function buildLocalBinaryRuntimeImages() { process.env.GENARRATIVE_WORKER_SMOKE_CARGO_PROFILE === 'release' ? 'release' : 'debug'; - const buildArgs = ['build', '-p', 'api-server', '--manifest-path', 'server-rs/Cargo.toml']; + const buildArgs = [ + 'build', + '-p', + 'api-server', + '--manifest-path', + 'server-rs/Cargo.toml', + ]; if (profile === 'release') { buildArgs.push('--release'); } const cargoImage = resolveLocalBinaryCargoImage(); const cargoHome = resolveLocalBinaryCargoHome(); - mkdirSync(cargoHome, {recursive: true}); + mkdirSync(cargoHome, { recursive: true }); console.log( `[worker-smoke] 使用 ${cargoImage} 复用本机 Cargo 缓存构建 ${profile} api-server 二进制。`, @@ -235,17 +251,27 @@ async function buildLocalBinaryRuntimeImages() { ...buildArgs, ]); - const sourceBinaryPath = path.join(localCargoTargetDir, profile, 'api-server'); + const sourceBinaryPath = path.join( + localCargoTargetDir, + profile, + 'api-server', + ); if (!existsSync(sourceBinaryPath)) { - throw new Error(`未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`); + throw new Error( + `未找到 worker smoke api-server 二进制: ${sourceBinaryPath}`, + ); } - mkdirSync(localImageDir, {recursive: true}); + mkdirSync(localImageDir, { recursive: true }); copyFileSync(sourceBinaryPath, localImageBinaryPath); chmodSync(localImageBinaryPath, 0o755); const baseImage = await resolveLocalBinaryBaseImage(); - writeFileSync(localImageDockerfilePath, buildLocalBinaryDockerfile(baseImage), 'utf8'); + writeFileSync( + localImageDockerfilePath, + buildLocalBinaryDockerfile(baseImage), + 'utf8', + ); await run('docker', [ 'build', @@ -260,7 +286,9 @@ async function buildLocalBinaryRuntimeImages() { } function resolveLocalBinaryCargoImage() { - return process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm'; + return ( + process.env.GENARRATIVE_WORKER_SMOKE_CARGO_IMAGE || 'rust:1.93-bookworm' + ); } function resolveLocalBinaryCargoHome() { @@ -274,42 +302,62 @@ function resolveLocalBinaryCargoHome() { } function currentUserSpec() { - if (typeof process.getuid === 'function' && typeof process.getgid === 'function') { + if ( + typeof process.getuid === 'function' && + typeof process.getgid === 'function' + ) { return `${process.getuid()}:${process.getgid()}`; } return '0:0'; } async function ensureSpacetimeImage() { - if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official') { + if ( + process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_IMAGE_MODE === 'official' + ) { return; } const imageName = localSpacetimeImageName(); - const existingImage = await runCapture('docker', ['image', 'inspect', imageName], { - allowFailure: true, - quiet: true, - }); + const existingImage = await runCapture( + 'docker', + ['image', 'inspect', imageName], + { + allowFailure: true, + quiet: true, + }, + ); if (existingImage.code === 0 && !rawArgs.includes('--force')) { return; } const spacetimePath = await resolveSpacetimeBinaryPath(); if (!spacetimePath) { - throw new Error('未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。'); + throw new Error( + '未找到本机 spacetime CLI,无法构建隔离 SpacetimeDB 镜像。', + ); } - mkdirSync(localSpacetimeImageDir, {recursive: true}); + mkdirSync(localSpacetimeImageDir, { recursive: true }); copyFileSync(spacetimePath, localSpacetimeBinaryPath); chmodSync(localSpacetimeBinaryPath, 0o755); - const standalonePath = path.join(path.dirname(spacetimePath), 'spacetimedb-standalone'); + const standalonePath = path.join( + path.dirname(spacetimePath), + 'spacetimedb-standalone', + ); if (!existsSync(standalonePath)) { throw new Error(`未找到本机 spacetimedb-standalone: ${standalonePath}`); } copyFileSync(standalonePath, localSpacetimeStandalonePath); chmodSync(localSpacetimeStandalonePath, 0o755); - writeFileSync(localSpacetimeDockerfilePath, buildLocalSpacetimeDockerfile(), 'utf8'); + writeFileSync( + localSpacetimeDockerfilePath, + buildLocalSpacetimeDockerfile(), + 'utf8', + ); - console.log(`[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`); + console.log( + `[worker-smoke] 使用本机 spacetime CLI 构建隔离镜像: ${imageName}`, + ); await run('docker', [ 'build', '-f', @@ -337,12 +385,14 @@ async function resolveSpacetimeBinaryPath() { if (process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN) { return process.env.GENARRATIVE_WORKER_SMOKE_SPACETIME_BIN; } - const versionResult = await runCapture('spacetime', ['--version'], {quiet: true}); + const versionResult = await runCapture('spacetime', ['--version'], { + quiet: true, + }); const pathMatch = versionResult.stdout.match(/^spacetime Path:\s*(.+)$/mu); if (pathMatch?.[1]) { return pathMatch[1].trim(); } - const whichResult = await runCapture('which', ['spacetime'], {quiet: true}); + const whichResult = await runCapture('which', ['spacetime'], { quiet: true }); return whichResult.stdout.trim(); } @@ -387,11 +437,11 @@ async function upRuntime() { } async function ensureStateAndEnv(options = {}) { - const {force = false, create = true} = options; + const { force = false, create = true } = options; if (!create && !existsSync(statePath)) { return defaultState(); } - mkdirSync(smokeDir, {recursive: true}); + mkdirSync(smokeDir, { recursive: true }); if (!existsSync(statePath) || force) { const state = { @@ -419,7 +469,9 @@ async function ensureStateAndEnv(options = {}) { } console.log(`[worker-smoke] env=${envPath}`); console.log(`[worker-smoke] state=${statePath}`); - console.log(`[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`); + console.log( + `[worker-smoke] SpacetimeDB=http://127.0.0.1:${state.spacetimePort}`, + ); console.log(`[worker-smoke] Nginx=http://127.0.0.1:${state.httpPort}`); return state; } @@ -552,7 +604,7 @@ async function enqueueSmokeJob(options = {}) { source_module: 'editor-canvas', source_entity_id: `worker-smoke-entity-${suffix}`, request_label: `worker-smoke ${label}`, - request_payload_json: JSON.stringify({label, suffix}), + request_payload_json: JSON.stringify({ label, suffix }), max_attempts: 1, available_at_micros: nowMicros, created_at_micros: nowMicros, @@ -574,7 +626,9 @@ async function enqueueSmokeJob(options = {}) { } async function printQueueStatus() { - console.log('[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:'); + console.log( + '[worker-smoke] external_generation_job 是 private table,status 显示最近 worker 日志:', + ); await printServiceLogs('external-generation-worker', 120); } @@ -584,17 +638,24 @@ async function waitForJobConsumed(jobId) { while (Date.now() < deadline) { const result = await dockerComposeCapture( ['logs', '--no-color', 'external-generation-worker'], - {allowFailure: true, quiet: true}, + { allowFailure: true, quiet: true }, ); lastOutput = `${result.stdout}\n${result.stderr}`; - if (lastOutput.includes(jobId) && lastOutput.includes('暂不支持的任务类型')) { - console.log(`[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`); + if ( + lastOutput.includes(jobId) && + lastOutput.includes('暂不支持的任务类型') + ) { + console.log( + `[worker-smoke] job ${jobId} 已被 worker 领取并执行到 unsupported 分支。`, + ); return; } await sleep(1000); } await printServiceLogs('external-generation-worker', 120); - throw new Error(`等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`); + throw new Error( + `等待 worker 消费 job ${jobId} 超时,最后输出:\n${lastOutput}`, + ); } async function assertSavedPortsAvailableForNewProject(state) { @@ -634,7 +695,7 @@ async function getProjectContainerIds() { async function assertWorkersRunning() { const result = await dockerComposeCapture( ['ps', '--status', 'running', '-q', 'external-generation-worker'], - {allowFailure: true, quiet: true}, + { allowFailure: true, quiet: true }, ); const workerIds = result.stdout .split(/\r?\n/u) @@ -644,7 +705,9 @@ async function assertWorkersRunning() { return; } await printServiceLogs('external-generation-worker', 80); - throw new Error('external-generation-worker 未处于 running 状态,已输出最近日志。'); + throw new Error( + 'external-generation-worker 未处于 running 状态,已输出最近日志。', + ); } async function printServiceLogs(service, tail = 80) { @@ -663,8 +726,15 @@ async function waitForApi() { const deadline = Date.now() + 120_000; while (Date.now() < deadline) { const result = await dockerComposeCapture( - ['exec', '-T', 'api-server', 'curl', '-fsS', 'http://127.0.0.1:8082/healthz'], - {allowFailure: true, quiet: true}, + [ + 'exec', + '-T', + 'api-server', + 'curl', + '-fsS', + 'http://127.0.0.1:8082/healthz', + ], + { allowFailure: true, quiet: true }, ); if (result.code === 0) { console.log('[worker-smoke] api-server 已就绪: api-server:8082/healthz'); @@ -690,7 +760,7 @@ async function waitForHttp(url, label) { throw new Error(`${label} 等待超时: ${url}`); } -async function apiOnlyUpdate({build}) { +async function apiOnlyUpdate({ build }) { const beforeWorkerIds = await getContainerIds('external-generation-worker'); const args = ['up', '-d', '--no-deps', '--force-recreate']; if (build) { @@ -730,7 +800,7 @@ async function getContainerIds(service) { } async function dockerCompose(args) { - await run('docker', composeArgs(args), {env: composeEnv()}); + await run('docker', composeArgs(args), { env: composeEnv() }); } async function dockerComposeCapture(args, options = {}) { @@ -750,7 +820,8 @@ function composeEnv() { ...process.env, GENARRATIVE_CONTAINER_API_ENV_FILE: './worker-smoke/api-server.env', GENARRATIVE_CONTAINER_SPACETIME_IMAGE: - process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || localSpacetimeImageName(), + process.env.GENARRATIVE_CONTAINER_SPACETIME_IMAGE || + localSpacetimeImageName(), GENARRATIVE_CONTAINER_SPACETIME_PORT: String(state.spacetimePort), GENARRATIVE_CONTAINER_HTTP_PORT: String(state.httpPort), GENARRATIVE_CONTAINER_OTLP_GRPC_PORT: String(state.otlpGrpcPort), @@ -773,7 +844,9 @@ function sleep(ms) { async function run(commandName, args, options = {}) { const result = await runCapture(commandName, args, options); if (result.code !== 0 && !options.allowFailure) { - throw new Error(`${commandName} ${args.join(' ')} 失败,exit=${result.code}`); + throw new Error( + `${commandName} ${args.join(' ')} 失败,exit=${result.code}`, + ); } return result; } @@ -807,7 +880,7 @@ function runCapture(commandName, args, options = {}) { reject(new Error(`${commandName} 被信号终止: ${signal}`)); return; } - resolve({code: code ?? 0, stdout, stderr}); + resolve({ code: code ?? 0, stdout, stderr }); }); }); } diff --git a/scripts/database-backup-to-oss.mjs b/scripts/database-backup-to-oss.mjs index f3788301a..c64e8657f 100644 --- a/scripts/database-backup-to-oss.mjs +++ b/scripts/database-backup-to-oss.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import {spawnSync} from 'node:child_process'; -import {createHash, createHmac} from 'node:crypto'; +import { spawnSync } from 'node:child_process'; +import { createHash, createHmac } from 'node:crypto'; import { chmodSync, closeSync, @@ -21,21 +21,38 @@ import { symlinkSync, writeFileSync, } from 'node:fs'; -import {basename, dirname, isAbsolute, join, relative, resolve, sep} from 'node:path'; -import {Readable} from 'node:stream'; -import {pipeline} from 'node:stream/promises'; -import {setTimeout as sleep} from 'node:timers/promises'; -import {fileURLToPath} from 'node:url'; -import {gunzipSync, gzipSync} from 'node:zlib'; +import { + basename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from 'node:path'; +import { Readable } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { setTimeout as sleep } from 'node:timers/promises'; +import { fileURLToPath } from 'node:url'; +import { gunzipSync, gzipSync } from 'node:zlib'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const REPO_ROOT = resolve(__dirname, '..'); -const DEFAULT_LOCAL_DATA_DIR = resolve(REPO_ROOT, 'server-rs/.spacetimedb/local/data'); -const DEFAULT_LOCAL_WORK_DIR = resolve(REPO_ROOT, 'server-rs/.data/database-backups'); +const DEFAULT_LOCAL_DATA_DIR = resolve( + REPO_ROOT, + 'server-rs/.spacetimedb/local/data', +); +const DEFAULT_LOCAL_WORK_DIR = resolve( + REPO_ROOT, + 'server-rs/.data/database-backups', +); const DEFAULT_PRODUCTION_DATA_DIR = '/stdb'; const DEFAULT_PRODUCTION_WORK_DIR = '/var/lib/genarrative/database-backups'; -const DEFAULT_DATABASE_BACKUP_STOP_MARKER = join(DEFAULT_PRODUCTION_WORK_DIR, '.spacetimedb-stopped'); +const DEFAULT_DATABASE_BACKUP_STOP_MARKER = join( + DEFAULT_PRODUCTION_WORK_DIR, + '.spacetimedb-stopped', +); const DEFAULT_SPACE_SAFETY_RATIO = 1.1; const DEFAULT_EXTRA_FREE_BYTES = 512 * 1024 * 1024; const OSS_ALGORITHM = 'OSS4-HMAC-SHA256'; @@ -118,7 +135,7 @@ function loadEnvFile(filePath, target, protectedKeys) { } function loadRepoEnv() { - const env = {...process.env}; + const env = { ...process.env }; const protectedKeys = new Set( Object.entries(process.env) .filter(([, value]) => String(value ?? '').trim()) @@ -290,21 +307,34 @@ function firstNonEmpty(...values) { } function parseDirectFilesConcurrency(rawValue) { - const value = Number(String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim()); - if (!Number.isSafeInteger(value) || value < 1 || value > MAX_DIRECT_FILES_CONCURRENCY) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`); + const value = Number( + String(rawValue ?? DEFAULT_DIRECT_FILES_CONCURRENCY).trim(), + ); + if ( + !Number.isSafeInteger(value) || + value < 1 || + value > MAX_DIRECT_FILES_CONCURRENCY + ) { + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY 必须是 1-${MAX_DIRECT_FILES_CONCURRENCY} 的整数,实际: ${rawValue}`, + ); } return value; } -export function createUploadBandwidthLimiter(rawValue, {nowFn = Date.now, sleepImpl = sleep} = {}) { +export function createUploadBandwidthLimiter( + rawValue, + { nowFn = Date.now, sleepImpl = sleep } = {}, +) { const normalized = String(rawValue ?? '').trim(); if (!normalized || normalized === '0') { return null; } const maxBytesPerSecond = Number(normalized); if (!Number.isSafeInteger(maxBytesPerSecond) || maxBytesPerSecond < 1024) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND 必须为空、0 或 >= 1024 的整数,实际: ${rawValue}`); + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND 必须为空、0 或 >= 1024 的整数,实际: ${rawValue}`, + ); } let nextAvailableAtMs = 0; const waitForChunk = async (sizeBytes) => { @@ -320,22 +350,31 @@ export function createUploadBandwidthLimiter(rawValue, {nowFn = Date.now, sleepI return { maxBytesPerSecond, wrap(readable) { - return Readable.from((async function* throttleUpload() { - for await (const chunk of readable) { - await waitForChunk(chunk.length); - yield chunk; - } - })(), {objectMode: false}); + return Readable.from( + (async function* throttleUpload() { + for await (const chunk of readable) { + await waitForChunk(chunk.length); + yield chunk; + } + })(), + { objectMode: false }, + ); }, }; } function createBufferReadStream(buffer, chunkSizeBytes = 64 * 1024) { - return Readable.from((function* readChunks() { - for (let offset = 0; offset < buffer.length; offset += chunkSizeBytes) { - yield buffer.subarray(offset, Math.min(offset + chunkSizeBytes, buffer.length)); - } - })(), {objectMode: false}); + return Readable.from( + (function* readChunks() { + for (let offset = 0; offset < buffer.length; offset += chunkSizeBytes) { + yield buffer.subarray( + offset, + Math.min(offset + chunkSizeBytes, buffer.length), + ); + } + })(), + { objectMode: false }, + ); } function resolvePath(value) { @@ -364,9 +403,12 @@ function timestampForFile(date = new Date()) { return `${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}T${pad(date.getUTCHours())}${pad(date.getUTCMinutes())}${pad(date.getUTCSeconds())}Z`; } -function buildBackupNames({database, dataDir, objectPrefix}) { +function buildBackupNames({ database, dataDir, objectPrefix }) { const timestamp = timestampForFile(); - const databasePart = sanitizeObjectPart(database || basename(dataDir), 'spacetimedb'); + const databasePart = sanitizeObjectPart( + database || basename(dataDir), + 'spacetimedb', + ); const fileName = `${databasePart}-${timestamp}.tar.gz`; const prefix = String(objectPrefix || 'database-backups') .trim() @@ -376,24 +418,27 @@ function buildBackupNames({database, dataDir, objectPrefix}) { .map((part) => sanitizeObjectPart(part, 'backup')) .join('/'); const objectKey = [prefix, databasePart, fileName].filter(Boolean).join('/'); - return {fileName, objectKey}; + return { fileName, objectKey }; } function atomicWriteBuffer(filePath, body) { - mkdirSync(dirname(filePath), {recursive: true}); + mkdirSync(dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; - writeFileSync(tempPath, body, {mode: 0o600}); + writeFileSync(tempPath, body, { mode: 0o600 }); chmodSync(tempPath, 0o600); renameSync(tempPath, filePath); } function atomicWriteJson(filePath, payload) { - atomicWriteBuffer(filePath, Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8')); + atomicWriteBuffer( + filePath, + Buffer.from(`${JSON.stringify(payload, null, 2)}\n`, 'utf8'), + ); } function atomicWriteGzipJson(filePath, payload) { const body = Buffer.from(`${JSON.stringify(payload)}\n`, 'utf8'); - atomicWriteBuffer(filePath, gzipSync(body, {level: 9})); + atomicWriteBuffer(filePath, gzipSync(body, { level: 9 })); } function processIsAlive(pid) { @@ -405,9 +450,12 @@ function processIsAlive(pid) { } } -function acquireBackupLock({workDir, database}) { - mkdirSync(workDir, {recursive: true}); - const lockPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`); +function acquireBackupLock({ workDir, database }) { + mkdirSync(workDir, { recursive: true }); + const lockPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}.backup.lock`, + ); try { const fd = openSync(lockPath, 'wx', 0o600); writeFileSync(fd, `${process.pid}\n`, 'utf8'); @@ -416,7 +464,7 @@ function acquireBackupLock({workDir, database}) { try { const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); if (ownerPid === process.pid) { - rmSync(lockPath, {force: true}); + rmSync(lockPath, { force: true }); } } catch { // The lock may already have been removed by the normal exit path. @@ -436,36 +484,53 @@ function acquireBackupLock({workDir, database}) { } } const ownerPid = Number(String(readFileSync(lockPath, 'utf8')).trim()); - if (Number.isSafeInteger(ownerPid) && ownerPid > 0 && processIsAlive(ownerPid)) { + if ( + Number.isSafeInteger(ownerPid) && + ownerPid > 0 && + processIsAlive(ownerPid) + ) { throw new Error(`已有数据库备份进程持有锁: ${lockPath} pid=${ownerPid}`); } - throw new Error(`发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`); + throw new Error( + `发现失效数据库备份锁,拒绝自动抢锁;请核对 OSS multipart 与进程后手工删除: ${lockPath} pid=${ownerPid || ''}`, + ); } -function historyStatePath({args, env, workDir, database}) { - return resolvePath(firstNonEmpty( - args.baselineState, - env.GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE, - join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-history-state.json`), - )); +function historyStatePath({ args, env, workDir, database }) { + return resolvePath( + firstNonEmpty( + args.baselineState, + env.GENARRATIVE_DATABASE_BACKUP_BASELINE_STATE, + join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-history-state.json`, + ), + ), + ); } function baselineIdFor(baseline) { - return sha256Hex([ - baseline.bucket, - baseline.objectKey, - baseline.verifiedAt, - baseline.contentLength, - baseline.archiveSha256, - ].join('\0')).slice(0, 24); + return sha256Hex( + [ + baseline.bucket, + baseline.objectKey, + baseline.verifiedAt, + baseline.contentLength, + baseline.archiveSha256, + ].join('\0'), + ).slice(0, 24); } -function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { +function normalizeUploadedBaselineManifest(manifest, { database, dataDir }) { if (manifest.uploadStatus !== 'uploaded') { - throw new Error(`baseline manifest 必须是 uploaded,实际: ${manifest.uploadStatus ?? ''}`); + throw new Error( + `baseline manifest 必须是 uploaded,实际: ${manifest.uploadStatus ?? ''}`, + ); } if (manifest.backupKind !== 'spacetimedb-data-dir') { - throw new Error(`baseline manifest backupKind 必须是 spacetimedb-data-dir,实际: ${manifest.backupKind ?? ''}`); + throw new Error( + `baseline manifest backupKind 必须是 spacetimedb-data-dir,实际: ${manifest.backupKind ?? ''}`, + ); } const baseline = { backupKind: 'spacetimedb-data-dir', @@ -476,24 +541,28 @@ function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { verifiedAt: String(manifest.verifiedAt ?? '').trim(), uploadedAt: String(manifest.uploadedAt ?? '').trim(), contentLength: Number(manifest.contentLength), - archiveSha256: String(manifest.archiveSha256 ?? '').trim().toLowerCase(), + archiveSha256: String(manifest.archiveSha256 ?? '') + .trim() + .toLowerCase(), manifestObjectKey: String(manifest.manifestObjectKey ?? '').trim(), manifestContentLength: Number(manifest.manifestContentLength), - manifestArchiveSha256: String(manifest.manifestArchiveSha256 ?? '').trim().toLowerCase(), + manifestArchiveSha256: String(manifest.manifestArchiveSha256 ?? '') + .trim() + .toLowerCase(), manifestVerifiedAt: String(manifest.manifestVerifiedAt ?? '').trim(), }; if ( - !baseline.bucket - || !baseline.objectKey - || !baseline.verifiedAt - || !Number.isSafeInteger(baseline.contentLength) - || baseline.contentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(baseline.archiveSha256) - || !baseline.manifestObjectKey - || !Number.isSafeInteger(baseline.manifestContentLength) - || baseline.manifestContentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(baseline.manifestArchiveSha256) - || !baseline.manifestVerifiedAt + !baseline.bucket || + !baseline.objectKey || + !baseline.verifiedAt || + !Number.isSafeInteger(baseline.contentLength) || + baseline.contentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(baseline.archiveSha256) || + !baseline.manifestObjectKey || + !Number.isSafeInteger(baseline.manifestContentLength) || + baseline.manifestContentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(baseline.manifestArchiveSha256) || + !baseline.manifestVerifiedAt ) { throw new Error('baseline manifest 缺少已验真 OSS 归档或 sidecar 信息。'); } @@ -501,19 +570,23 @@ function normalizeUploadedBaselineManifest(manifest, {database, dataDir}) { return baseline; } -function validateHistoryState(state, {database, dataDir}) { +function validateHistoryState(state, { database, dataDir }) { if (state.schemaVersion !== HISTORY_STATE_SCHEMA_VERSION || !state.baseline) { throw new Error('history state schemaVersion 或 baseline 无效。'); } const baseline = normalizeUploadedBaselineManifest( - {...state.baseline, uploadStatus: 'uploaded'}, - {database, dataDir}, + { ...state.baseline, uploadStatus: 'uploaded' }, + { database, dataDir }, ); if (baseline.database !== database) { - throw new Error(`history state database 不匹配: expected=${database}, actual=${baseline.database}`); + throw new Error( + `history state database 不匹配: expected=${database}, actual=${baseline.database}`, + ); } if (resolvePath(baseline.dataDir) !== resolvePath(dataDir)) { - throw new Error(`history state dataDir 不匹配: expected=${resolvePath(dataDir)}, actual=${resolvePath(baseline.dataDir)}`); + throw new Error( + `history state dataDir 不匹配: expected=${resolvePath(dataDir)}, actual=${resolvePath(baseline.dataDir)}`, + ); } return { ...state, @@ -522,34 +595,52 @@ function validateHistoryState(state, {database, dataDir}) { }; } -function writeBaselineState({statePath, baseline, previousState = null}) { +function writeBaselineState({ statePath, baseline, previousState = null }) { const state = { schemaVersion: HISTORY_STATE_SCHEMA_VERSION, updatedAt: new Date().toISOString(), baseline, - batches: previousState?.baseline?.id === baseline.id && Array.isArray(previousState.batches) - ? previousState.batches - : [], + batches: + previousState?.baseline?.id === baseline.id && + Array.isArray(previousState.batches) + ? previousState.batches + : [], }; atomicWriteJson(statePath, state); return state; } -function loadOrImportHistoryState({args, env, statePath, database, dataDir}) { +function loadOrImportHistoryState({ args, env, statePath, database, dataDir }) { if (existsSync(statePath)) { - return validateHistoryState(readManifest(statePath), {database, dataDir}); + return validateHistoryState(readManifest(statePath), { database, dataDir }); } - const importPath = firstNonEmpty(args.baselineManifest, env.GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST); + const importPath = firstNonEmpty( + args.baselineManifest, + env.GENARRATIVE_DATABASE_BACKUP_BASELINE_MANIFEST, + ); if (!importPath) { - throw new Error(`history 模式缺少已验真 baseline state: ${statePath};可用 --baseline-manifest 导入已有 uploaded baseline manifest。`); + throw new Error( + `history 模式缺少已验真 baseline state: ${statePath};可用 --baseline-manifest 导入已有 uploaded baseline manifest。`, + ); } - const baseline = normalizeUploadedBaselineManifest(readManifest(resolvePath(importPath)), {database, dataDir}); - return writeBaselineState({statePath, baseline}); + const baseline = normalizeUploadedBaselineManifest( + readManifest(resolvePath(importPath)), + { database, dataDir }, + ); + return writeBaselineState({ statePath, baseline }); } function assertSafeRelativePath(dataDir, absolutePath) { - const relativePath = relative(resolvePath(dataDir), resolvePath(absolutePath)); - if (!relativePath || relativePath === '..' || relativePath.startsWith(`..${sep}`) || isAbsolute(relativePath)) { + const relativePath = relative( + resolvePath(dataDir), + resolvePath(absolutePath), + ); + if ( + !relativePath || + relativePath === '..' || + relativePath.startsWith(`..${sep}`) || + isAbsolute(relativePath) + ) { throw new Error(`history 候选路径越界或等于数据目录: ${absolutePath}`); } return relativePath.split(sep).join('/'); @@ -563,12 +654,19 @@ function statFingerprint(absolutePath, rootPath = absolutePath) { let entryCount = 0; let totalSize = 0n; const visit = (currentPath) => { - const stat = lstatSync(currentPath, {bigint: true}); + const stat = lstatSync(currentPath, { bigint: true }); if (stat.isSymbolicLink()) { throw new Error(`history 候选不得包含符号链接: ${currentPath}`); } - const entryPath = currentPath === rootPath ? '.' : relative(rootPath, currentPath).split(sep).join('/'); - const kind = stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : 'other'; + const entryPath = + currentPath === rootPath + ? '.' + : relative(rootPath, currentPath).split(sep).join('/'); + const kind = stat.isDirectory() + ? 'directory' + : stat.isFile() + ? 'file' + : 'other'; if (kind === 'other') { throw new Error(`history 候选只允许普通文件或目录: ${currentPath}`); } @@ -604,15 +702,27 @@ function statFingerprint(absolutePath, rootPath = absolutePath) { } function findReplicasDir(dataDir) { - const candidates = [resolve(dataDir, 'replicas'), resolve(dataDir, 'data', 'replicas')] - .filter((candidate) => existsSync(candidate) && lstatSync(candidate).isDirectory()); + const candidates = [ + resolve(dataDir, 'replicas'), + resolve(dataDir, 'data', 'replicas'), + ].filter( + (candidate) => existsSync(candidate) && lstatSync(candidate).isDirectory(), + ); if (candidates.length !== 1) { - throw new Error(`无法唯一确定 replicas 目录: ${candidates.length === 0 ? '' : candidates.join(', ')}`); + throw new Error( + `无法唯一确定 replicas 目录: ${candidates.length === 0 ? '' : candidates.join(', ')}`, + ); } return candidates[0]; } -function historyCandidate({dataDir, absolutePath, kind, replicaId, transaction}) { +function historyCandidate({ + dataDir, + absolutePath, + kind, + replicaId, + transaction, +}) { const stat = statFingerprint(absolutePath); return { path: assertSafeRelativePath(dataDir, absolutePath), @@ -623,14 +733,16 @@ function historyCandidate({dataDir, absolutePath, kind, replicaId, transaction}) }; } -export function discoverHistoryPlan({dataDir}) { +export function discoverHistoryPlan({ dataDir }) { const resolvedDataDir = resolvePath(dataDir); const replicasDir = findReplicasDir(resolvedDataDir); - const replicaEntries = readdirSync(replicasDir, {withFileTypes: true}); + const replicaEntries = readdirSync(replicasDir, { withFileTypes: true }); const replicas = []; const candidates = []; - for (const replicaEntry of replicaEntries.sort((left, right) => left.name.localeCompare(right.name))) { + for (const replicaEntry of replicaEntries.sort((left, right) => + left.name.localeCompare(right.name), + )) { if (!replicaEntry.isDirectory()) { continue; } @@ -642,93 +754,133 @@ export function discoverHistoryPlan({dataDir}) { const snapshotsDir = join(replicaDir, 'snapshots'); const clogDir = join(replicaDir, 'clog'); if (!existsSync(snapshotsDir) || !lstatSync(snapshotsDir).isDirectory()) { - replicas.push({replicaId, status: 'skipped', reason: 'no-snapshots-directory'}); + replicas.push({ + replicaId, + status: 'skipped', + reason: 'no-snapshots-directory', + }); continue; } - const snapshotEntries = readdirSync(snapshotsDir, {withFileTypes: true}); - const snapshots = snapshotEntries.flatMap((entry) => { - const match = /^(\d{20})\.snapshot_dir$/u.exec(entry.name); - if (!match) { - return []; - } - const transaction = BigInt(match[1]); - if (transaction > 0xffff_ffff_ffff_ffffn) { - throw new Error(`snapshot transaction 超出 u64: ${entry.name}`); - } - if (!entry.isDirectory()) { - throw new Error(`snapshot 候选必须是目录: ${join(snapshotsDir, entry.name)}`); - } - const snapshotDir = join(snapshotsDir, entry.name); - const lockPath = join(snapshotsDir, `${match[1]}.lock`); - const snapshotFile = join(snapshotDir, `${match[1]}.snapshot_bsatn`); - if (existsSync(lockPath) || !existsSync(snapshotFile) || !lstatSync(snapshotFile).isFile()) { - return []; - } - return [{name: entry.name, transaction}]; - }).sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + const snapshotEntries = readdirSync(snapshotsDir, { withFileTypes: true }); + const snapshots = snapshotEntries + .flatMap((entry) => { + const match = /^(\d{20})\.snapshot_dir$/u.exec(entry.name); + if (!match) { + return []; + } + const transaction = BigInt(match[1]); + if (transaction > 0xffff_ffff_ffff_ffffn) { + throw new Error(`snapshot transaction 超出 u64: ${entry.name}`); + } + if (!entry.isDirectory()) { + throw new Error( + `snapshot 候选必须是目录: ${join(snapshotsDir, entry.name)}`, + ); + } + const snapshotDir = join(snapshotsDir, entry.name); + const lockPath = join(snapshotsDir, `${match[1]}.lock`); + const snapshotFile = join(snapshotDir, `${match[1]}.snapshot_bsatn`); + if ( + existsSync(lockPath) || + !existsSync(snapshotFile) || + !lstatSync(snapshotFile).isFile() + ) { + return []; + } + return [{ name: entry.name, transaction }]; + }) + .sort((left, right) => + left.transaction < right.transaction + ? -1 + : left.transaction > right.transaction + ? 1 + : 0, + ); if (snapshots.length === 0) { - replicas.push({replicaId, status: 'skipped', reason: 'no-snapshot'}); + replicas.push({ replicaId, status: 'skipped', reason: 'no-snapshot' }); continue; } if (!existsSync(clogDir) || !lstatSync(clogDir).isDirectory()) { throw new Error(`replica ${replicaId} 缺少 clog 目录。`); } const segmentFiles = new Map(); - for (const entry of readdirSync(clogDir, {withFileTypes: true})) { + for (const entry of readdirSync(clogDir, { withFileTypes: true })) { const match = /^(\d{20})\.stdb\.(log|ofs)$/u.exec(entry.name); if (!match) { throw new Error(`commitlog 文件名不符合预期: ${entry.name}`); } if (!entry.isFile()) { - throw new Error(`commitlog 候选必须是普通文件: ${join(clogDir, entry.name)}`); + throw new Error( + `commitlog 候选必须是普通文件: ${join(clogDir, entry.name)}`, + ); } const transaction = BigInt(match[1]); if (transaction > 0xffff_ffff_ffff_ffffn) { throw new Error(`commitlog transaction 超出 u64: ${entry.name}`); } const key = transaction.toString(); - const group = segmentFiles.get(key) ?? {transaction}; + const group = segmentFiles.get(key) ?? { transaction }; group[match[2]] = entry.name; segmentFiles.set(key, group); } for (const group of segmentFiles.values()) { if (group.ofs && !group.log) { - throw new Error(`commitlog offset 缺少对应 log: replica=${replicaId}, transaction=${group.transaction}`); + throw new Error( + `commitlog offset 缺少对应 log: replica=${replicaId}, transaction=${group.transaction}`, + ); } } const segments = [...segmentFiles.values()] .filter((group) => group.log) - .sort((left, right) => left.transaction < right.transaction ? -1 : left.transaction > right.transaction ? 1 : 0); + .sort((left, right) => + left.transaction < right.transaction + ? -1 + : left.transaction > right.transaction + ? 1 + : 0, + ); const latestSnapshot = snapshots.at(-1).transaction; - const boundarySegment = segments.filter((segment) => segment.transaction <= latestSnapshot).at(-1); + const boundarySegment = segments + .filter((segment) => segment.transaction <= latestSnapshot) + .at(-1); if (!boundarySegment) { - throw new Error(`replica ${replicaId} 无法找到覆盖 latest snapshot ${latestSnapshot} 的 commitlog 边界。`); + throw new Error( + `replica ${replicaId} 无法找到覆盖 latest snapshot ${latestSnapshot} 的 commitlog 边界。`, + ); } for (const snapshot of snapshots.slice(0, -1)) { - candidates.push(historyCandidate({ - dataDir: resolvedDataDir, - absolutePath: join(snapshotsDir, snapshot.name), - kind: 'snapshot', - replicaId, - transaction: snapshot.transaction, - })); - } - for (const segment of segments.filter((item) => item.transaction < boundarySegment.transaction)) { - candidates.push(historyCandidate({ - dataDir: resolvedDataDir, - absolutePath: join(clogDir, segment.log), - kind: 'commitlog', - replicaId, - transaction: segment.transaction, - })); - if (segment.ofs) { - candidates.push(historyCandidate({ + candidates.push( + historyCandidate({ dataDir: resolvedDataDir, - absolutePath: join(clogDir, segment.ofs), - kind: 'commitlog-offset', + absolutePath: join(snapshotsDir, snapshot.name), + kind: 'snapshot', + replicaId, + transaction: snapshot.transaction, + }), + ); + } + for (const segment of segments.filter( + (item) => item.transaction < boundarySegment.transaction, + )) { + candidates.push( + historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.log), + kind: 'commitlog', replicaId, transaction: segment.transaction, - })); + }), + ); + if (segment.ofs) { + candidates.push( + historyCandidate({ + dataDir: resolvedDataDir, + absolutePath: join(clogDir, segment.ofs), + kind: 'commitlog-offset', + replicaId, + transaction: segment.transaction, + }), + ); } } replicas.push({ @@ -744,7 +896,9 @@ export function discoverHistoryPlan({dataDir}) { replicasDir: assertSafeRelativePath(resolvedDataDir, replicasDir), replicas, candidates, - totalSizeBytes: candidates.reduce((sum, item) => sum + BigInt(item.sizeBytes), 0n).toString(), + totalSizeBytes: candidates + .reduce((sum, item) => sum + BigInt(item.sizeBytes), 0n) + .toString(), }; } @@ -773,7 +927,9 @@ function parseByteSize(rawValue, label) { } const match = /^(\d+)(?:\s*([KMGTPE]?)(?:I?B?)?)?$/iu.exec(value); if (!match) { - throw new Error(`${label} 必须是字节数或 K/M/G/T/P/E 后缀大小,实际: ${rawValue}`); + throw new Error( + `${label} 必须是字节数或 K/M/G/T/P/E 后缀大小,实际: ${rawValue}`, + ); } const [, amountText, unitText = ''] = match; const multipliers = { @@ -790,11 +946,11 @@ function parseByteSize(rawValue, label) { function formatBytes(bytes) { const value = BigInt(bytes); - const gib = Number(value) / (1024 ** 3); + const gib = Number(value) / 1024 ** 3; if (gib >= 1) { return `${gib.toFixed(1)}GiB`; } - const mib = Number(value) / (1024 ** 2); + const mib = Number(value) / 1024 ** 2; if (mib >= 1) { return `${mib.toFixed(1)}MiB`; } @@ -803,7 +959,9 @@ function formatBytes(bytes) { function getDirectorySizeBytes(dataDir) { const result = runCommand('du', ['-sk', dataDir]); - const [sizeKbText] = String(result.stdout ?? '').trim().split(/\s+/u); + const [sizeKbText] = String(result.stdout ?? '') + .trim() + .split(/\s+/u); if (!sizeKbText || !/^\d+$/u.test(sizeKbText)) { throw new Error(`无法解析数据目录大小: ${result.stdout}`); } @@ -811,7 +969,7 @@ function getDirectorySizeBytes(dataDir) { } function getAvailableBytes(fileSystemPath) { - const stat = statfsSync(fileSystemPath, {bigint: true}); + const stat = statfsSync(fileSystemPath, { bigint: true }); return stat.bavail * stat.bsize; } @@ -822,35 +980,51 @@ function parseSafetyRatio(rawValue) { } const ratio = Number(value); if (!Number.isFinite(ratio) || ratio < 1) { - throw new Error(`GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO 必须是 >= 1 的数字,实际: ${rawValue}`); + throw new Error( + `GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO 必须是 >= 1 的数字,实际: ${rawValue}`, + ); } return ratio; } -function calculateRequiredFreeBytes({dataSizeBytes, args, env}) { +function calculateRequiredFreeBytes({ dataSizeBytes, args, env }) { const explicitMinFreeBytes = parseByteSize( - firstNonEmpty(args.minFreeBytes, env.GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES), + firstNonEmpty( + args.minFreeBytes, + env.GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES, + ), 'GENARRATIVE_DATABASE_BACKUP_MIN_FREE_BYTES', ); if (explicitMinFreeBytes !== null) { return explicitMinFreeBytes; } - const ratio = parseSafetyRatio(env.GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO); + const ratio = parseSafetyRatio( + env.GENARRATIVE_DATABASE_BACKUP_SPACE_SAFETY_RATIO, + ); const ratioBasisPoints = BigInt(Math.ceil(ratio * 10000)); const ratioRequirement = (dataSizeBytes * ratioBasisPoints + 9999n) / 10000n; const extraFreeBytes = parseByteSize( - firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES, String(DEFAULT_EXTRA_FREE_BYTES)), + firstNonEmpty( + env.GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES, + String(DEFAULT_EXTRA_FREE_BYTES), + ), 'GENARRATIVE_DATABASE_BACKUP_EXTRA_FREE_BYTES', ); const extraRequirement = dataSizeBytes + extraFreeBytes; - return ratioRequirement > extraRequirement ? ratioRequirement : extraRequirement; + return ratioRequirement > extraRequirement + ? ratioRequirement + : extraRequirement; } -function assertSufficientWorkDirSpace({dataDir, workDir, args, env}) { - mkdirSync(workDir, {recursive: true}); +function assertSufficientWorkDirSpace({ dataDir, workDir, args, env }) { + mkdirSync(workDir, { recursive: true }); const dataSizeBytes = getDirectorySizeBytes(dataDir); const availableBytes = getAvailableBytes(workDir); - const requiredFreeBytes = calculateRequiredFreeBytes({dataSizeBytes, args, env}); + const requiredFreeBytes = calculateRequiredFreeBytes({ + dataSizeBytes, + args, + env, + }); console.log( `[database-backup] 备份空间预检: data=${formatBytes(dataSizeBytes)}, available=${formatBytes(availableBytes)}, required=${formatBytes(requiredFreeBytes)}`, ); @@ -868,19 +1042,30 @@ function assertSufficientWorkDirSpace({dataDir, workDir, args, env}) { } } -function assertSufficientHistoryWorkDirSpace({historySizeBytes, workDir, args, env}) { - mkdirSync(workDir, {recursive: true}); +function assertSufficientHistoryWorkDirSpace({ + historySizeBytes, + workDir, + args, + env, +}) { + mkdirSync(workDir, { recursive: true }); const availableBytes = getAvailableBytes(workDir); - const requiredFreeBytes = calculateRequiredFreeBytes({dataSizeBytes: BigInt(historySizeBytes), args, env}); + const requiredFreeBytes = calculateRequiredFreeBytes({ + dataSizeBytes: BigInt(historySizeBytes), + args, + env, + }); console.log( `[database-backup] history 空间预检: candidates=${formatBytes(historySizeBytes)}, available=${formatBytes(availableBytes)}, required=${formatBytes(requiredFreeBytes)}`, ); if (availableBytes < requiredFreeBytes) { - throw new Error(`history 工作目录剩余空间不足: available=${formatBytes(availableBytes)};required=${formatBytes(requiredFreeBytes)}`); + throw new Error( + `history 工作目录剩余空间不足: available=${formatBytes(availableBytes)};required=${formatBytes(requiredFreeBytes)}`, + ); } } -function collectRestartServicesAfterBackup({args, env}) { +function collectRestartServicesAfterBackup({ args, env }) { const serviceNames = [ ...String(env.GENARRATIVE_DATABASE_BACKUP_RESTART_SERVICE_AFTER ?? '') .split(',') @@ -892,12 +1077,14 @@ function collectRestartServicesAfterBackup({args, env}) { } function databaseBackupStopMarkerPath(workDir) { - return resolvePath(firstNonEmpty( - process.env.GENARRATIVE_DATABASE_BACKUP_STOP_MARKER, - workDir === DEFAULT_PRODUCTION_WORK_DIR - ? DEFAULT_DATABASE_BACKUP_STOP_MARKER - : join(workDir, '.spacetimedb-stopped'), - )); + return resolvePath( + firstNonEmpty( + process.env.GENARRATIVE_DATABASE_BACKUP_STOP_MARKER, + workDir === DEFAULT_PRODUCTION_WORK_DIR + ? DEFAULT_DATABASE_BACKUP_STOP_MARKER + : join(workDir, '.spacetimedb-stopped'), + ), + ); } function writeDatabaseBackupStopMarker(markerPath, serviceName) { @@ -910,7 +1097,7 @@ function writeDatabaseBackupStopMarker(markerPath, serviceName) { function clearDatabaseBackupStopMarker(markerPath) { if (markerPath) { - rmSync(markerPath, {force: true}); + rmSync(markerPath, { force: true }); } } @@ -922,7 +1109,7 @@ function stopServiceIfNeeded(serviceName, stopMarkerPath) { writeDatabaseBackupStopMarker(stopMarkerPath, serviceName); // stop 命令失败时仍保留 marker:systemd 的 ExecStopPost 需要它判断是否要 // 兜底恢复,不能因为当前进程还能捕获异常就抹掉上一次停库证据。 - runCommand('systemctl', ['stop', serviceName], {stdio: 'inherit'}); + runCommand('systemctl', ['stop', serviceName], { stdio: 'inherit' }); return true; } @@ -931,7 +1118,7 @@ function startServiceIfNeeded(serviceName, wasStopped) { return; } console.log(`[database-backup] 恢复服务: ${serviceName}`); - runCommand('systemctl', ['start', serviceName], {stdio: 'inherit'}); + runCommand('systemctl', ['start', serviceName], { stdio: 'inherit' }); } function restartServicesAfterBackup(serviceNames) { @@ -942,17 +1129,25 @@ function restartServicesAfterBackup(serviceNames) { } console.log(`[database-backup] 冷备份后重启依赖服务: ${serviceName}`); try { - runCommand('systemctl', ['restart', serviceName], {stdio: 'inherit'}); + runCommand('systemctl', ['restart', serviceName], { stdio: 'inherit' }); } catch (error) { errors.push(error); } } if (errors.length > 0) { - throw new AggregateError(errors, `冷备份后重启依赖服务失败: ${errors.map((error) => error.message).join('; ')}`); + throw new AggregateError( + errors, + `冷备份后重启依赖服务失败: ${errors.map((error) => error.message).join('; ')}`, + ); } } -function restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath}) { +function restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, +}) { const errors = []; try { startServiceIfNeeded(stopService, serviceStopped); @@ -965,12 +1160,15 @@ function restoreServicesAfterBackup({stopService, serviceStopped, restartService errors.push(error); } if (errors.length > 0) { - throw new AggregateError(errors, `恢复冷备份相关服务失败: ${errors.map((error) => error.message).join('; ')}`); + throw new AggregateError( + errors, + `恢复冷备份相关服务失败: ${errors.map((error) => error.message).join('; ')}`, + ); } clearDatabaseBackupStopMarker(stopMarkerPath); } -function createArchive({dataDir, workDir, fileName}) { +function createArchive({ dataDir, workDir, fileName }) { if (!existsSync(dataDir)) { throw new Error(`数据库数据目录不存在: ${dataDir}`); } @@ -978,32 +1176,38 @@ function createArchive({dataDir, workDir, fileName}) { if (!stat.isDirectory()) { throw new Error(`数据库数据路径不是目录: ${dataDir}`); } - mkdirSync(workDir, {recursive: true}); + mkdirSync(workDir, { recursive: true }); const archivePath = resolve(workDir, fileName); const parentDir = dirname(dataDir); const entryName = basename(dataDir); console.log(`[database-backup] 打包: ${dataDir} -> ${archivePath}`); - runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], {stdio: 'inherit'}); + runCommand('tar', ['-czf', archivePath, '-C', parentDir, entryName], { + stdio: 'inherit', + }); verifyArchive(archivePath); return archivePath; } function verifyArchive(archivePath) { console.log(`[database-backup] 校验归档: ${archivePath}`); - runCommand('tar', ['-tzf', archivePath], {stdio: 'ignore'}); + runCommand('tar', ['-tzf', archivePath], { stdio: 'ignore' }); } -function historyBatchId({baselineId, plan}) { - const identity = plan.candidates.map((candidate) => [ - candidate.path, - candidate.kind, - candidate.transaction, - candidate.fingerprint, - ].join('\0')).join('\n'); +function historyBatchId({ baselineId, plan }) { + const identity = plan.candidates + .map((candidate) => + [ + candidate.path, + candidate.kind, + candidate.transaction, + candidate.fingerprint, + ].join('\0'), + ) + .join('\n'); return sha256Hex(`${baselineId}\0${identity}`).slice(0, 32); } -function buildHistoryNames({database, objectPrefix, baselineId, batchId}) { +function buildHistoryNames({ database, objectPrefix, baselineId, batchId }) { const databasePart = sanitizeObjectPart(database, 'spacetimedb'); const prefix = String(objectPrefix || 'database-backups') .trim() @@ -1015,30 +1219,52 @@ function buildHistoryNames({database, objectPrefix, baselineId, batchId}) { const fileName = `${databasePart}-history-${batchId}.tar.gz`; return { fileName, - objectKey: [prefix, databasePart, 'history', baselineId, fileName].filter(Boolean).join('/'), + objectKey: [prefix, databasePart, 'history', baselineId, fileName] + .filter(Boolean) + .join('/'), }; } -function createHistoryArchive({dataDir, workDir, fileName, manifestPath, candidates}) { - mkdirSync(workDir, {recursive: true}); +function createHistoryArchive({ + dataDir, + workDir, + fileName, + manifestPath, + candidates, +}) { + mkdirSync(workDir, { recursive: true }); const archivePath = resolve(workDir, fileName); const candidatePaths = candidates.map((candidate) => candidate.path); - console.log(`[database-backup] 打包 history: ${candidatePaths.length} 个候选 -> ${archivePath}`); - runCommand('tar', [ - '-czf', - archivePath, - '-C', - dataDir, - ...candidatePaths, - '-C', - dirname(manifestPath), - basename(manifestPath), - ], {stdio: 'inherit'}); + console.log( + `[database-backup] 打包 history: ${candidatePaths.length} 个候选 -> ${archivePath}`, + ); + runCommand( + 'tar', + [ + '-czf', + archivePath, + '-C', + dataDir, + ...candidatePaths, + '-C', + dirname(manifestPath), + basename(manifestPath), + ], + { stdio: 'inherit' }, + ); verifyArchive(archivePath); return archivePath; } -function recordHistoryBatch({statePath, state, manifest, uploadResult, manifestUpload, status, cleanedAt = ''}) { +function recordHistoryBatch({ + statePath, + state, + manifest, + uploadResult, + manifestUpload, + status, + cleanedAt = '', +}) { const batch = { batchId: manifest.batchId, objectKey: uploadResult.objectKey, @@ -1054,9 +1280,11 @@ function recordHistoryBatch({statePath, state, manifest, uploadResult, manifestU cleanedAt, candidates: manifest.candidates, }; - const batches = state.batches.filter((item) => item.batchId !== batch.batchId); + const batches = state.batches.filter( + (item) => item.batchId !== batch.batchId, + ); batches.push(batch); - const nextState = {...state, updatedAt: new Date().toISOString(), batches}; + const nextState = { ...state, updatedAt: new Date().toISOString(), batches }; atomicWriteJson(statePath, nextState); return nextState; } @@ -1065,9 +1293,14 @@ function candidateKey(candidate) { return `${candidate.kind}\0${candidate.path}`; } -export function cleanupHistoryCandidates({dataDir, candidates}) { - const currentPlan = discoverHistoryPlan({dataDir}); - const eligible = new Map(currentPlan.candidates.map((candidate) => [candidateKey(candidate), candidate])); +export function cleanupHistoryCandidates({ dataDir, candidates }) { + const currentPlan = discoverHistoryPlan({ dataDir }); + const eligible = new Map( + currentPlan.candidates.map((candidate) => [ + candidateKey(candidate), + candidate, + ]), + ); const existing = []; for (const candidate of candidates) { const absolutePath = resolve(dataDir, candidate.path); @@ -1077,27 +1310,41 @@ export function cleanupHistoryCandidates({dataDir, candidates}) { } const current = eligible.get(candidateKey(candidate)); if (!current) { - throw new Error(`history 候选已不在当前安全边界内,拒绝删除: ${candidate.path}`); + throw new Error( + `history 候选已不在当前安全边界内,拒绝删除: ${candidate.path}`, + ); } const currentStat = statFingerprint(absolutePath); - if (currentStat.fingerprint !== candidate.fingerprint || currentStat.sizeBytes !== candidate.sizeBytes) { + if ( + currentStat.fingerprint !== candidate.fingerprint || + currentStat.sizeBytes !== candidate.sizeBytes + ) { throw new Error(`history 候选 stat 漂移,拒绝删除: ${candidate.path}`); } - existing.push({candidate, absolutePath}); + existing.push({ candidate, absolutePath }); } existing.sort((left, right) => { - const priority = {'commitlog-offset': 0, commitlog: 1, snapshot: 2}; - return (priority[left.candidate.kind] ?? 3) - (priority[right.candidate.kind] ?? 3) - || left.candidate.path.localeCompare(right.candidate.path); + const priority = { 'commitlog-offset': 0, commitlog: 1, snapshot: 2 }; + return ( + (priority[left.candidate.kind] ?? 3) - + (priority[right.candidate.kind] ?? 3) || + left.candidate.path.localeCompare(right.candidate.path) + ); }); - for (const {candidate, absolutePath} of existing) { - rmSync(absolutePath, {recursive: candidate.kind === 'snapshot', force: false}); + for (const { candidate, absolutePath } of existing) { + rmSync(absolutePath, { + recursive: candidate.kind === 'snapshot', + force: false, + }); console.log(`[database-backup] 已清理 history 源文件: ${candidate.path}`); } - return {deletedCount: existing.length, alreadyMissingCount: candidates.length - existing.length}; + return { + deletedCount: existing.length, + alreadyMissingCount: candidates.length - existing.length, + }; } -function writeManifest({manifestPath, payload}) { +function writeManifest({ manifestPath, payload }) { writeFileSync(manifestPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8'); } @@ -1124,15 +1371,21 @@ async function sha256FileHex(filePath) { return hash.digest('hex'); } -function directFilesStatePath({workDir, database}) { - return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`); +function directFilesStatePath({ workDir, database }) { + return join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json.gz`, + ); } -function legacyDirectFilesStatePath({workDir, database}) { - return join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`); +function legacyDirectFilesStatePath({ workDir, database }) { + return join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-files-state.json`, + ); } -function directCatalogLocalPaths({workDir, database, catalog}) { +function directCatalogLocalPaths({ workDir, database, catalog }) { const baseName = `${sanitizeObjectPart(database, 'spacetimedb')}-${catalog.mode}-${catalog.catalogId}.catalog.json`; return { jsonPath: join(workDir, baseName), @@ -1140,8 +1393,12 @@ function directCatalogLocalPaths({workDir, database, catalog}) { }; } -function readLocalDirectCatalog({workDir, database, catalog}) { - const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); +function readLocalDirectCatalog({ workDir, database, catalog }) { + const { jsonPath, gzipPath } = directCatalogLocalPaths({ + workDir, + database, + catalog, + }); let body = null; if (existsSync(jsonPath)) { body = readFileSync(jsonPath); @@ -1151,16 +1408,21 @@ function readLocalDirectCatalog({workDir, database, catalog}) { if (!body) { return null; } - if (body.length !== catalog.contentLength || sha256Hex(body) !== catalog.sha256) { - throw new Error(`本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`); + if ( + body.length !== catalog.contentLength || + sha256Hex(body) !== catalog.sha256 + ) { + throw new Error( + `本地 files catalog 长度或 SHA 与 state 引用不匹配: ${jsonPath}`, + ); } const payload = JSON.parse(body.toString('utf8')); if ( - payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || payload.database !== database - || payload.mode !== catalog.mode - || payload.catalogId !== catalog.catalogId - || !Array.isArray(payload.files) + payload.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + payload.database !== database || + payload.mode !== catalog.mode || + payload.catalogId !== catalog.catalogId || + !Array.isArray(payload.files) ) { throw new Error(`本地 files catalog 与 state 引用不匹配: ${jsonPath}`); } @@ -1169,32 +1431,45 @@ function readLocalDirectCatalog({workDir, database, catalog}) { function readJsonOrGzip(filePath) { const body = readFileSync(filePath); - const decoded = filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b) - ? gunzipSync(body) - : body; + const decoded = + filePath.endsWith('.gz') || (body[0] === 0x1f && body[1] === 0x8b) + ? gunzipSync(body) + : body; return JSON.parse(decoded.toString('utf8')); } -function compactDirectCatalogFile({workDir, database, catalog}) { - const {jsonPath, gzipPath} = directCatalogLocalPaths({workDir, database, catalog}); +function compactDirectCatalogFile({ workDir, database, catalog }) { + const { jsonPath, gzipPath } = directCatalogLocalPaths({ + workDir, + database, + catalog, + }); if (!existsSync(jsonPath)) { - return existsSync(gzipPath) ? {compressed: false, gzipPath} : null; + return existsSync(gzipPath) ? { compressed: false, gzipPath } : null; } const body = readFileSync(jsonPath); - atomicWriteBuffer(gzipPath, gzipSync(body, {level: 9})); - rmSync(jsonPath, {force: false}); - return {compressed: true, gzipPath}; + atomicWriteBuffer(gzipPath, gzipSync(body, { level: 9 })); + rmSync(jsonPath, { force: false }); + return { compressed: true, gzipPath }; } -function compactDirectFilesLocalMetadata({workDir, database, nextState, transientCatalogPaths = []}) { +function compactDirectFilesLocalMetadata({ + workDir, + database, + nextState, + transientCatalogPaths = [], +}) { const keepCatalog = nextState.latestCatalog; const keepCatalogIds = new Set([keepCatalog?.catalogId].filter(Boolean)); const databasePart = sanitizeObjectPart(database, 'spacetimedb'); - const catalogPattern = new RegExp(`^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, 'u'); + const catalogPattern = new RegExp( + `^${databasePart.replace(/[.*+?^${}()|[\]\\]/gu, '\\$&')}-(full|history)-([a-f0-9]{64})\\.catalog\\.json(?:\\.gz)?$`, + 'u', + ); let compressedCatalogCount = 0; let deletedCatalogCount = 0; let compactedResultCount = 0; - for (const entry of readdirSync(workDir, {withFileTypes: true})) { + for (const entry of readdirSync(workDir, { withFileTypes: true })) { if (!entry.isFile()) { continue; } @@ -1202,25 +1477,32 @@ function compactDirectFilesLocalMetadata({workDir, database, nextState, transien if (!match) { continue; } - const catalog = {mode: match[1], catalogId: match[2]}; + const catalog = { mode: match[1], catalogId: match[2] }; if (keepCatalogIds.has(catalog.catalogId) && catalog.mode === 'full') { - readLocalDirectCatalog({workDir, database, catalog: keepCatalog}); - if (!entry.name.endsWith('.gz') && compactDirectCatalogFile({workDir, database, catalog})?.compressed) { + readLocalDirectCatalog({ workDir, database, catalog: keepCatalog }); + if ( + !entry.name.endsWith('.gz') && + compactDirectCatalogFile({ workDir, database, catalog })?.compressed + ) { compressedCatalogCount += 1; } continue; } - rmSync(join(workDir, entry.name), {force: false}); + rmSync(join(workDir, entry.name), { force: false }); deletedCatalogCount += 1; } for (const filePath of transientCatalogPaths) { if (existsSync(filePath)) { - rmSync(filePath, {force: false}); + rmSync(filePath, { force: false }); deletedCatalogCount += 1; } } - for (const entry of readdirSync(workDir, {withFileTypes: true})) { - if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.endsWith('.catalog.json')) { + for (const entry of readdirSync(workDir, { withFileTypes: true })) { + if ( + !entry.isFile() || + !entry.name.endsWith('.json') || + entry.name.endsWith('.catalog.json') + ) { continue; } const filePath = join(workDir, entry.name); @@ -1231,18 +1513,24 @@ function compactDirectFilesLocalMetadata({workDir, database, nextState, transien continue; } if ( - payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || payload.catalog.database !== database - || payload.catalog.bucket !== nextState.bucket - || !Array.isArray(payload.catalog.files) + payload?.catalog?.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + payload.catalog.database !== database || + payload.catalog.bucket !== nextState.bucket || + !Array.isArray(payload.catalog.files) ) { continue; } atomicWriteJson(filePath, compactDirectFilesResult(payload)); compactedResultCount += 1; } - const result = {compressedCatalogCount, deletedCatalogCount, compactedResultCount}; - console.log(`[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`); + const result = { + compressedCatalogCount, + deletedCatalogCount, + compactedResultCount, + }; + console.log( + `[database-backup] files 本地元数据清理: ${JSON.stringify(result)}`, + ); return result; } @@ -1254,11 +1542,13 @@ function normalizeObjectPrefix(objectPrefix, database) { .filter(Boolean) .map((part) => sanitizeObjectPart(part, 'backup')) .join('/'); - return [prefix, sanitizeObjectPart(database, 'spacetimedb')].filter(Boolean).join('/'); + return [prefix, sanitizeObjectPart(database, 'spacetimedb')] + .filter(Boolean) + .join('/'); } function directFileIdentity(filePath) { - const stat = lstatSync(filePath, {bigint: true}); + const stat = lstatSync(filePath, { bigint: true }); if (!stat.isFile() || stat.isSymbolicLink()) { throw new Error(`files 模式只允许普通文件: ${filePath}`); } @@ -1272,43 +1562,63 @@ function directFileIdentity(filePath) { } function sameDirectFileIdentity(left, right) { - return left.dev === right.dev - && left.ino === right.ino - && left.size === right.size - && left.mtimeNs === right.mtimeNs - && left.mode === right.mode; + return ( + left.dev === right.dev && + left.ino === right.ino && + left.size === right.size && + left.mtimeNs === right.mtimeNs && + left.mode === right.mode + ); } -export async function collectDirectFileEntries({dataDir, candidates = null, objectPrefix, database}) { +export async function collectDirectFileEntries({ + dataDir, + candidates = null, + objectPrefix, + database, +}) { const resolvedDataDir = resolvePath(dataDir); - if (!existsSync(resolvedDataDir) || !lstatSync(resolvedDataDir).isDirectory()) { + if ( + !existsSync(resolvedDataDir) || + !lstatSync(resolvedDataDir).isDirectory() + ) { throw new Error(`files 数据目录不存在或不是目录: ${resolvedDataDir}`); } const files = new Map(); const symlinks = new Map(); const directories = new Set(['.']); - const roots = candidates === null - ? [{absolutePath: resolvedDataDir, relativePath: '.'}] - : candidates.map((candidate) => ({ - absolutePath: resolve(resolvedDataDir, candidate.path), - relativePath: assertSafeRelativePath(resolvedDataDir, resolve(resolvedDataDir, candidate.path)), - })); + const roots = + candidates === null + ? [{ absolutePath: resolvedDataDir, relativePath: '.' }] + : candidates.map((candidate) => ({ + absolutePath: resolve(resolvedDataDir, candidate.path), + relativePath: assertSafeRelativePath( + resolvedDataDir, + resolve(resolvedDataDir, candidate.path), + ), + })); const visit = async (absolutePath, relativePath) => { const stat = lstatSync(absolutePath); if (stat.isSymbolicLink()) { const target = readlinkSync(absolutePath, 'utf8'); if (!target || isAbsolute(target)) { - throw new Error(`files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`); + throw new Error( + `files 模式只允许 data-dir 内部的相对符号链接: ${absolutePath} -> ${target}`, + ); } - assertSafeRelativePath(resolvedDataDir, resolve(dirname(absolutePath), target)); - symlinks.set(relativePath, {path: relativePath, target}); + assertSafeRelativePath( + resolvedDataDir, + resolve(dirname(absolutePath), target), + ); + symlinks.set(relativePath, { path: relativePath, target }); return; } if (stat.isDirectory()) { directories.add(relativePath); for (const name of readdirSync(absolutePath).sort()) { - const childRelative = relativePath === '.' ? name : `${relativePath}/${name}`; + const childRelative = + relativePath === '.' ? name : `${relativePath}/${name}`; await visit(join(absolutePath, name), childRelative); } return; @@ -1332,11 +1642,16 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje }; // 上传前后的 inode/stat 仍用于防止在线扫描漂移,但设为不可枚举,避免 // 把仅供本地校验的副本再次写入 catalog 或 result JSON。 - Object.defineProperty(file, 'sourceStat', {value: after, enumerable: false}); + Object.defineProperty(file, 'sourceStat', { + value: after, + enumerable: false, + }); files.set(relativePath, file); }; - for (const root of roots.sort((left, right) => left.relativePath.localeCompare(right.relativePath))) { + for (const root of roots.sort((left, right) => + left.relativePath.localeCompare(right.relativePath), + )) { if (!existsSync(root.absolutePath)) { throw new Error(`files 候选在扫描前消失: ${root.relativePath}`); } @@ -1344,12 +1659,23 @@ export async function collectDirectFileEntries({dataDir, candidates = null, obje } return { directories: [...directories].sort(), - files: [...files.values()].sort((left, right) => left.path.localeCompare(right.path)), - symlinks: [...symlinks.values()].sort((left, right) => left.path.localeCompare(right.path)), + files: [...files.values()].sort((left, right) => + left.path.localeCompare(right.path), + ), + symlinks: [...symlinks.values()].sort((left, right) => + left.path.localeCompare(right.path), + ), }; } -function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, files, symlinks}) { +function directCatalogIdentity({ + mode, + baselineCatalogId, + rootName, + directories, + files, + symlinks, +}) { // 不把数十万条文件元数据先拼成一个巨型 JSON 字符串;分段写入 hash // 保持与 JSON.stringify 同样的字段顺序和转义结果,同时把峰值降到单条记录。 const hash = createHash('sha256'); @@ -1360,15 +1686,19 @@ function directCatalogIdentity({mode, baselineCatalogId, rootName, directories, hash.update(',"rootName":'); hash.update(JSON.stringify(rootName)); hash.update(',"directories":'); - updateJsonArrayHash(hash, directories, (directory) => JSON.stringify(directory)); + updateJsonArrayHash(hash, directories, (directory) => + JSON.stringify(directory), + ); hash.update(',"files":'); - updateJsonArrayHash(hash, files, (file) => JSON.stringify({ - path: file.path, - sizeBytes: file.sizeBytes, - sha256: file.sha256, - mode: file.mode, - objectKey: file.objectKey, - })); + updateJsonArrayHash(hash, files, (file) => + JSON.stringify({ + path: file.path, + sizeBytes: file.sizeBytes, + sha256: file.sha256, + mode: file.mode, + objectKey: file.objectKey, + }), + ); hash.update(',"symlinks":'); updateJsonArrayHash(hash, symlinks, (symlink) => JSON.stringify(symlink)); hash.update('}'); @@ -1386,7 +1716,7 @@ function updateJsonArrayHash(hash, values, serialize) { hash.update(']'); } -function readDirectFilesState(statePath, {database, bucket}) { +function readDirectFilesState(statePath, { database, bucket }) { const candidates = statePath.endsWith('.gz') ? [statePath, statePath.slice(0, -3)] : [statePath, `${statePath}.gz`]; @@ -1396,24 +1726,30 @@ function readDirectFilesState(statePath, {database, bucket}) { } const state = readJsonOrGzip(existingPath); if ( - ![LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, DIRECT_FILES_STATE_SCHEMA_VERSION].includes(state.schemaVersion) - || state.backupKind !== 'spacetimedb-direct-files-state' - || state.database !== database - || state.bucket !== bucket + ![ + LEGACY_DIRECT_FILES_STATE_SCHEMA_VERSION, + DIRECT_FILES_STATE_SCHEMA_VERSION, + ].includes(state.schemaVersion) || + state.backupKind !== 'spacetimedb-direct-files-state' || + state.database !== database || + state.bucket !== bucket ) { throw new Error(`files state 与本次数据源或 bucket 不匹配: ${statePath}`); } return state; } -function directPreviousFiles({state, workDir, database}) { +function directPreviousFiles({ state, workDir, database }) { if (!state?.latestCatalog) { return []; } if (Array.isArray(state?.latestCatalog?.files)) { return state.latestCatalog.files; } - return readLocalDirectCatalog({workDir, database, catalog: state?.latestCatalog})?.files ?? []; + return ( + readLocalDirectCatalog({ workDir, database, catalog: state?.latestCatalog }) + ?.files ?? [] + ); } async function ensureDirectObject({ @@ -1427,9 +1763,11 @@ async function ensureDirectObject({ }) { const absolutePath = resolve(dataDir, file.path); assertSafeRelativePath(dataDir, absolutePath); - if (previousFile?.sha256 === file.sha256 - && previousFile?.sizeBytes === file.sizeBytes - && previousFile?.objectKey === file.objectKey) { + if ( + previousFile?.sha256 === file.sha256 && + previousFile?.sizeBytes === file.sizeBytes && + previousFile?.objectKey === file.objectKey + ) { if (verifyCatalogReuse) { await verifyFn({ ...uploadOptions, @@ -1450,7 +1788,7 @@ async function ensureDirectObject({ contentLength: file.sizeBytes, archiveSha256: file.sha256, }); - return {status: 'oss-reused', objectKey: file.objectKey}; + return { status: 'oss-reused', objectKey: file.objectKey }; } catch (error) { if (error?.status !== 404) { throw error; @@ -1473,10 +1811,16 @@ async function ensureDirectObject({ if (!sameDirectFileIdentity(afterUpload, file.sourceStat)) { throw new Error(`files 上传期间源文件 stat 漂移: ${file.path}`); } - return {status: 'uploaded', objectKey: file.objectKey}; + return { status: 'uploaded', objectKey: file.objectKey }; } -async function ensureDirectManifest({manifestPath, objectKey, uploadOptions, uploadManifestFn, verifyFn}) { +async function ensureDirectManifest({ + manifestPath, + objectKey, + uploadOptions, + uploadManifestFn, + verifyFn, +}) { const body = readFileSync(manifestPath); const archiveSha256 = sha256Hex(body); try { @@ -1486,13 +1830,19 @@ async function ensureDirectManifest({manifestPath, objectKey, uploadOptions, upl contentLength: body.length, archiveSha256, }); - return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt, reused: true}; + return { + objectKey, + contentLength: body.length, + archiveSha256, + verifiedAt: verification.verifiedAt, + reused: true, + }; } catch (error) { if (error?.status !== 404) { throw error; } } - return uploadManifestFn({manifestPath, ...uploadOptions, objectKey}); + return uploadManifestFn({ manifestPath, ...uploadOptions, objectKey }); } function directCatalogRef(catalog) { @@ -1506,7 +1856,7 @@ function directCatalogRef(catalog) { }; } -function normalizeDirectFilesState({state, dataDir, database, bucket}) { +function normalizeDirectFilesState({ state, dataDir, database, bucket }) { return { schemaVersion: DIRECT_FILES_STATE_SCHEMA_VERSION, backupKind: 'spacetimedb-direct-files-state', @@ -1514,18 +1864,26 @@ function normalizeDirectFilesState({state, dataDir, database, bucket}) { dataDir, bucket, updatedAt: new Date().toISOString(), - baselineCatalog: assertDirectCatalogRef(state?.baselineCatalog, 'full', 'baseline full'), - latestCatalog: assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'), - historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => ( - assertDirectCatalogRef(catalog, 'history', 'history') - )), + baselineCatalog: assertDirectCatalogRef( + state?.baselineCatalog, + 'full', + 'baseline full', + ), + latestCatalog: assertDirectCatalogRef( + state?.latestCatalog, + 'full', + 'latest full', + ), + historyCatalogs: (state?.historyCatalogs ?? []).map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ), }; } -function persistDirectFilesState({statePath, legacyStatePath, state}) { +function persistDirectFilesState({ statePath, legacyStatePath, state }) { atomicWriteGzipJson(statePath, state); if (legacyStatePath !== statePath && existsSync(legacyStatePath)) { - rmSync(legacyStatePath, {force: false}); + rmSync(legacyStatePath, { force: false }); } } @@ -1533,7 +1891,7 @@ function compactDirectFilesResult(result) { if (!result.catalog) { return result; } - const {catalog, ...rest} = result; + const { catalog, ...rest } = result; return { ...rest, catalog: { @@ -1547,34 +1905,40 @@ function compactDirectFilesResult(result) { baselineCatalogId: catalog.baselineCatalogId, rootName: catalog.rootName, fileCount: Array.isArray(catalog.files) ? catalog.files.length : 0, - symlinkCount: Array.isArray(catalog.symlinks) ? catalog.symlinks.length : 0, + symlinkCount: Array.isArray(catalog.symlinks) + ? catalog.symlinks.length + : 0, }, }; } function assertDirectCatalogRef(catalog, expectedMode, label) { if ( - !catalog - || catalog.mode !== expectedMode - || !/^[a-f0-9]{64}$/u.test(catalog.catalogId) - || typeof catalog.objectKey !== 'string' - || !catalog.objectKey - || !Number.isSafeInteger(catalog.contentLength) - || catalog.contentLength <= 0 - || !/^[a-f0-9]{64}$/u.test(catalog.sha256) - || typeof catalog.verifiedAt !== 'string' - || !catalog.verifiedAt + !catalog || + catalog.mode !== expectedMode || + !/^[a-f0-9]{64}$/u.test(catalog.catalogId) || + typeof catalog.objectKey !== 'string' || + !catalog.objectKey || + !Number.isSafeInteger(catalog.contentLength) || + catalog.contentLength <= 0 || + !/^[a-f0-9]{64}$/u.test(catalog.sha256) || + typeof catalog.verifiedAt !== 'string' || + !catalog.verifiedAt ) { throw new Error(`files ${label} catalog ref 无效。`); } return directCatalogRef(catalog); } -function buildDirectFilesLatest({database, bucket, state}) { - const latestFullCatalog = assertDirectCatalogRef(state?.latestCatalog, 'full', 'latest full'); - const historyCatalogs = (state?.historyCatalogs ?? []).map((catalog) => ( - assertDirectCatalogRef(catalog, 'history', 'history') - )); +function buildDirectFilesLatest({ database, bucket, state }) { + const latestFullCatalog = assertDirectCatalogRef( + state?.latestCatalog, + 'full', + 'latest full', + ); + const historyCatalogs = (state?.historyCatalogs ?? []).map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ); return { schemaVersion: DIRECT_FILES_LATEST_SCHEMA_VERSION, backupKind: 'spacetimedb-direct-files-latest', @@ -1586,20 +1950,26 @@ function buildDirectFilesLatest({database, bucket, state}) { }; } -function validateDirectFilesLatest(latest, {database, bucket}) { +function validateDirectFilesLatest(latest, { database, bucket }) { if ( - latest?.schemaVersion !== DIRECT_FILES_LATEST_SCHEMA_VERSION - || latest.backupKind !== 'spacetimedb-direct-files-latest' - || latest.database !== database - || latest.bucket !== bucket - || !Array.isArray(latest.historyCatalogs) + latest?.schemaVersion !== DIRECT_FILES_LATEST_SCHEMA_VERSION || + latest.backupKind !== 'spacetimedb-direct-files-latest' || + latest.database !== database || + latest.bucket !== bucket || + !Array.isArray(latest.historyCatalogs) ) { throw new Error('files latest pointer 契约无效。'); } return { ...latest, - latestFullCatalog: assertDirectCatalogRef(latest.latestFullCatalog, 'full', 'latest full'), - historyCatalogs: latest.historyCatalogs.map((catalog) => assertDirectCatalogRef(catalog, 'history', 'history')), + latestFullCatalog: assertDirectCatalogRef( + latest.latestFullCatalog, + 'full', + 'latest full', + ), + historyCatalogs: latest.historyCatalogs.map((catalog) => + assertDirectCatalogRef(catalog, 'history', 'history'), + ), }; } @@ -1613,8 +1983,11 @@ async function publishDirectFilesLatest({ uploadManifestFn, verifyFn, }) { - const latest = buildDirectFilesLatest({database, bucket, state}); - for (const catalogRef of [latest.latestFullCatalog, ...latest.historyCatalogs]) { + const latest = buildDirectFilesLatest({ database, bucket, state }); + for (const catalogRef of [ + latest.latestFullCatalog, + ...latest.historyCatalogs, + ]) { await verifyFn({ ...uploadOptions, objectKey: catalogRef.objectKey, @@ -1622,10 +1995,17 @@ async function publishDirectFilesLatest({ archiveSha256: catalogRef.sha256, }); } - const latestPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-latest.json`); + const latestPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-latest.json`, + ); const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; - writeManifest({manifestPath: latestPath, payload: latest}); - const uploaded = await uploadManifestFn({manifestPath: latestPath, ...uploadOptions, objectKey: latestObjectKey}); + writeManifest({ manifestPath: latestPath, payload: latest }); + const uploaded = await uploadManifestFn({ + manifestPath: latestPath, + ...uploadOptions, + objectKey: latestObjectKey, + }); const verification = await verifyFn({ ...uploadOptions, objectKey: latestObjectKey, @@ -1657,29 +2037,46 @@ export async function runDirectFilesBackup({ verifyFn = verifyOssObject, concurrency = 1, }) { - mkdirSync(workDir, {recursive: true}); - const statePath = directFilesStatePath({workDir, database}); - const legacyStatePath = legacyDirectFilesStatePath({workDir, database}); - const state = readDirectFilesState(statePath, {database, bucket}); - if (mode === 'history' && (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full')) { - throw new Error(`files history 模式缺少已发布 full baseline catalog: ${statePath}`); + mkdirSync(workDir, { recursive: true }); + const statePath = directFilesStatePath({ workDir, database }); + const legacyStatePath = legacyDirectFilesStatePath({ workDir, database }); + const state = readDirectFilesState(statePath, { database, bucket }); + if ( + mode === 'history' && + (!state?.baselineCatalog || state?.latestCatalog?.mode !== 'full') + ) { + throw new Error( + `files history 模式缺少已发布 full baseline catalog: ${statePath}`, + ); } - const plan = mode === 'history' ? discoverHistoryPlan({dataDir}) : null; + const plan = mode === 'history' ? discoverHistoryPlan({ dataDir }) : null; const collected = await collectDirectFileEntries({ dataDir, candidates: plan?.candidates ?? null, objectPrefix, database, }); - const baselineCatalogId = mode === 'history' ? (state?.baselineCatalog?.catalogId ?? '') : ''; + const baselineCatalogId = + mode === 'history' ? (state?.baselineCatalog?.catalogId ?? '') : ''; const rootName = basename(dataDir); - const catalogId = directCatalogIdentity({mode, baselineCatalogId, rootName, ...collected}); + const catalogId = directCatalogIdentity({ + mode, + baselineCatalogId, + rootName, + ...collected, + }); const basePrefix = normalizeObjectPrefix(objectPrefix, database); const catalogObjectKey = `${basePrefix}/catalogs/${mode}/${catalogId}.json`; - const catalogPath = join(workDir, `${sanitizeObjectPart(database, 'spacetimedb')}-${mode}-${catalogId}.catalog.json`); + const catalogPath = join( + workDir, + `${sanitizeObjectPart(database, 'spacetimedb')}-${mode}-${catalogId}.catalog.json`, + ); const catalog = { schemaVersion: DIRECT_FILES_CATALOG_SCHEMA_VERSION, - backupKind: mode === 'full' ? 'spacetimedb-data-dir-files' : 'spacetimedb-history-files', + backupKind: + mode === 'full' + ? 'spacetimedb-data-dir-files' + : 'spacetimedb-history-files', database, bucket, mode, @@ -1691,7 +2088,7 @@ export async function runDirectFilesBackup({ files: collected.files, symlinks: collected.symlinks, }; - writeManifest({manifestPath: catalogPath, payload: catalog}); + writeManifest({ manifestPath: catalogPath, payload: catalog }); const summary = { statePath, catalogPath, @@ -1699,16 +2096,22 @@ export async function runDirectFilesBackup({ catalogId, fileCount: collected.files.length, symlinkCount: collected.symlinks.length, - totalSizeBytes: collected.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(), + totalSizeBytes: collected.files + .reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n) + .toString(), candidateCount: plan?.candidates.length ?? 0, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), {...summary, dryRun}); + atomicWriteJson(resolvePath(resultFile), { ...summary, dryRun }); } - console.log(`[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`); + console.log( + `[database-backup] files ${mode}: files=${summary.fileCount}, symlinks=${summary.symlinkCount}, size=${formatBytes(summary.totalSizeBytes)}, catalog=${catalogId}`, + ); if (dryRun) { - console.log('[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。'); - return {...summary, catalog, uploadedCount: 0, reusedCount: 0}; + console.log( + '[database-backup] files dry-run,仅扫描并生成本地 catalog,不上传或删除。', + ); + return { ...summary, catalog, uploadedCount: 0, reusedCount: 0 }; } if (mode === 'history' && plan.candidates.length === 0) { await verifyFn({ @@ -1727,8 +2130,17 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); - persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const compactedState = normalizeDirectFilesState({ + state, + dataDir, + database, + bucket, + }); + persistDirectFilesState({ + statePath, + legacyStatePath, + state: compactedState, + }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1746,13 +2158,24 @@ export async function runDirectFilesBackup({ metadataCleanup, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(emptyResult)); + atomicWriteJson( + resolvePath(resultFile), + compactDirectFilesResult(emptyResult), + ); } return emptyResult; } - if (state?.latestCatalog?.catalogId === catalogId && state.latestCatalog.mode === mode) { - await verifyFn({...uploadOptions, objectKey: state.latestCatalog.objectKey, contentLength: state.latestCatalog.contentLength, archiveSha256: state.latestCatalog.sha256}); + if ( + state?.latestCatalog?.catalogId === catalogId && + state.latestCatalog.mode === mode + ) { + await verifyFn({ + ...uploadOptions, + objectKey: state.latestCatalog.objectKey, + contentLength: state.latestCatalog.contentLength, + archiveSha256: state.latestCatalog.sha256, + }); if (mode === 'full') { const latestPointer = await publishDirectFilesLatest({ workDir, @@ -1764,8 +2187,17 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - const compactedState = normalizeDirectFilesState({state, dataDir, database, bucket}); - persistDirectFilesState({statePath, legacyStatePath, state: compactedState}); + const compactedState = normalizeDirectFilesState({ + state, + dataDir, + database, + bucket, + }); + persistDirectFilesState({ + statePath, + legacyStatePath, + state: compactedState, + }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1793,13 +2225,18 @@ export async function runDirectFilesBackup({ }); } - const previousFiles = new Map(directPreviousFiles({state, workDir, database}).map((file) => [file.path, file])); + const previousFiles = new Map( + directPreviousFiles({ state, workDir, database }).map((file) => [ + file.path, + file, + ]), + ); let uploadedCount = 0; let reusedCount = 0; let nextIndex = 0; let completedCount = 0; const workerCount = Math.min(concurrency, collected.files.length); - const workers = Array.from({length: workerCount}, async () => { + const workers = Array.from({ length: workerCount }, async () => { while (nextIndex < collected.files.length) { const index = nextIndex; nextIndex += 1; @@ -1819,8 +2256,14 @@ export async function runDirectFilesBackup({ reusedCount += 1; } completedCount += 1; - if (collected.files.length <= 100 || completedCount % 1000 === 0 || completedCount === collected.files.length) { - console.log(`[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`); + if ( + collected.files.length <= 100 || + completedCount % 1000 === 0 || + completedCount === collected.files.length + ) { + console.log( + `[database-backup] files 进度: ${completedCount}/${collected.files.length} (${result.status}) ${file.path}`, + ); } } }); @@ -1832,7 +2275,12 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - await verifyFn({...uploadOptions, objectKey: catalogObjectKey, contentLength: catalogUpload.contentLength, archiveSha256: catalogUpload.archiveSha256}); + await verifyFn({ + ...uploadOptions, + objectKey: catalogObjectKey, + contentLength: catalogUpload.contentLength, + archiveSha256: catalogUpload.archiveSha256, + }); if (mode === 'history') { await verifyFn({ @@ -1858,15 +2306,18 @@ export async function runDirectFilesBackup({ bucket, updatedAt: new Date().toISOString(), baselineCatalog: directCatalogRef(state?.baselineCatalog ?? catalogRef), - latestCatalog: directCatalogRef(mode === 'full' ? catalogRef : state.latestCatalog), - historyCatalogs: mode === 'history' - ? [ - ...(state.historyCatalogs ?? []) - .filter((item) => item.catalogId !== catalogId) - .map((item) => directCatalogRef(item)), - directCatalogRef(catalogRef), - ] - : (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)), + latestCatalog: directCatalogRef( + mode === 'full' ? catalogRef : state.latestCatalog, + ), + historyCatalogs: + mode === 'history' + ? [ + ...(state.historyCatalogs ?? []) + .filter((item) => item.catalogId !== catalogId) + .map((item) => directCatalogRef(item)), + directCatalogRef(catalogRef), + ] + : (state?.historyCatalogs ?? []).map((item) => directCatalogRef(item)), }; const latestPointer = await publishDirectFilesLatest({ workDir, @@ -1878,7 +2329,7 @@ export async function runDirectFilesBackup({ uploadManifestFn, verifyFn, }); - persistDirectFilesState({statePath, legacyStatePath, state: nextState}); + persistDirectFilesState({ statePath, legacyStatePath, state: nextState }); const metadataCleanup = compactDirectFilesLocalMetadata({ workDir, database, @@ -1886,7 +2337,10 @@ export async function runDirectFilesBackup({ }); let cleanup = null; if (mode === 'history') { - cleanup = cleanupHistoryCandidates({dataDir, candidates: plan.candidates}); + cleanup = cleanupHistoryCandidates({ + dataDir, + candidates: plan.candidates, + }); } const finalResult = { ...summary, @@ -1898,12 +2352,15 @@ export async function runDirectFilesBackup({ metadataCleanup, }; if (resultFile) { - atomicWriteJson(resolvePath(resultFile), compactDirectFilesResult(finalResult)); + atomicWriteJson( + resolvePath(resultFile), + compactDirectFilesResult(finalResult), + ); } return finalResult; } -async function downloadOssBuffer({objectKey, uploadOptions}) { +async function downloadOssBuffer({ objectKey, uploadOptions }) { const response = await signedOssRequest({ ...ossRequestDefaults(uploadOptions), method: 'GET', @@ -1913,7 +2370,7 @@ async function downloadOssBuffer({objectKey, uploadOptions}) { return Buffer.from(await response.arrayBuffer()); } -async function downloadOssFile({objectKey, destinationPath, uploadOptions}) { +async function downloadOssFile({ objectKey, destinationPath, uploadOptions }) { const response = await signedOssRequest({ ...ossRequestDefaults(uploadOptions), method: 'GET', @@ -1921,50 +2378,67 @@ async function downloadOssFile({objectKey, destinationPath, uploadOptions}) { operation: '下载对象', }); const tempPath = `${destinationPath}.partial-${process.pid}`; - rmSync(tempPath, {force: true}); + rmSync(tempPath, { force: true }); try { if (response.body) { - await pipeline(Readable.fromWeb(response.body), createWriteStream(tempPath, {mode: 0o600})); + await pipeline( + Readable.fromWeb(response.body), + createWriteStream(tempPath, { mode: 0o600 }), + ); } else { - writeFileSync(tempPath, Buffer.alloc(0), {mode: 0o600}); + writeFileSync(tempPath, Buffer.alloc(0), { mode: 0o600 }); } renameSync(tempPath, destinationPath); } catch (error) { - rmSync(tempPath, {force: true}); + rmSync(tempPath, { force: true }); throw error; } } -async function loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}) { - const catalogBody = await downloadBufferFn({objectKey: catalogRef.objectKey, uploadOptions}); - if (catalogBody.length !== catalogRef.contentLength || sha256Hex(catalogBody) !== catalogRef.sha256) { - throw new Error(`files restore catalog 长度或 SHA-256 不一致: ${catalogRef.objectKey}`); +async function loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, +}) { + const catalogBody = await downloadBufferFn({ + objectKey: catalogRef.objectKey, + uploadOptions, + }); + if ( + catalogBody.length !== catalogRef.contentLength || + sha256Hex(catalogBody) !== catalogRef.sha256 + ) { + throw new Error( + `files restore catalog 长度或 SHA-256 不一致: ${catalogRef.objectKey}`, + ); } const catalog = JSON.parse(catalogBody.toString('utf8')); if ( - catalog.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION - || catalog.backupKind !== 'spacetimedb-data-dir-files' - || catalog.database !== database - || catalog.bucket !== bucket - || catalog.catalogId !== catalogRef.catalogId - || !Array.isArray(catalog.directories) - || !Array.isArray(catalog.files) + catalog.schemaVersion !== DIRECT_FILES_CATALOG_SCHEMA_VERSION || + catalog.backupKind !== 'spacetimedb-data-dir-files' || + catalog.database !== database || + catalog.bucket !== bucket || + catalog.catalogId !== catalogRef.catalogId || + !Array.isArray(catalog.directories) || + !Array.isArray(catalog.files) ) { throw new Error(`files restore catalog 契约无效: ${catalogRef.objectKey}`); } - return {...catalog, symlinks: catalog.symlinks ?? []}; + return { ...catalog, symlinks: catalog.symlinks ?? [] }; } function assertDirectCatalogFile(file, index) { if ( - !file - || typeof file.path !== 'string' - || !Number.isSafeInteger(file.sizeBytes) - || file.sizeBytes < 0 - || !/^[a-f0-9]{64}$/u.test(file.sha256) - || typeof file.objectKey !== 'string' - || !file.objectKey - || !Number.isSafeInteger(file.mode) + !file || + typeof file.path !== 'string' || + !Number.isSafeInteger(file.sizeBytes) || + file.sizeBytes < 0 || + !/^[a-f0-9]{64}$/u.test(file.sha256) || + typeof file.objectKey !== 'string' || + !file.objectKey || + !Number.isSafeInteger(file.mode) ) { throw new Error(`files restore catalog 文件项无效: index=${index}`); } @@ -1972,18 +2446,21 @@ function assertDirectCatalogFile(file, index) { function assertDirectCatalogSymlink(symlink, index, restoreDir) { if ( - !symlink - || typeof symlink.path !== 'string' - || !symlink.path - || typeof symlink.target !== 'string' - || !symlink.target - || isAbsolute(symlink.target) + !symlink || + typeof symlink.path !== 'string' || + !symlink.path || + typeof symlink.target !== 'string' || + !symlink.target || + isAbsolute(symlink.target) ) { throw new Error(`files restore catalog 符号链接项无效: index=${index}`); } const destinationPath = resolve(restoreDir, symlink.path); assertSafeRelativePath(restoreDir, destinationPath); - assertSafeRelativePath(restoreDir, resolve(dirname(destinationPath), symlink.target)); + assertSafeRelativePath( + restoreDir, + resolve(dirname(destinationPath), symlink.target), + ); } async function restoreDirectFilesCatalog({ @@ -1996,8 +2473,12 @@ async function restoreDirectFilesCatalog({ }) { const resolvedRestoreDir = resolvePath(restoreDir); catalog.files.forEach(assertDirectCatalogFile); - catalog.symlinks.forEach((symlink, index) => assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir)); - const totalSizeBytes = catalog.files.reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n).toString(); + catalog.symlinks.forEach((symlink, index) => + assertDirectCatalogSymlink(symlink, index, resolvedRestoreDir), + ); + const totalSizeBytes = catalog.files + .reduce((sum, file) => sum + BigInt(file.sizeBytes), 0n) + .toString(); if (dryRun) { const result = { restoreDir: resolvedRestoreDir, @@ -2014,14 +2495,14 @@ async function restoreDirectFilesCatalog({ } return result; } - mkdirSync(resolvedRestoreDir, {recursive: true, mode: 0o700}); + mkdirSync(resolvedRestoreDir, { recursive: true, mode: 0o700 }); for (const directoryPath of catalog.directories) { if (directoryPath === '.') { continue; } const absolutePath = resolve(resolvedRestoreDir, directoryPath); assertSafeRelativePath(resolvedRestoreDir, absolutePath); - mkdirSync(absolutePath, {recursive: true}); + mkdirSync(absolutePath, { recursive: true }); } let downloadedCount = 0; @@ -2029,35 +2510,47 @@ async function restoreDirectFilesCatalog({ for (const [index, file] of catalog.files.entries()) { const destinationPath = resolve(resolvedRestoreDir, file.path); assertSafeRelativePath(resolvedRestoreDir, destinationPath); - mkdirSync(dirname(destinationPath), {recursive: true}); + mkdirSync(dirname(destinationPath), { recursive: true }); let reusable = false; if (existsSync(destinationPath) && lstatSync(destinationPath).isFile()) { const stat = statSync(destinationPath); - reusable = stat.size === file.sizeBytes && await sha256FileHex(destinationPath) === file.sha256; + reusable = + stat.size === file.sizeBytes && + (await sha256FileHex(destinationPath)) === file.sha256; } if (reusable) { reusedCount += 1; } else { - rmSync(destinationPath, {force: true}); - await downloadFileFn({objectKey: file.objectKey, destinationPath, uploadOptions}); + rmSync(destinationPath, { force: true }); + await downloadFileFn({ + objectKey: file.objectKey, + destinationPath, + uploadOptions, + }); const stat = statSync(destinationPath); const sha256 = await sha256FileHex(destinationPath); if (stat.size !== file.sizeBytes || sha256 !== file.sha256) { - rmSync(destinationPath, {force: true}); - throw new Error(`files restore 对象长度或 SHA-256 不一致: ${file.path}`); + rmSync(destinationPath, { force: true }); + throw new Error( + `files restore 对象长度或 SHA-256 不一致: ${file.path}`, + ); } downloadedCount += 1; } chmodSync(destinationPath, file.mode & 0o7777); - console.log(`[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`); + console.log( + `[database-backup] files restore: ${index + 1}/${catalog.files.length} (${reusable ? 'reused' : 'downloaded'}) ${file.path}`, + ); } for (const symlink of catalog.symlinks) { const destinationPath = resolve(resolvedRestoreDir, symlink.path); assertSafeRelativePath(resolvedRestoreDir, destinationPath); - mkdirSync(dirname(destinationPath), {recursive: true}); - rmSync(destinationPath, {recursive: true, force: true}); + mkdirSync(dirname(destinationPath), { recursive: true }); + rmSync(destinationPath, { recursive: true, force: true }); symlinkSync(symlink.target, destinationPath); - console.log(`[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`); + console.log( + `[database-backup] files restore: symlink ${symlink.path} -> ${symlink.target}`, + ); } const result = { restoreDir: resolvedRestoreDir, @@ -2085,12 +2578,25 @@ export async function restoreDirectFilesBackup({ downloadBufferFn = downloadOssBuffer, downloadFileFn = downloadOssFile, }) { - const state = readDirectFilesState(resolvePath(statePath), {database, bucket}); + const state = readDirectFilesState(resolvePath(statePath), { + database, + bucket, + }); if (!state?.latestCatalog || state.latestCatalog.mode !== 'full') { throw new Error(`files restore 缺少 full baseline catalog: ${statePath}`); } - const catalogRef = assertDirectCatalogRef(state.latestCatalog, 'full', 'latest full'); - const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + const catalogRef = assertDirectCatalogRef( + state.latestCatalog, + 'full', + 'latest full', + ); + const catalog = await loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, + }); return restoreDirectFilesCatalog({ catalog, restoreDir, @@ -2114,7 +2620,10 @@ export async function restoreDirectFilesLatest({ verifyFn = verifyOssObject, }) { const latestObjectKey = `${normalizeObjectPrefix(objectPrefix, database)}/latest.json`; - const latestBody = await downloadBufferFn({objectKey: latestObjectKey, uploadOptions}); + const latestBody = await downloadBufferFn({ + objectKey: latestObjectKey, + uploadOptions, + }); const latestSha256 = sha256Hex(latestBody); await verifyFn({ ...uploadOptions, @@ -2122,7 +2631,10 @@ export async function restoreDirectFilesLatest({ contentLength: latestBody.length, archiveSha256: latestSha256, }); - const latest = validateDirectFilesLatest(JSON.parse(latestBody.toString('utf8')), {database, bucket}); + const latest = validateDirectFilesLatest( + JSON.parse(latestBody.toString('utf8')), + { database, bucket }, + ); const catalogRef = latest.latestFullCatalog; await verifyFn({ ...uploadOptions, @@ -2130,7 +2642,13 @@ export async function restoreDirectFilesLatest({ contentLength: catalogRef.contentLength, archiveSha256: catalogRef.sha256, }); - const catalog = await loadDirectFilesCatalog({catalogRef, database, bucket, uploadOptions, downloadBufferFn}); + const catalog = await loadDirectFilesCatalog({ + catalogRef, + database, + bucket, + uploadOptions, + downloadBufferFn, + }); return restoreDirectFilesCatalog({ catalog, restoreDir, @@ -2160,7 +2678,12 @@ function formatOssDate(date) { function encodePath(path) { return path .split('/') - .map((segment) => encodeURIComponent(segment).replace(/[!'()*]/gu, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)) + .map((segment) => + encodeURIComponent(segment).replace( + /[!'()*]/gu, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`, + ), + ) .join('/'); } @@ -2173,7 +2696,10 @@ function encodeQueryComponent(value) { export function buildCanonicalQuery(queries = {}) { return Object.entries(queries) - .map(([key, value]) => [encodeQueryComponent(key), value === null ? null : encodeQueryComponent(value)]) + .map(([key, value]) => [ + encodeQueryComponent(key), + value === null ? null : encodeQueryComponent(value), + ]) .sort(([leftKey, leftValue], [rightKey, rightValue]) => { if (leftKey !== rightKey) { return leftKey < rightKey ? -1 : 1; @@ -2182,7 +2708,7 @@ export function buildCanonicalQuery(queries = {}) { const right = rightValue ?? ''; return left === right ? 0 : left < right ? -1 : 1; }) - .map(([key, value]) => value === null ? key : `${key}=${value}`) + .map(([key, value]) => (value === null ? key : `${key}=${value}`)) .join('&'); } @@ -2190,13 +2716,26 @@ function canonicalHeaderValue(value) { return String(value).trim().replace(/\s+/gu, ' '); } -export function buildAuthorization({method, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, headers, date, queries = {}}) { +export function buildAuthorization({ + method, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + headers, + date, + queries = {}, +}) { const region = regionFromEndpoint(endpoint); const scopeDate = formatScopeDate(date); const scope = `${scopeDate}/${region}/${OSS_SERVICE}/${OSS_REQUEST}`; const canonicalUri = `/${encodeURIComponent(bucket)}/${encodePath(objectKey)}`; const signedHeaders = Object.fromEntries( - Object.entries(headers).map(([key, value]) => [key.toLowerCase(), canonicalHeaderValue(value)]), + Object.entries(headers).map(([key, value]) => [ + key.toLowerCase(), + canonicalHeaderValue(value), + ]), ); const canonicalHeaders = Object.entries(signedHeaders) .sort(([left], [right]) => left.localeCompare(right)) @@ -2211,8 +2750,16 @@ export function buildAuthorization({method, bucket, endpoint, objectKey, accessK additionalHeaders, UNSIGNED_PAYLOAD, ].join('\n'); - const stringToSign = [OSS_ALGORITHM, headers['x-oss-date'], scope, sha256Hex(canonicalRequest)].join('\n'); - const signature = hmac(Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), scopeDate); + const stringToSign = [ + OSS_ALGORITHM, + headers['x-oss-date'], + scope, + sha256Hex(canonicalRequest), + ].join('\n'); + const signature = hmac( + Buffer.from(`aliyun_v4${accessKeySecret}`, 'utf8'), + scopeDate, + ); const regionKey = hmac(signature, region); const serviceKey = hmac(regionKey, OSS_SERVICE); const signingKey = hmac(serviceKey, OSS_REQUEST); @@ -2220,7 +2767,7 @@ export function buildAuthorization({method, bucket, endpoint, objectKey, accessK return `${OSS_ALGORITHM} Credential=${accessKeyId}/${scope},AdditionalHeaders=${additionalHeaders},Signature=${finalSignature}`; } -function buildOssUrl({bucket, endpoint, objectKey, queries = {}}) { +function buildOssUrl({ bucket, endpoint, objectKey, queries = {} }) { const canonicalQuery = buildCanonicalQuery(queries); return `https://${bucket}.${endpoint}/${encodePath(objectKey)}${canonicalQuery ? `?${canonicalQuery}` : ''}`; } @@ -2229,8 +2776,11 @@ function isRetryableOssStatus(status) { return RETRYABLE_OSS_HTTP_STATUSES.has(status); } -function retryDelayMs({attempt, baseDelayMs, maxDelayMs, randomFn}) { - const ceiling = Math.min(maxDelayMs, baseDelayMs * (2 ** Math.max(0, attempt - 1))); +function retryDelayMs({ attempt, baseDelayMs, maxDelayMs, randomFn }) { + const ceiling = Math.min( + maxDelayMs, + baseDelayMs * 2 ** Math.max(0, attempt - 1), + ); return Math.floor(randomFn() * ceiling); } @@ -2282,7 +2832,7 @@ async function signedOssRequest({ retryBaseDelayMs, retryMaxDelayMs, }) { - const targetUrl = buildOssUrl({bucket, endpoint, objectKey, queries}); + const targetUrl = buildOssUrl({ bucket, endpoint, objectKey, queries }); let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { @@ -2304,12 +2854,12 @@ async function signedOssRequest({ date: now, queries, }); - const requestHeaders = {...signedHeaders, authorization}; + const requestHeaders = { ...signedHeaders, authorization }; if (contentLength !== undefined) { requestHeaders['content-length'] = String(contentLength); } const body = bodyFactory ? bodyFactory() : undefined; - const requestOptions = {method, headers: requestHeaders}; + const requestOptions = { method, headers: requestHeaders }; if (body !== undefined) { requestOptions.body = body; requestOptions.duplex = 'half'; @@ -2319,7 +2869,10 @@ async function signedOssRequest({ try { response = await fetchImpl(targetUrl, requestOptions); } catch (error) { - lastError = new Error(`OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, {cause: error}); + lastError = new Error( + `OSS ${operation}请求失败: oss://${bucket}/${objectKey}`, + { cause: error }, + ); } if (response?.ok) { @@ -2338,16 +2891,27 @@ async function signedOssRequest({ if (!retryable || attempt >= maxAttempts) { throw lastError; } - const delayMs = retryDelayMs({attempt, baseDelayMs: retryBaseDelayMs, maxDelayMs: retryMaxDelayMs, randomFn}); - console.warn(`[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`); + const delayMs = retryDelayMs({ + attempt, + baseDelayMs: retryBaseDelayMs, + maxDelayMs: retryMaxDelayMs, + randomFn, + }); + console.warn( + `[database-backup] OSS ${operation}失败,${delayMs}ms 后重试 (${attempt}/${maxAttempts})`, + ); await sleepImpl(delayMs); } - throw lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`); + throw ( + lastError ?? new Error(`OSS ${operation}失败: oss://${bucket}/${objectKey}`) + ); } function readXmlTag(xml, tagName) { - const match = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'u').exec(xml); + const match = new RegExp(`<${tagName}>([\\s\\S]*?)`, 'u').exec( + xml, + ); if (!match) { return ''; } @@ -2371,19 +2935,26 @@ function escapeXml(value) { function buildCompleteMultipartBody(parts) { const partXml = parts - .map(({partNumber, etag}) => [ - '', - `${partNumber}`, - `${escapeXml(etag)}`, - '', - ].join('')) + .map(({ partNumber, etag }) => + [ + '', + `${partNumber}`, + `${escapeXml(etag)}`, + '', + ].join(''), + ) .join(''); return `${partXml}`; } function resolveMultipartPartSize(fileSize, configuredPartSize) { - if (!Number.isSafeInteger(configuredPartSize) || configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES) { - throw new Error(`OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`); + if ( + !Number.isSafeInteger(configuredPartSize) || + configuredPartSize < OSS_MIN_MULTIPART_PART_SIZE_BYTES + ) { + throw new Error( + `OSS multipart part size 必须是 >= ${OSS_MIN_MULTIPART_PART_SIZE_BYTES} 的安全整数`, + ); } const minimumForPartLimit = Math.ceil(fileSize / OSS_MAX_MULTIPART_PARTS); const partSize = Math.max(configuredPartSize, minimumForPartLimit); @@ -2393,7 +2964,11 @@ function resolveMultipartPartSize(fileSize, configuredPartSize) { return partSize; } -async function verifyUploadedObject({requestOptions, expectedContentLength, expectedArchiveSha256}) { +async function verifyUploadedObject({ + requestOptions, + expectedContentLength, + expectedArchiveSha256, +}) { const response = await signedOssRequest({ ...requestOptions, method: 'HEAD', @@ -2411,17 +2986,27 @@ async function verifyUploadedObject({requestOptions, expectedContentLength, expe } const remoteContentLength = Number(effectiveLengthHeader); if (remoteContentLength !== expectedContentLength) { - throw new Error(`OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`); + throw new Error( + `OSS HEAD 验证长度不一致: local=${expectedContentLength}, remote=${remoteContentLength}`, + ); } const remoteArchiveSha256 = String( - response.headers.get('x-oss-meta-file-sha256') - ?? response.headers.get('x-oss-meta-archive-sha256') - ?? '', - ).trim().toLowerCase(); + response.headers.get('x-oss-meta-file-sha256') ?? + response.headers.get('x-oss-meta-archive-sha256') ?? + '', + ) + .trim() + .toLowerCase(); if (remoteArchiveSha256 !== expectedArchiveSha256) { - throw new Error(`OSS HEAD 验证 SHA-256 不一致: local=${expectedArchiveSha256}, remote=${remoteArchiveSha256 || ''}`); + throw new Error( + `OSS HEAD 验证 SHA-256 不一致: local=${expectedArchiveSha256}, remote=${remoteArchiveSha256 || ''}`, + ); } - return {verifiedAt: new Date().toISOString(), remoteContentLength, remoteArchiveSha256}; + return { + verifiedAt: new Date().toISOString(), + remoteContentLength, + remoteArchiveSha256, + }; } export async function verifyOssObject({ @@ -2455,22 +3040,28 @@ export async function verifyOssObject({ return verifyUploadedObject({ requestOptions, expectedContentLength: Number(contentLength), - expectedArchiveSha256: String(archiveSha256 ?? '').trim().toLowerCase(), + expectedArchiveSha256: String(archiveSha256 ?? '') + .trim() + .toLowerCase(), }); } -async function abortMultipartUpload({requestOptions, uploadId}) { +async function abortMultipartUpload({ requestOptions, uploadId }) { try { await signedOssRequest({ ...requestOptions, method: 'DELETE', - queries: {uploadId}, + queries: { uploadId }, operation: 'AbortMultipartUpload', maxAttempts: Math.min(2, requestOptions.maxAttempts), }); - console.warn(`[database-backup] 已清理失败的 multipart upload: ${uploadId}`); + console.warn( + `[database-backup] 已清理失败的 multipart upload: ${uploadId}`, + ); } catch (error) { - console.warn(`[database-backup] 清理 multipart upload 失败: ${error.message}`); + console.warn( + `[database-backup] 清理 multipart upload 失败: ${error.message}`, + ); } } @@ -2497,9 +3088,12 @@ export async function uploadArchive({ }) { const fileStat = statSync(archivePath); if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { - throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + throw new Error( + `待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`, + ); } - const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + const verifiedArchiveSha256 = + archiveSha256 || (await sha256FileHex(archivePath)); if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); } @@ -2532,20 +3126,36 @@ export async function uploadArchive({ bodyFactory: () => Buffer.alloc(0), operation: '上传空文件', }); - const verification = await verifyUploadedObject({requestOptions, expectedContentLength: 0, expectedArchiveSha256: verifiedArchiveSha256}); - return {bucket, objectKey, contentLength: 0, archiveSha256: verifiedArchiveSha256, etag: '', uploadMode: 'single', partCount: 1, partSizeBytes: 0, verifiedAt: verification.verifiedAt}; + const verification = await verifyUploadedObject({ + requestOptions, + expectedContentLength: 0, + expectedArchiveSha256: verifiedArchiveSha256, + }); + return { + bucket, + objectKey, + contentLength: 0, + archiveSha256: verifiedArchiveSha256, + etag: '', + uploadMode: 'single', + partCount: 1, + partSizeBytes: 0, + verifiedAt: verification.verifiedAt, + }; } const partSize = resolveMultipartPartSize(fileStat.size, partSizeBytes); const partCount = Math.ceil(fileStat.size / partSize); let uploadId = ''; let uploadCompleted = false; - console.log(`[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`); + console.log( + `[database-backup] multipart 上传 OSS: oss://${bucket}/${objectKey} (${partCount} parts)`, + ); try { const initiateResponse = await signedOssRequest({ ...requestOptions, method: 'POST', - queries: {uploads: null}, + queries: { uploads: null }, headers: { 'content-type': contentType, 'x-oss-meta-archive-sha256': verifiedArchiveSha256, @@ -2568,21 +3178,25 @@ export async function uploadArchive({ const response = await signedOssRequest({ ...requestOptions, method: 'PUT', - queries: {partNumber, uploadId}, - headers: {'content-type': 'application/octet-stream'}, + queries: { partNumber, uploadId }, + headers: { 'content-type': 'application/octet-stream' }, contentLength, bodyFactory: () => { - const stream = createReadStream(archivePath, {start, end}); + const stream = createReadStream(archivePath, { start, end }); return bandwidthLimiter ? bandwidthLimiter.wrap(stream) : stream; }, operation: `UploadPart ${partNumber}/${partCount}`, }); const etag = response.headers.get('etag'); if (!etag) { - throw new Error(`OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`); + throw new Error( + `OSS UploadPart ${partNumber}/${partCount} 响应缺少 ETag`, + ); } - parts.push({partNumber, etag}); - console.log(`[database-backup] multipart 进度: ${partNumber}/${partCount}`); + parts.push({ partNumber, etag }); + console.log( + `[database-backup] multipart 进度: ${partNumber}/${partCount}`, + ); } const completeBody = buildCompleteMultipartBody(parts); @@ -2591,19 +3205,25 @@ export async function uploadArchive({ completeResponse = await signedOssRequest({ ...requestOptions, method: 'POST', - queries: {uploadId}, - headers: {'content-type': 'application/xml'}, + queries: { uploadId }, + headers: { 'content-type': 'application/xml' }, contentLength: Buffer.byteLength(completeBody), bodyFactory: () => completeBody, operation: 'CompleteMultipartUpload', }); const completeResponseText = await completeResponse.text(); if (/)/u.test(completeResponseText)) { - throw new Error(`OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`); + throw new Error( + `OSS CompleteMultipartUpload 返回错误: ${completeResponseText.slice(0, 500)}`, + ); } } catch (completeError) { try { - await verifyUploadedObject({requestOptions, expectedContentLength: fileStat.size, expectedArchiveSha256: verifiedArchiveSha256}); + await verifyUploadedObject({ + requestOptions, + expectedContentLength: fileStat.size, + expectedArchiveSha256: verifiedArchiveSha256, + }); completeResponse = null; } catch { throw completeError; @@ -2629,7 +3249,7 @@ export async function uploadArchive({ }; } catch (error) { if (uploadId && !uploadCompleted) { - await abortMultipartUpload({requestOptions, uploadId}); + await abortMultipartUpload({ requestOptions, uploadId }); } throw error; } @@ -2657,7 +3277,9 @@ export async function uploadDirectFile({ }) { const fileStat = statSync(archivePath); if (!fileStat.isFile() || (!allowEmpty && fileStat.size <= 0)) { - throw new Error(`待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`); + throw new Error( + `待上传备份必须是${allowEmpty ? '' : '非空'}普通文件: ${archivePath}`, + ); } if (fileStat.size > DIRECT_FILES_SINGLE_PUT_MAX_BYTES) { return uploadArchive({ @@ -2681,7 +3303,8 @@ export async function uploadDirectFile({ bandwidthLimiter, }); } - const verifiedArchiveSha256 = archiveSha256 || await sha256FileHex(archivePath); + const verifiedArchiveSha256 = + archiveSha256 || (await sha256FileHex(archivePath)); if (!/^[a-f0-9]{64}$/u.test(verifiedArchiveSha256)) { throw new Error(`归档 SHA-256 无效: ${verifiedArchiveSha256}`); } @@ -2791,10 +3414,15 @@ export async function uploadManifestFile({ expectedContentLength: body.length, expectedArchiveSha256: archiveSha256, }); - return {objectKey, contentLength: body.length, archiveSha256, verifiedAt: verification.verifiedAt}; + return { + objectKey, + contentLength: body.length, + archiveSha256, + verifiedAt: verification.verifiedAt, + }; } -function uploadedManifestPayload({manifest, database, result}) { +function uploadedManifestPayload({ manifest, database, result }) { return { ...manifest, database, @@ -2828,8 +3456,12 @@ export async function uploadHistoryArchiveWithCleanup({ ...uploadOptions, backupKind: 'spacetimedb-history', }); - const uploadedManifest = uploadedManifestPayload({manifest, database: manifest.database, result}); - writeManifest({manifestPath, payload: uploadedManifest}); + const uploadedManifest = uploadedManifestPayload({ + manifest, + database: manifest.database, + result, + }); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await manifestUploadFn({ manifestPath, ...uploadOptions, @@ -2838,13 +3470,15 @@ export async function uploadHistoryArchiveWithCleanup({ uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); let state = validateHistoryState(readManifest(statePath), { database: uploadedManifest.database, dataDir: uploadedManifest.dataDir, }); if (state.baseline.id !== uploadedManifest.baselineId) { - throw new Error(`history manifest baselineId 与 state 不匹配: manifest=${uploadedManifest.baselineId}, state=${state.baseline.id}`); + throw new Error( + `history manifest baselineId 与 state 不匹配: manifest=${uploadedManifest.baselineId}, state=${state.baseline.id}`, + ); } await verifyFn({ ...uploadOptions, @@ -2881,69 +3515,111 @@ export async function uploadHistoryArchiveWithCleanup({ status: 'cleaned', cleanedAt: new Date().toISOString(), }); - return {result, uploadedManifest, cleanup, state}; + return { result, uploadedManifest, cleanup, state }; } -export function discoverDeferredArchiveUploads({workDir, database, includeUploaded = false}) { +export function discoverDeferredArchiveUploads({ + workDir, + database, + includeUploaded = false, +}) { const resolvedWorkDir = resolvePath(workDir); if (!existsSync(resolvedWorkDir)) { - return {archives: [], missingArchives: []}; + return { archives: [], missingArchives: [] }; } const archives = []; const missingArchives = []; const manifestSuffix = '.tar.gz.manifest.json'; const expectedDatabase = String(database || '').trim(); - const entries = readdirSync(resolvedWorkDir, {withFileTypes: true}) - .filter((candidate) => candidate.isFile() && candidate.name.endsWith(manifestSuffix)) + const entries = readdirSync(resolvedWorkDir, { withFileTypes: true }) + .filter( + (candidate) => + candidate.isFile() && candidate.name.endsWith(manifestSuffix), + ) .sort((left, right) => left.name.localeCompare(right.name, 'en')); for (const entry of entries) { const manifestPath = join(resolvedWorkDir, entry.name); const manifest = readManifest(manifestPath); const uploadStatus = String(manifest.uploadStatus || '').trim(); - if (!['deferred', 'pending'].includes(uploadStatus) && !(includeUploaded && uploadStatus === 'uploaded')) { + if ( + !['deferred', 'pending'].includes(uploadStatus) && + !(includeUploaded && uploadStatus === 'uploaded') + ) { continue; } - if (expectedDatabase && String(manifest.database || '').trim() !== expectedDatabase) { + if ( + expectedDatabase && + String(manifest.database || '').trim() !== expectedDatabase + ) { continue; } if (!manifest.archivePath) { throw new Error(`deferred 备份清单缺少 archivePath: ${manifestPath}`); } const archivePath = resolvePath(manifest.archivePath); - if (dirname(archivePath) !== resolvedWorkDir || manifestPath !== `${archivePath}.manifest.json`) { + if ( + dirname(archivePath) !== resolvedWorkDir || + manifestPath !== `${archivePath}.manifest.json` + ) { throw new Error(`deferred 备份路径与清单不匹配: ${manifestPath}`); } - const candidate = {archivePath, manifestPath, manifest}; + const candidate = { archivePath, manifestPath, manifest }; if (existsSync(archivePath)) { const archiveStat = lstatSync(archivePath); if (!archiveStat.isFile() || archiveStat.isSymbolicLink()) { - throw new Error(`deferred 备份归档必须是非符号链接的普通文件: ${archivePath}`); + throw new Error( + `deferred 备份归档必须是非符号链接的普通文件: ${archivePath}`, + ); } archives.push(candidate); } else { missingArchives.push(candidate); } } - return {archives, missingArchives}; + return { archives, missingArchives }; } -async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix, bandwidthLimiter}) { +async function uploadExistingArchive({ + args, + env, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + bandwidthLimiter, +}) { const archivePath = resolvePath(args.uploadArchive); if (!existsSync(archivePath)) { throw new Error(`待上传备份文件不存在: ${archivePath}`); } - const manifestPath = resolvePath(args.manifestFile || `${archivePath}.manifest.json`); + const manifestPath = resolvePath( + args.manifestFile || `${archivePath}.manifest.json`, + ); const manifest = existsSync(manifestPath) ? readManifest(manifestPath) : {}; - const dataDir = firstNonEmpty(manifest.dataDir, env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, DEFAULT_PRODUCTION_DATA_DIR); - const database = firstNonEmpty(args.database, manifest.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); - const objectKey = firstNonEmpty(args.objectKey, manifest.objectKey, buildBackupNames({database, dataDir, objectPrefix}).objectKey); + const dataDir = firstNonEmpty( + manifest.dataDir, + env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, + DEFAULT_PRODUCTION_DATA_DIR, + ); + const database = firstNonEmpty( + args.database, + manifest.database, + env.GENARRATIVE_SPACETIME_DATABASE, + basename(dataDir), + ); + const objectKey = firstNonEmpty( + args.objectKey, + manifest.objectKey, + buildBackupNames({ database, dataDir, objectPrefix }).objectKey, + ); if (manifest.backupKind !== 'spacetimedb-history') { manifest.backupKind = 'spacetimedb-data-dir'; manifest.baselineStatePath = firstNonEmpty( manifest.baselineStatePath, - historyStatePath({args, env, workDir: dirname(archivePath), database}), + historyStatePath({ args, env, workDir: dirname(archivePath), database }), ); } @@ -2955,10 +3631,12 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, return; } - const statePath = resolvePath(firstNonEmpty( - manifest.baselineStatePath, - historyStatePath({args, env, workDir: dirname(archivePath), database}), - )); + const statePath = resolvePath( + firstNonEmpty( + manifest.baselineStatePath, + historyStatePath({ args, env, workDir: dirname(archivePath), database }), + ), + ); let result; let uploadedAt; if (manifest.backupKind === 'spacetimedb-history') { @@ -2967,16 +3645,37 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, manifestPath, manifest, statePath, - uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }, }); result = historyResult.result; uploadedAt = historyResult.uploadedManifest.uploadedAt; - console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + console.log( + `[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`, + ); } else { - result = await uploadArchive({archivePath, bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}); - const uploadedManifest = uploadedManifestPayload({manifest, database, result}); + result = await uploadArchive({ + archivePath, + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }); + const uploadedManifest = uploadedManifestPayload({ + manifest, + database, + result, + }); uploadedAt = uploadedManifest.uploadedAt; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await uploadManifestFile({ manifestPath, bucket, @@ -2989,50 +3688,81 @@ async function uploadExistingArchive({args, env, bucket, endpoint, accessKeyId, uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const previousState = existsSync(statePath) - ? validateHistoryState(readManifest(statePath), {database, dataDir}) + ? validateHistoryState(readManifest(statePath), { database, dataDir }) : null; - const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); - writeBaselineState({statePath, baseline, previousState}); + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, { + database, + dataDir, + }); + writeBaselineState({ statePath, baseline, previousState }); console.log(`[database-backup] 已写入 baseline state: ${statePath}`); } console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, ...result, uploadedAt}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, statePath, ...result, uploadedAt }, null, 2)}\n`, + 'utf8', + ); } - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); - console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。'); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); + console.log( + '[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。', + ); } else { console.log(`[database-backup] 已保留本地备份: ${archivePath}`); console.log(`[database-backup] 已保留备份清单: ${manifestPath}`); } } -async function uploadDeferredArchives({args, env, bucket, endpoint, accessKeyId, accessKeySecret, objectPrefix, database, bandwidthLimiter}) { +async function uploadDeferredArchives({ + args, + env, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + objectPrefix, + database, + bandwidthLimiter, +}) { const workDir = resolvePath(args.uploadDeferredDir); - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; - const {archives, missingArchives} = discoverDeferredArchiveUploads({ + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; + const { archives, missingArchives } = discoverDeferredArchiveUploads({ workDir, database, includeUploaded: !keepLocal, }); - for (const {manifestPath} of missingArchives) { - console.warn(`[database-backup] deferred 清单对应的本地归档不存在,跳过: ${manifestPath}`); + for (const { manifestPath } of missingArchives) { + console.warn( + `[database-backup] deferred 清单对应的本地归档不存在,跳过: ${manifestPath}`, + ); } if (archives.length === 0) { console.log(`[database-backup] 没有可补偿的本地归档: ${workDir}`); return; } - console.log(`[database-backup] 开始串行上传待补偿本地归档: count=${archives.length}`); - for (const {archivePath, manifestPath} of archives) { + console.log( + `[database-backup] 开始串行上传待补偿本地归档: count=${archives.length}`, + ); + for (const { archivePath, manifestPath } of archives) { await uploadExistingArchive({ - args: {...args, uploadArchive: archivePath, manifestFile: manifestPath}, + args: { ...args, uploadArchive: archivePath, manifestFile: manifestPath }, env, bucket, endpoint, @@ -3042,17 +3772,29 @@ async function uploadDeferredArchives({args, env, bucket, endpoint, accessKeyId, bandwidthLimiter, }); } - console.log(`[database-backup] 待补偿本地归档上传完成: count=${archives.length}`); + console.log( + `[database-backup] 待补偿本地归档上传完成: count=${archives.length}`, + ); } -async function publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter}) { +async function publishExistingManifest({ + args, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter, +}) { const manifestPath = resolvePath(args.publishManifest); const manifest = readManifest(manifestPath); if (manifest.uploadStatus !== 'uploaded' || !manifest.objectKey) { - throw new Error('只允许发布 uploadStatus=uploaded 且包含 objectKey 的备份 manifest。'); + throw new Error( + '只允许发布 uploadStatus=uploaded 且包含 objectKey 的备份 manifest。', + ); } - manifest.manifestObjectKey = manifest.manifestObjectKey || `${manifest.objectKey}.manifest.json`; - writeManifest({manifestPath, payload: manifest}); + manifest.manifestObjectKey = + manifest.manifestObjectKey || `${manifest.objectKey}.manifest.json`; + writeManifest({ manifestPath, payload: manifest }); const result = await uploadManifestFile({ manifestPath, bucket, @@ -3065,16 +3807,28 @@ async function publishExistingManifest({args, bucket, endpoint, accessKeyId, acc manifest.manifestVerifiedAt = result.verifiedAt; manifest.manifestContentLength = result.contentLength; manifest.manifestArchiveSha256 = result.archiveSha256; - writeManifest({manifestPath, payload: manifest}); - console.log(`[database-backup] manifest 上传并验真完成: ${JSON.stringify(result)}`); + writeManifest({ manifestPath, payload: manifest }); + console.log( + `[database-backup] manifest 上传并验真完成: ${JSON.stringify(result)}`, + ); } -export async function resumeUploadedHistoryBatch({statePath, state, dataDir, verificationOptions, verifyFn = verifyOssObject}) { - const pendingBatch = state.batches.find((batch) => batch.status === 'uploaded'); +export async function resumeUploadedHistoryBatch({ + statePath, + state, + dataDir, + verificationOptions, + verifyFn = verifyOssObject, +}) { + const pendingBatch = state.batches.find( + (batch) => batch.status === 'uploaded', + ); if (!pendingBatch) { return state; } - console.log(`[database-backup] 重试已上传 history 批次的本地清理: ${pendingBatch.batchId}`); + console.log( + `[database-backup] 重试已上传 history 批次的本地清理: ${pendingBatch.batchId}`, + ); await verifyFn({ ...verificationOptions, objectKey: pendingBatch.objectKey, @@ -3087,7 +3841,10 @@ export async function resumeUploadedHistoryBatch({statePath, state, dataDir, ver contentLength: pendingBatch.manifestContentLength, archiveSha256: pendingBatch.manifestArchiveSha256, }); - const cleanup = cleanupHistoryCandidates({dataDir, candidates: pendingBatch.candidates}); + const cleanup = cleanupHistoryCandidates({ + dataDir, + candidates: pendingBatch.candidates, + }); const manifest = { batchId: pendingBatch.batchId, uploadedAt: pendingBatch.uploadedAt, @@ -3114,7 +3871,9 @@ export async function resumeUploadedHistoryBatch({statePath, state, dataDir, ver status: 'cleaned', cleanedAt: new Date().toISOString(), }); - console.log(`[database-backup] 已完成 history 清理重试: ${JSON.stringify(cleanup)}`); + console.log( + `[database-backup] 已完成 history 清理重试: ${JSON.stringify(cleanup)}`, + ); return nextState; } @@ -3132,10 +3891,18 @@ async function runHistoryBackup({ keepLocal, bandwidthLimiter, }) { - const statePath = historyStatePath({args, env, workDir, database}); - let state = loadOrImportHistoryState({args, env, statePath, database, dataDir}); + const statePath = historyStatePath({ args, env, workDir, database }); + let state = loadOrImportHistoryState({ + args, + env, + statePath, + database, + dataDir, + }); if (!args.dryRun && !args.deferUpload) { - console.log(`[database-backup] 重新验真 full baseline: oss://${state.baseline.bucket}/${state.baseline.objectKey}`); + console.log( + `[database-backup] 重新验真 full baseline: oss://${state.baseline.bucket}/${state.baseline.objectKey}`, + ); await verifyOssObject({ bucket: state.baseline.bucket, endpoint, @@ -3158,17 +3925,27 @@ async function runHistoryBackup({ statePath, state, dataDir, - verificationOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + verificationOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, }); } - const plan = discoverHistoryPlan({dataDir}); - console.log(`[database-backup] history replicas: ${JSON.stringify(plan.replicas)}`); - console.log(`[database-backup] history 候选: count=${plan.candidates.length}, size=${formatBytes(plan.totalSizeBytes)}`); + const plan = discoverHistoryPlan({ dataDir }); + console.log( + `[database-backup] history replicas: ${JSON.stringify(plan.replicas)}`, + ); + console.log( + `[database-backup] history 候选: count=${plan.candidates.length}, size=${formatBytes(plan.totalSizeBytes)}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({statePath, baseline: state.baseline, ...plan}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ statePath, baseline: state.baseline, ...plan }, null, 2)}\n`, + 'utf8', + ); } if (args.dryRun) { - console.log('[database-backup] history dry-run,仅输出安全候选,不打包、上传或删除。'); + console.log( + '[database-backup] history dry-run,仅输出安全候选,不打包、上传或删除。', + ); return; } if (plan.candidates.length === 0) { @@ -3176,9 +3953,14 @@ async function runHistoryBackup({ return; } - assertSufficientHistoryWorkDirSpace({historySizeBytes: plan.totalSizeBytes, workDir, args, env}); - const batchId = historyBatchId({baselineId: state.baseline.id, plan}); - const {fileName, objectKey} = buildHistoryNames({ + assertSufficientHistoryWorkDirSpace({ + historySizeBytes: plan.totalSizeBytes, + workDir, + args, + env, + }); + const batchId = historyBatchId({ baselineId: state.baseline.id, plan }); + const { fileName, objectKey } = buildHistoryNames({ database, objectPrefix, baselineId: state.baseline.id, @@ -3203,7 +3985,7 @@ async function runHistoryBackup({ totalSizeBytes: plan.totalSizeBytes, uploadStatus: args.deferUpload ? 'deferred' : 'pending', }; - writeManifest({manifestPath, payload: manifest}); + writeManifest({ manifestPath, payload: manifest }); createHistoryArchive({ dataDir, workDir, @@ -3213,9 +3995,15 @@ async function runHistoryBackup({ }); if (args.deferUpload) { - console.log(`[database-backup] 已生成 history 归档,延后上传且未清理源文件: ${archivePath}`); + console.log( + `[database-backup] 已生成 history 归档,延后上传且未清理源文件: ${archivePath}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, statePath, bucket, objectKey, batchId}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, statePath, bucket, objectKey, batchId }, null, 2)}\n`, + 'utf8', + ); } return; } @@ -3225,22 +4013,39 @@ async function runHistoryBackup({ manifestPath, manifest, statePath, - uploadOptions: {bucket, endpoint, objectKey, accessKeyId, accessKeySecret, bandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + objectKey, + accessKeyId, + accessKeySecret, + bandwidthLimiter, + }, }); - console.log(`[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`); + console.log( + `[database-backup] history 上传并清理完成: ${JSON.stringify(historyResult.cleanup)}`, + ); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({ - archivePath, - manifestPath, - statePath, - batchId, - ...historyResult.result, - uploadedAt: historyResult.uploadedManifest.uploadedAt, - }, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify( + { + archivePath, + manifestPath, + statePath, + batchId, + ...historyResult.result, + uploadedAt: historyResult.uploadedManifest.uploadedAt, + }, + null, + 2, + )}\n`, + 'utf8', + ); } if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); console.log('[database-backup] 已删除本地 history 临时归档和清单。'); } } @@ -3248,44 +4053,96 @@ async function runHistoryBackup({ async function main() { const args = parseArgs(process.argv.slice(2)); const env = loadEffectiveEnv(args.envFiles); - const isProductionLike = existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32'; - const dataDir = resolvePath(firstNonEmpty( - args.dataDir, - env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, - isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR, - )); - const workDir = resolvePath(firstNonEmpty( - args.workDir, - args.uploadDeferredDir, - env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR, - isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR, - )); - const bucket = firstNonEmpty(args.bucket, env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, env.ALIYUN_OSS_BUCKET); - const endpoint = normalizeEndpoint(firstNonEmpty(args.endpoint, env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, env.ALIYUN_OSS_ENDPOINT)); - const accessKeyId = firstNonEmpty(args.accessKeyId, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, env.ALIYUN_OSS_ACCESS_KEY_ID); - const accessKeySecret = firstNonEmpty(args.accessKeySecret, env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, env.ALIYUN_OSS_ACCESS_KEY_SECRET); - const objectPrefix = firstNonEmpty(args.objectPrefix, env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, 'database-backups'); - const database = firstNonEmpty(args.database, env.GENARRATIVE_SPACETIME_DATABASE, basename(dataDir)); - const keepLocal = args.keepLocal || String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '').trim().toLowerCase() === 'true'; - const storageFormat = firstNonEmpty(args.storageFormat, env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, 'archive'); - const directFilesConcurrency = parseDirectFilesConcurrency(env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY); - const uploadBandwidthLimiter = createUploadBandwidthLimiter(env.GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND); + const isProductionLike = + existsSync(DEFAULT_PRODUCTION_DATA_DIR) && process.platform !== 'win32'; + const dataDir = resolvePath( + firstNonEmpty( + args.dataDir, + env.GENARRATIVE_DATABASE_BACKUP_DATA_DIR, + isProductionLike ? DEFAULT_PRODUCTION_DATA_DIR : DEFAULT_LOCAL_DATA_DIR, + ), + ); + const workDir = resolvePath( + firstNonEmpty( + args.workDir, + args.uploadDeferredDir, + env.GENARRATIVE_DATABASE_BACKUP_WORK_DIR, + isProductionLike ? DEFAULT_PRODUCTION_WORK_DIR : DEFAULT_LOCAL_WORK_DIR, + ), + ); + const bucket = firstNonEmpty( + args.bucket, + env.GENARRATIVE_DATABASE_BACKUP_OSS_BUCKET, + env.ALIYUN_OSS_BUCKET, + ); + const endpoint = normalizeEndpoint( + firstNonEmpty( + args.endpoint, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ENDPOINT, + env.ALIYUN_OSS_ENDPOINT, + ), + ); + const accessKeyId = firstNonEmpty( + args.accessKeyId, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_ID, + env.ALIYUN_OSS_ACCESS_KEY_ID, + ); + const accessKeySecret = firstNonEmpty( + args.accessKeySecret, + env.GENARRATIVE_DATABASE_BACKUP_OSS_ACCESS_KEY_SECRET, + env.ALIYUN_OSS_ACCESS_KEY_SECRET, + ); + const objectPrefix = firstNonEmpty( + args.objectPrefix, + env.GENARRATIVE_DATABASE_BACKUP_OSS_PREFIX, + 'database-backups', + ); + const database = firstNonEmpty( + args.database, + env.GENARRATIVE_SPACETIME_DATABASE, + basename(dataDir), + ); + const keepLocal = + args.keepLocal || + String(env.GENARRATIVE_DATABASE_BACKUP_KEEP_LOCAL ?? '') + .trim() + .toLowerCase() === 'true'; + const storageFormat = firstNonEmpty( + args.storageFormat, + env.GENARRATIVE_DATABASE_BACKUP_STORAGE_FORMAT, + 'archive', + ); + const directFilesConcurrency = parseDirectFilesConcurrency( + env.GENARRATIVE_DATABASE_BACKUP_FILES_CONCURRENCY, + ); + const uploadBandwidthLimiter = createUploadBandwidthLimiter( + env.GENARRATIVE_DATABASE_BACKUP_UPLOAD_MAX_BYTES_PER_SECOND, + ); if (!['full', 'history'].includes(args.mode)) { throw new Error(`--mode 只能是 full 或 history,实际: ${args.mode}`); } if (!['archive', 'files'].includes(storageFormat)) { - throw new Error(`--storage-format 只能是 archive 或 files,实际: ${storageFormat}`); + throw new Error( + `--storage-format 只能是 archive 或 files,实际: ${storageFormat}`, + ); } - for (const [label, value] of Object.entries({bucket, endpoint, accessKeyId, accessKeySecret})) { + for (const [label, value] of Object.entries({ + bucket, + endpoint, + accessKeyId, + accessKeySecret, + })) { if (!value) { throw new Error(`缺少 ${label} 配置`); } } if (args.restoreFilesState && args.restoreFilesLatest) { - throw new Error('--restore-files-state 与 --restore-files-latest 不能同时使用。'); + throw new Error( + '--restore-files-state 与 --restore-files-latest 不能同时使用。', + ); } if (args.restoreFilesState) { if (!args.restoreDir) { @@ -3296,7 +4153,7 @@ async function main() { restoreDir: args.restoreDir, database, bucket, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + uploadOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, resultFile: args.resultFile, dryRun: args.dryRun, }); @@ -3311,26 +4168,35 @@ async function main() { database, bucket, objectPrefix, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret}, + uploadOptions: { bucket, endpoint, accessKeyId, accessKeySecret }, resultFile: args.resultFile, dryRun: args.dryRun, }); return; } if (args.restoreDir) { - throw new Error('--restore-dir 只能与 --restore-files-state 或 --restore-files-latest 一起使用。'); + throw new Error( + '--restore-dir 只能与 --restore-files-state 或 --restore-files-latest 一起使用。', + ); } if (args.uploadArchive && args.uploadDeferredDir) { throw new Error('--upload-archive 与 --upload-deferred-dir 不能同时使用。'); } if (!args.dryRun) { - const lockPath = acquireBackupLock({workDir, database}); + const lockPath = acquireBackupLock({ workDir, database }); console.log(`[database-backup] 已获取进程锁: ${lockPath}`); } if (args.publishManifest) { - await publishExistingManifest({args, bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter: uploadBandwidthLimiter}); + await publishExistingManifest({ + args, + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter: uploadBandwidthLimiter, + }); return; } @@ -3365,10 +4231,17 @@ async function main() { if (storageFormat === 'files') { if (args.deferUpload) { - throw new Error('files 模式无需本地归档且不支持 --defer-upload;失败后使用同一 work-dir 重跑即可续传。'); + throw new Error( + 'files 模式无需本地归档且不支持 --defer-upload;失败后使用同一 work-dir 重跑即可续传。', + ); } - const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); - const restartServicesAfter = collectRestartServicesAfterBackup({args, env}); + const stopService = + args.stopService || + firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); + const restartServicesAfter = collectRestartServicesAfterBackup({ + args, + env, + }); const stopMarkerPath = databaseBackupStopMarkerPath(workDir); let serviceStopped = false; let backupError = null; @@ -3386,7 +4259,13 @@ async function main() { objectPrefix, dryRun: args.dryRun, resultFile: args.resultFile, - uploadOptions: {bucket, endpoint, accessKeyId, accessKeySecret, bandwidthLimiter: uploadBandwidthLimiter}, + uploadOptions: { + bucket, + endpoint, + accessKeyId, + accessKeySecret, + bandwidthLimiter: uploadBandwidthLimiter, + }, concurrency: directFilesConcurrency, }); } catch (error) { @@ -3394,7 +4273,12 @@ async function main() { } finally { try { if (serviceStopped) { - restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath}); + restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, + }); } else if (!backupError && args.mode === 'full' && !args.dryRun) { restartServicesAfterBackup(restartServicesAfter); } @@ -3403,7 +4287,10 @@ async function main() { } } if (backupError && restoreError) { - throw new AggregateError([backupError, restoreError], `files 备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`); + throw new AggregateError( + [backupError, restoreError], + `files 备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`, + ); } if (backupError) { throw backupError; @@ -3432,7 +4319,11 @@ async function main() { return; } - const {fileName, objectKey} = buildBackupNames({database, dataDir, objectPrefix}); + const { fileName, objectKey } = buildBackupNames({ + database, + dataDir, + objectPrefix, + }); console.log(`[database-backup] 数据目录: ${dataDir}`); console.log(`[database-backup] 本地临时目录: ${workDir}`); console.log(`[database-backup] 目标对象: oss://${bucket}/${objectKey}`); @@ -3446,19 +4337,26 @@ async function main() { let serviceStopped = false; let backupError = null; let restoreError = null; - const stopService = args.stopService || firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); - const restartServicesAfter = collectRestartServicesAfterBackup({args, env}); + const stopService = + args.stopService || + firstNonEmpty(env.GENARRATIVE_DATABASE_BACKUP_STOP_SERVICE); + const restartServicesAfter = collectRestartServicesAfterBackup({ args, env }); const stopMarkerPath = databaseBackupStopMarkerPath(workDir); try { - assertSufficientWorkDirSpace({dataDir, workDir, args, env}); + assertSufficientWorkDirSpace({ dataDir, workDir, args, env }); serviceStopped = stopServiceIfNeeded(stopService, stopMarkerPath); - archivePath = createArchive({dataDir, workDir, fileName}); + archivePath = createArchive({ dataDir, workDir, fileName }); } catch (error) { backupError = error; } finally { try { if (serviceStopped) { - restoreServicesAfterBackup({stopService, serviceStopped, restartServicesAfter, stopMarkerPath}); + restoreServicesAfterBackup({ + stopService, + serviceStopped, + restartServicesAfter, + stopMarkerPath, + }); } else if (!backupError) { restartServicesAfterBackup(restartServicesAfter); } @@ -3468,7 +4366,10 @@ async function main() { } if (backupError) { if (restoreError) { - throw new AggregateError([backupError, restoreError], `数据库备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`); + throw new AggregateError( + [backupError, restoreError], + `数据库备份失败,且恢复依赖服务时也失败: ${backupError.message}; ${restoreError.message}`, + ); } throw backupError; } @@ -3477,7 +4378,7 @@ async function main() { } const manifestPath = `${archivePath}.manifest.json`; - const baselineStatePath = historyStatePath({args, env, workDir, database}); + const baselineStatePath = historyStatePath({ args, env, workDir, database }); const fullManifest = { backupKind: 'spacetimedb-data-dir', createdAt: new Date().toISOString(), @@ -3498,7 +4399,11 @@ async function main() { console.log(`[database-backup] 已生成本地冷备份,延后上传: ${archivePath}`); console.log(`[database-backup] 已写入备份清单: ${manifestPath}`); if (args.resultFile) { - writeFileSync(resolvePath(args.resultFile), `${JSON.stringify({archivePath, manifestPath, baselineStatePath, bucket, objectKey}, null, 2)}\n`, 'utf8'); + writeFileSync( + resolvePath(args.resultFile), + `${JSON.stringify({ archivePath, manifestPath, baselineStatePath, bucket, objectKey }, null, 2)}\n`, + 'utf8', + ); } return; } @@ -3513,8 +4418,12 @@ async function main() { bandwidthLimiter: uploadBandwidthLimiter, }); console.log(`[database-backup] 上传完成: ${JSON.stringify(result)}`); - const uploadedManifest = uploadedManifestPayload({manifest: fullManifest, database, result}); - writeManifest({manifestPath, payload: uploadedManifest}); + const uploadedManifest = uploadedManifestPayload({ + manifest: fullManifest, + database, + result, + }); + writeManifest({ manifestPath, payload: uploadedManifest }); const manifestUpload = await uploadManifestFile({ manifestPath, bucket, @@ -3527,18 +4436,26 @@ async function main() { uploadedManifest.manifestVerifiedAt = manifestUpload.verifiedAt; uploadedManifest.manifestContentLength = manifestUpload.contentLength; uploadedManifest.manifestArchiveSha256 = manifestUpload.archiveSha256; - writeManifest({manifestPath, payload: uploadedManifest}); + writeManifest({ manifestPath, payload: uploadedManifest }); const previousState = existsSync(baselineStatePath) - ? validateHistoryState(readManifest(baselineStatePath), {database, dataDir}) + ? validateHistoryState(readManifest(baselineStatePath), { + database, + dataDir, + }) : null; - const baseline = normalizeUploadedBaselineManifest(uploadedManifest, {database, dataDir}); - writeBaselineState({statePath: baselineStatePath, baseline, previousState}); + const baseline = normalizeUploadedBaselineManifest(uploadedManifest, { + database, + dataDir, + }); + writeBaselineState({ statePath: baselineStatePath, baseline, previousState }); console.log(`[database-backup] 已写入 baseline state: ${baselineStatePath}`); if (!keepLocal) { - rmSync(archivePath, {force: true}); - rmSync(manifestPath, {force: true}); - console.log('[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。'); + rmSync(archivePath, { force: true }); + rmSync(manifestPath, { force: true }); + console.log( + '[database-backup] 已删除本地临时备份文件;如需保留请设置 --keep-local。', + ); } else { console.log(`[database-backup] 已保留本地备份: ${archivePath}`); console.log(`[database-backup] 已保留备份清单: ${manifestPath}`); @@ -3552,7 +4469,9 @@ function formatErrorDetails(error) { return ['code', 'errno', 'syscall', 'hostname', 'host', 'port', 'address'] .map((field) => { const value = error[field]; - return value === undefined || value === null || value === '' ? '' : `${field}=${String(value)}`; + return value === undefined || value === null || value === '' + ? '' + : `${field}=${String(value)}`; }) .filter(Boolean) .join(' '); @@ -3575,9 +4494,14 @@ function describeError(error) { } if (current instanceof AggregateError) { current.errors.slice(0, 3).forEach((item, index) => { - const itemText = item instanceof Error ? `${item.name}: ${item.message}` : String(item); + const itemText = + item instanceof Error + ? `${item.name}: ${item.message}` + : String(item); const itemDetails = formatErrorDetails(item); - lines.push(`${label}.errors[${index}]: ${itemText}${itemDetails ? ` (${itemDetails})` : ''}`); + lines.push( + `${label}.errors[${index}]: ${itemText}${itemDetails ? ` (${itemDetails})` : ''}`, + ); }); } current = current.cause; @@ -3585,7 +4509,10 @@ function describeError(error) { return lines; } -if (process.argv[1] && realpathSync(resolve(process.argv[1])) === realpathSync(__filename)) { +if ( + process.argv[1] && + realpathSync(resolve(process.argv[1])) === realpathSync(__filename) +) { main().catch((error) => { for (const line of describeError(error)) { console.error(`[database-backup] ${line}`); diff --git a/scripts/dev.test.ts b/scripts/dev.test.ts index 209856185..a8883b337 100644 --- a/scripts/dev.test.ts +++ b/scripts/dev.test.ts @@ -376,7 +376,9 @@ describe('dev scheduler api-server env', () => { expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_HOST).toBe('127.0.0.1'); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_PORT).toBe('18083'); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_CONCURRENCY).toBe('16'); - expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe('5000'); + expect(workerEnv.GENARRATIVE_EDITOR_BGFILTER_SINGLE_IMAGE_ESTIMATE_MS).toBe( + '5000', + ); expect(workerEnv.GENARRATIVE_BGFILTER_WORKER_MAX_REQUESTS).toBe('2048'); }); diff --git a/scripts/spacetime-migration-common.mjs b/scripts/spacetime-migration-common.mjs index afe52a598..1f2341ad4 100644 --- a/scripts/spacetime-migration-common.mjs +++ b/scripts/spacetime-migration-common.mjs @@ -9,11 +9,13 @@ export function parseArgs(argv) { 'GENARRATIVE_SPACETIME_MIGRATION_CHUNK_SIZE', ), database: process.env.GENARRATIVE_SPACETIME_DATABASE || '', - bootstrapSecret: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '', + bootstrapSecret: + process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET || '', bootstrapSecretFile: process.env.GENARRATIVE_SPACETIME_MIGRATION_BOOTSTRAP_SECRET_FILE || '', includeTables: [], - operatorIdentity: process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '', + operatorIdentity: + process.env.GENARRATIVE_SPACETIME_MIGRATION_OPERATOR_IDENTITY || '', passthrough: [], note: '', server: process.env.GENARRATIVE_SPACETIME_SERVER || '', @@ -142,7 +144,9 @@ export function buildSpacetimeCallArgs(options, procedureName, input) { export async function callSpacetimeProcedure(options, procedureName, input) { if (!options.database) { - throw new Error('必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。'); + throw new Error( + '必须传入 --database,或设置 GENARRATIVE_SPACETIME_DATABASE。', + ); } validateSpacetimeDatabaseName(options.database); @@ -196,7 +200,9 @@ export async function createSpacetimeWebIdentity(options) { const text = await response.text(); if (!response.ok) { - throw new Error(`SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`); + throw new Error( + `SpacetimeDB identity HTTP ${response.status}: ${trimPreview(text)}`, + ); } let payload; @@ -209,16 +215,25 @@ export async function createSpacetimeWebIdentity(options) { } const identity = - payload.identity ?? payload.Identity ?? payload.identity_hex ?? payload.identityHex; + payload.identity ?? + payload.Identity ?? + payload.identity_hex ?? + payload.identityHex; const token = payload.token ?? payload.Token; if (typeof identity !== 'string' || typeof token !== 'string') { - throw new Error(`SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`); + throw new Error( + `SpacetimeDB identity 响应缺少 identity/token: ${trimPreview(text)}`, + ); } return { identity, token }; } -export async function callSpacetimeProcedureAuto(options, procedureName, input) { +export async function callSpacetimeProcedureAuto( + options, + procedureName, + input, +) { if (options.useHttp) { return callSpacetimeProcedure(options, procedureName, input); } @@ -226,7 +241,11 @@ export async function callSpacetimeProcedureAuto(options, procedureName, input) return callSpacetimeProcedureViaCli(options, procedureName, input); } -export async function callSpacetimeProcedureViaCli(options, procedureName, input) { +export async function callSpacetimeProcedureViaCli( + options, + procedureName, + input, +) { const args = buildSpacetimeCallArgs(options, procedureName, input); const output = await runSpacetimeCli(args); return parseProcedureResult(output, procedureName); @@ -335,7 +354,8 @@ function normalizeSatsProduct(value, procedureName) { } if ( - procedureName === 'normalize_editor_character_animation_metadata_and_return' && + procedureName === + 'normalize_editor_character_animation_metadata_and_return' && value.length === 19 ) { return { @@ -515,7 +535,10 @@ function normalizeSatsValue(value) { if (value && typeof value === 'object') { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, normalizeSatsValue(entry)]), + Object.entries(value).map(([key, entry]) => [ + key, + normalizeSatsValue(entry), + ]), ); } @@ -599,7 +622,9 @@ export function resolveServerUrl(options) { return 'http://127.0.0.1:3101'; } - throw new Error(`未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`); + throw new Error( + `未知 SpacetimeDB server: ${server}。请改用 --server-url 显式传入地址。`, + ); } function resolveCliServer(options) { @@ -653,7 +678,11 @@ function runSpacetimeCli(args) { return; } if (code !== 0) { - reject(new Error(`spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`)); + reject( + new Error( + `spacetime call 失败,退出码 ${code}: ${trimPreview(output)}`, + ), + ); return; }