@oj-module/cli 0.2.66 → 0.2.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (200) hide show
  1. package/bin/.oj-version +1 -0
  2. package/bin/oj +4 -0
  3. package/devkit/README.md +15 -7
  4. package/devkit/SKILL.md +38 -4
  5. package/devkit/framework-dev.md +10 -4
  6. package/devkit/manual.md +146 -24
  7. package/devkit/menu-rules.md +19 -4
  8. package/devkit/module-dev-and-cdn-playground.md +4 -0
  9. package/devkit/publish-walkthrough.md +132 -37
  10. package/devkit/wecom-internal-app.md +8 -3
  11. package/devkit/wecom-multi-corp-links.md +163 -0
  12. package/package.json +2 -2
  13. package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
  14. package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.1xzahckt10.js} +4863 -4847
  15. package/shell-dist/index.html +4 -4
  16. package/shell-dist/versions.json +3 -3
  17. package/src/__tests__/cli.test.ts +31 -0
  18. package/src/__tests__/new-scaffold.test.ts +13 -0
  19. package/src/__tests__/publish-keygen.test.ts +157 -0
  20. package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
  21. package/src/__tests__/templates-schema-columns.test.ts +15 -2
  22. package/src/__tests__/templates-tenant-scope.test.ts +6 -2
  23. package/src/build/module-def.ts +1 -0
  24. package/src/builtin.ts +3 -2
  25. package/src/bundle.ts +8 -3
  26. package/src/cli.ts +21 -5
  27. package/src/dev.ts +15 -10
  28. package/src/init.ts +47 -41
  29. package/src/install.ts +11 -4
  30. package/src/keygen.ts +54 -0
  31. package/src/lifecycle/__tests__/bundle.test.ts +21 -3
  32. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
  33. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
  34. package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
  35. package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
  36. package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
  37. package/src/lifecycle/__tests__/smoke.test.ts +2 -2
  38. package/src/manifest-contract.ts +91 -8
  39. package/src/market/bootstrap.ts +26 -2
  40. package/src/market/command.ts +1 -0
  41. package/src/market/publish.ts +8 -1
  42. package/src/market/walkthrough.ts +24 -8
  43. package/src/new.ts +2 -0
  44. package/src/scaffold-guard.ts +227 -0
  45. package/src/site.ts +2 -0
  46. package/src/usage.ts +13 -9
  47. package/src/vendor.ts +5 -1
  48. package/templates/config.yaml +4 -2
  49. package/templates/init.include.yaml +10 -3
  50. package/templates/openapi.json +460 -1
  51. package/templates/src/README.md +37 -0
  52. package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
  53. package/templates/src/app/api/_platform/_lifecycle/installer.ts +78 -25
  54. package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
  55. package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
  56. package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
  57. package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
  58. package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
  59. package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
  60. package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
  61. package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
  62. package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
  63. package/templates/src/app/api/_platform/_shared/service/domain/solution-perms.ts +25 -10
  64. package/templates/src/app/api/_platform/manifest.yaml +1 -0
  65. package/templates/src/app/api/_platform/market/api.ts +235 -10
  66. package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
  67. package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
  68. package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
  69. package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
  70. package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
  71. package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
  72. package/templates/src/app/api/_platform/schema.yaml +18 -0
  73. package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
  74. package/templates/src/app/api/_platform/seed.pg.sql +10 -0
  75. package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
  76. package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
  77. package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
  78. package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
  79. package/templates/src/app/api/home/seed.mysql.sql +71 -0
  80. package/templates/src/app/api/home/seed.pg.sql +71 -0
  81. package/templates/src/app/api/notification/_shared/notify.ts +6 -6
  82. package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
  83. package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
  84. package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
  85. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
  86. package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
  87. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
  88. package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
  89. package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
  90. package/templates/src/app/api/notification/seed.mysql.sql +8 -0
  91. package/templates/src/app/api/notification/seed.pg.sql +11 -0
  92. package/templates/src/app/api/publish/_shared/identity.ts +69 -0
  93. package/templates/src/app/api/publish/api.ts +349 -0
  94. package/templates/src/app/api/publish/manifest.yaml +8 -0
  95. package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
  96. package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
  97. package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
  98. package/templates/src/app/api/publish/schema.yaml +33 -0
  99. package/templates/src/app/api/system/login-methods/api.ts +2 -1
  100. package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
  101. package/templates/src/app/web/_platform/__client/index.ts +25 -1
  102. package/templates/src/app/web/entry.ts +9 -5
  103. package/templates/src/app/web/home/routes.ts +5 -0
  104. package/templates/src/app/web/login/locales/en-US.json +7 -1
  105. package/templates/src/app/web/login/locales/zh-CN.json +7 -1
  106. package/templates/src/app/web/login/pages/login.tsx +79 -86
  107. package/templates/src/app/web/login/routes.ts +54 -1
  108. package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
  109. package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
  110. package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
  111. package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
  112. package/templates/src/app/web/system/locales/en-US.json +1 -8
  113. package/templates/src/app/web/system/locales/zh-CN.json +1 -8
  114. package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
  115. package/templates/src/app/web/system/routes.ts +0 -6
  116. package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
  117. package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
  118. package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
  119. package/templates/src/oj-market/web/api/client.ts +3 -0
  120. package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
  121. package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
  122. package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
  123. package/templates/src/oj-market/web/entry.ts +31 -2
  124. package/templates/src/oj-market/web/locales/en-US.json +5 -1
  125. package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
  126. package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
  127. package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
  128. package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
  129. package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
  130. package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
  131. package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
  132. package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
  133. package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
  134. package/templates/src/oj-market/web/publish/routes.ts +41 -0
  135. package/templates/src/oj-market/web/types.ts +30 -0
  136. package/templates/tests/lifecycle/compensate.spec.ts +207 -0
  137. package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
  138. package/templates/tests/lifecycle/installer.spec.ts +28 -9
  139. package/templates/tests/lifecycle/market-api.spec.ts +164 -12
  140. package/templates/tests/lifecycle/preflight.spec.ts +68 -0
  141. package/templates/tests/lifecycle/register.spec.ts +22 -0
  142. package/templates/tests/lifecycle/role-title-translate.spec.ts +4 -34
  143. package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
  144. package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
  145. package/templates/tests/publish-credential.spec.ts +354 -0
  146. package/templates/tests/publish-identity.spec.ts +44 -0
  147. package/templates/tests/rbac-solution-perms.spec.ts +48 -3
  148. package/templates/tests/wecom-client-timeout.spec.ts +33 -3
  149. package/templates/tests/wecom-landing.spec.ts +63 -1
  150. package/templates/tsconfig.paths.json +1 -0
  151. package/templates/web.config.ts +8 -4
  152. package/vendor/host-versions.json +3 -3
  153. package/templates/seed.mysql.sql +0 -6
  154. package/templates/seed.pg.sql +0 -6
  155. package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
  156. package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
  157. package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
  158. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
  159. package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
  160. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
  161. package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
  162. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
  163. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
  164. package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
  165. package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
  166. package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
  167. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
  168. package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
  169. package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
  170. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
  171. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
  172. package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
  173. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
  174. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
  175. package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
  176. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
  177. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
  178. package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
  179. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
  180. package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
  181. package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
  182. package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
  183. package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
  184. package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
  185. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
  186. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
  187. package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
  188. package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
  189. package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
  190. package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
  191. package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
  192. package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
  193. package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
  194. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
  195. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
  196. package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
  197. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
  198. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
  199. package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
  200. package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
@@ -0,0 +1,136 @@
1
+ // 安装补偿清单(P0 §3.5):锁后失败(registering/deploying_web/reloading/smoke)时,把站点恢复到
2
+ // 「装前代」状态——旧代继续服务,任务标 partial + 可重试。本模块是回滚的唯一入口(SRP:只负责
3
+ // 逆序回滚已落盘资产,不含装前校验/审计/终态文案),各资产回滚原语各自单一函数(OCP:新增资产
4
+ // 类型只需加一个原语 + 在此注册一步)。所有原语幂等(缺则 no-op),故补偿可安全被重试端点反复调用。
5
+ // 与 cancelCleanup 的区别(intentionally 不同):取消 = 回到装前且删版本目录;补偿 = 回到装前代
6
+ // 但**保留版本目录**供重试。两者共用 revertLock(lock.ts)不重复锁回退逻辑(DRY)。
7
+
8
+ import type { ReloadFace } from "./steps/reload";
9
+ import { atomicWriteText, fsFace, isMissingPath } from "./atomic";
10
+ import { revertLock } from "./lock";
11
+ import { removeModulesJsonEntry } from "./steps/deploy-web";
12
+ import { removeAnonymousPaths } from "./text-edit";
13
+ import { revertRegistrations } from "./uninstall";
14
+
15
+ /** 本仓 preview 派生文件名(与 cli preview-config.ts / installer.ts 逐字一致) */
16
+ const PREVIEW_CONFIG_NAME = ".ojm-preview.yaml";
17
+
18
+ /** 与 config 同目录的 preview 派生路径(无目录分隔符 = 同目录裸名);本地化避免与 installer.ts 循环依赖 */
19
+ function previewSiblingOf(configPath: string): string {
20
+ const cut = Math.max(configPath.lastIndexOf("/"), configPath.lastIndexOf("\\"));
21
+ return cut < 0 ? PREVIEW_CONFIG_NAME : `${configPath.slice(0, cut + 1)}${PREVIEW_CONFIG_NAME}`;
22
+ }
23
+
24
+ function isObj(v: unknown): v is Record<string, unknown> {
25
+ return typeof v === "object" && v !== null && !Array.isArray(v);
26
+ }
27
+
28
+ export interface CompensateInput {
29
+ /** 方案名 */
30
+ name: string
31
+ /** 本次安装版本 */
32
+ version: string
33
+ /** config.yaml 路径(缺省 "config.yaml") */
34
+ configPath: string
35
+ /** reloading 段真正新增的匿名路径(installer 已写入 req.deferredAnonymous);空 = 未接线,config 步 no-op */
36
+ deferredAnonymous: string[]
37
+ /** 站点 sharedRoot(锁/版本目录所在) */
38
+ sharedRoot: string
39
+ /** 站点 modules.json 路径 */
40
+ modulesJsonPath: string
41
+ /** runInstall 起始捕获的装前锁快照 */
42
+ lockBefore: Record<string, string>
43
+ /** 装前锁文件是否存在(首装 = false → 回退即删锁) */
44
+ lockExisted: boolean
45
+ /** 注入式 reload 面(与 reloadStage 同源) */
46
+ reload: ReloadFace
47
+ }
48
+
49
+ export interface CompensateStep {
50
+ step: string
51
+ ok: boolean
52
+ message: string
53
+ }
54
+
55
+ export interface CompensateResult {
56
+ /** 各步结果(逆序:config → lock → modules.json → db → reload) */
57
+ steps: CompensateStep[]
58
+ /** 是否全部成功(任一步失败即 false,但其余步仍尽力执行) */
59
+ allOk: boolean
60
+ }
61
+
62
+ /**
63
+ * 执行补偿回滚。每步独立 try/catch:单步失败记录进 steps 并继续其余步,最终 allOk 反映整体;
64
+ * 调用方据 allOk 决定 task 文案(失败步需在 message 中点名,便于运维定位)。
65
+ */
66
+ export async function compensate(input: CompensateInput): Promise<CompensateResult> {
67
+ const steps: CompensateStep[] = [];
68
+ const run = async (step: string, fn: () => Promise<void>): Promise<void> => {
69
+ try {
70
+ await fn();
71
+ steps.push({ step, ok: true, message: `${step}:已回滚到装前代` });
72
+ }
73
+ catch (e) {
74
+ steps.push({ step, ok: false, message: `${step}回滚失败:${String(e)}` });
75
+ }
76
+ };
77
+
78
+ const f = fsFace();
79
+
80
+ // 1. config 匿名移除(源 config + preview 派生,派生缺失即跳过)
81
+ await run("config.anonymous", async () => {
82
+ if (input.deferredAnonymous.length === 0) {
83
+ return;
84
+ }
85
+ for (const p of [input.configPath, previewSiblingOf(input.configPath)]) {
86
+ let content: string;
87
+ try {
88
+ content = new TextDecoder().decode(await f.readFile(p));
89
+ }
90
+ catch (e) {
91
+ if (isMissingPath(e)) {
92
+ continue;
93
+ }
94
+ throw e;
95
+ }
96
+ const { content: next, removed } = removeAnonymousPaths(content, input.deferredAnonymous);
97
+ if (removed.length > 0) {
98
+ await atomicWriteText(p, next);
99
+ }
100
+ }
101
+ });
102
+
103
+ // 2. lock 回写装前(首装则删锁;空锁拒启)
104
+ await run("lock", async () => {
105
+ await revertLock(input.sharedRoot, input.lockBefore, input.lockExisted);
106
+ });
107
+
108
+ // 3. modules.json 条目移除 + 断言(防并发写者插回)
109
+ await run("modules.json", async () => {
110
+ const { removed } = await removeModulesJsonEntry(input.name, { modulesJsonPath: input.modulesJsonPath });
111
+ if (!removed) {
112
+ return;
113
+ }
114
+ const list = JSON.parse(new TextDecoder().decode(await f.readFile(input.modulesJsonPath))) as unknown;
115
+ if (Array.isArray(list) && list.some(e => isObj(e) && e.name === input.name)) {
116
+ throw new Error("回滚后 modules.json 仍存在该模块条目(疑似并发写者插回)");
117
+ }
118
+ });
119
+
120
+ // 4. DB 注册回退(复用卸载事务:删 route + 级联清角色/权限)
121
+ await run("db.registrations", async () => {
122
+ await revertRegistrations(input.name);
123
+ });
124
+
125
+ // 5. reload 让旧代生效(best-effort 重同步活动代;抛错即记失败,目标=活动代回到装前态)
126
+ // 注:US-1 落地前上游仅按 config sha 判定——回退 config 会产出 applied(移除匿名),非 unchanged;
127
+ // US-1 落地后预期 unchanged===true。两种皆视为成功(只要未抛错)。
128
+ await run("reload", async () => {
129
+ const r = await input.reload.reloadConfig();
130
+ if (typeof r !== "object" || r === null || typeof r.unchanged !== "boolean") {
131
+ throw new Error("reloadConfig 返回形状违约(expected {unchanged:boolean,...})");
132
+ }
133
+ });
134
+
135
+ return { steps, allOk: steps.every(s => s.ok) };
136
+ }
@@ -24,8 +24,9 @@ import type { ReloadFace } from "./steps/reload";
24
24
  import type { InstallContext, InstallStage, StageResult, TaskRecord, TaskStatus } from "./types";
25
25
  import { auditOp } from "../_shared/service/infra/audit";
26
26
  import { atomicWriteText, fsFace, randomId } from "./atomic";
27
+ import { compensate } from "./compensate";
27
28
  import { recordHistory } from "./history";
28
- import { acquireTaskLock, commitLockEntry, readLock, writeLock } from "./lock";
29
+ import { acquireTaskLock, commitLockEntry, readLock, revertLock, writeLock } from "./lock";
29
30
  import { deployWeb } from "./steps/deploy-web";
30
31
  import { download } from "./steps/download";
31
32
  import { extract } from "./steps/extract";
@@ -36,7 +37,7 @@ import { reloadAndAssert } from "./steps/reload";
36
37
  import { smoke } from "./steps/smoke";
37
38
  import { verify } from "./steps/verify";
38
39
  import { getTask, readTasks, sweepTimedOut, writeTask } from "./tasks";
39
- import { appendAnonymousPaths, bumpInstallSeq } from "./text-edit";
40
+ import { appendAnonymousPaths, readAnonymousPaths } from "./text-edit";
40
41
  import { createTrustStore } from "./trust";
41
42
 
42
43
  // db/http 为 oj isolate 注入全局(同模板 service 层惯例,register.ts/audit.ts 同款;测试由夹具装 globalThis.db)
@@ -66,7 +67,7 @@ export interface InstallRequest extends InstallContext {
66
67
  modulesJsonPath: string
67
68
  /** API 前缀(smoke 探针 URL 前缀,C2-13) */
68
69
  apiPrefix: string
69
- /** 站点 config.yaml 路径(reloading 段 vars.ojm_install_seq 自增落盘处;缺省 config.yaml) */
70
+ /** 站点 config.yaml 路径(reloading 段 anonymous_paths 接线落盘处;缺省 config.yaml) */
70
71
  configPath?: string
71
72
  /** 注入面(DIP):下载 / exec(tar+oj)/ 热更 / sha384(R10 integrity 重算) */
72
73
  fetch: FetchFace
@@ -301,11 +302,15 @@ export function declOf(req: InstallRequest): SolutionDecl {
301
302
  title_key: typeof r.title_key === "string" ? r.title_key : undefined,
302
303
  permissions: Array.isArray(r.permissions) ? r.permissions.map(String) : [],
303
304
  })),
304
- // 顶层 permissions[] 的 code:无角色权限点(系统预置点)唯一能落库的来源,别在此丢弃
305
+ // 顶层 permissions[] 透传 code + title_key(R2 展示锚随落库走,别在校验后丢弃):
306
+ // 无角色权限点(系统预置点)唯一能落库的来源
305
307
  permissions: (Array.isArray(m.permissions) ? m.permissions : [])
306
308
  .filter(isObj)
307
- .map(p => (typeof p.code === "string" ? p.code : ""))
308
- .filter(Boolean),
309
+ .map(p => ({
310
+ code: typeof p.code === "string" ? p.code : "",
311
+ title_key: typeof p.title_key === "string" ? p.title_key : undefined,
312
+ }))
313
+ .filter(p => p.code !== ""),
309
314
  settings,
310
315
  routes: (Array.isArray(m.routes) ? m.routes : []).filter(isObj).map(r => ({
311
316
  path: typeof r.path === "string" ? r.path : "",
@@ -352,7 +357,11 @@ function previewSiblingOf(configPath: string): string {
352
357
  return cut < 0 ? PREVIEW_CONFIG_NAME : `${configPath.slice(0, cut + 1)}${PREVIEW_CONFIG_NAME}`;
353
358
  }
354
359
 
355
- /** 把「安装序号自增 + 匿名端点接线」写到一份 config 文本上;`optional` 用于派生文件不存在即跳过 */
360
+ /**
361
+ * 把「匿名端点接线」写到一份 config 文本上;`optional` 用于派生文件不存在即跳过。
362
+ * 仅当匿名端点确有新增(added 非空)才落盘——无谓重写会改动 config 文档 sha,反而引发假阳性
363
+ * (reload unchanged 判定基于文档 sha + 锁 diff;盘上本就一致时什么都不写才是正确的幂等态)。
364
+ */
356
365
  async function patchConfigAt(configPath: string, anonymous: string[], optional = false): Promise<string[]> {
357
366
  let content: string;
358
367
  try {
@@ -363,17 +372,22 @@ async function patchConfigAt(configPath: string, anonymous: string[], optional =
363
372
  return [];
364
373
  throw e;
365
374
  }
366
- const bumped = bumpInstallSeq(content);
367
- const patched = anonymous.length > 0 ? appendAnonymousPaths(bumped.content, anonymous) : { content: bumped.content, added: [] };
375
+ if (anonymous.length === 0)
376
+ return [];
377
+ const patched = appendAnonymousPaths(content, anonymous);
378
+ if (patched.added.length === 0)
379
+ return [];
368
380
  await atomicWriteText(configPath, patched.content);
369
381
  return patched.added;
370
382
  }
371
383
 
372
384
  /**
373
- * 热更段适配(B-11 reload 触发面):先落盘触发变更再 reloadAndAssert——reloadConfig 的
374
- * unchanged 判定基于 config 文档 sha(oj-bin reload_bridge 真源),不落盘变更则热更恒
375
- * unchanged 必失败。触发变更 = vars.ojm_install_seq 自增(PRD E5 实测先例:vars 段语义值
376
- * 改动 applied 可热生效,无需改 mounts),经 text-edit 纯文本行操作(R18,注释/键序逐字保留)。
385
+ * 热更段适配(B-11 reload 触发面,R18 修订):先落盘 anonymous_paths 接线再 reloadAndAssert。
386
+ * oj 0.1.122+ 的 `unchanged` 由 **manifests 锁 diff 自然触发**(安装/升级/降级即锁变化),
387
+ * 不再依赖 config 文档 sha(旧的 vars.ojm_install_seq 哨兵已删);本段只做 anonymous_paths
388
+ * 接线(且幂等、仅 added 非空才落盘),其余变更(模块装配)由锁 diff 驱动,无需此处制造。
389
+ * 触发变更判定 = 文档 sha 或锁 diff 任一变化(oj-bin reload_bridge 真源);盘上本就一致则
390
+ * `unchanged=true` = 已是最新装配 = 幂等成功(R18 修订:不再视为失败)。
377
391
  *
378
392
  * **preview 形态(`ojm preview`)**:服务端读的是同目录派生出来的 `.ojm-preview.yaml`(spa 挂载 +
379
393
  * platform.root 改写到 dist),安装器改的 `config.yaml` 它根本不看——于是 reload 按文档 sha 判
@@ -395,9 +409,12 @@ async function reloadStage(req: InstallRequest): Promise<StageResult> {
395
409
  req.deferredAnonymous = added;
396
410
  }
397
411
  catch (e) {
398
- return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml 安装序号自增/匿名端点接线失败(${String(e)})——reload unchanged 判定基于 config 文档 sha,无变更则热更不可能生效(B-11,PRD E5 口径)` };
412
+ return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml 匿名端点接线失败(${String(e)})——reload 由锁 diff / 文档 sha 变化触发,接线本身失败则热更无变更可落` };
399
413
  }
400
- return reloadAndAssert(req);
414
+ const rr = await reloadAndAssert(req);
415
+ // 捕获待重启段(P0 §3.6 / 验收 15):非空 = 部分生效,installer 终态据其标 partial + restartRequired
416
+ req.restartPending = rr.restartPending;
417
+ return rr;
401
418
  }
402
419
 
403
420
  /** 终态审计(AC-P1-14):done/partial 都落 oplog;自身失败仅日志(附录 B-5,不阻断任务终态) */
@@ -492,13 +509,7 @@ async function cancelCleanup(req: InstallRequest, lockBefore: Record<string, str
492
509
  // staging 已不存在(首装取消 / 重复回退)不掩盖取消终态
493
510
  }
494
511
  try {
495
- if (lockExisted) {
496
- await writeLock(`${req.sharedRoot}/manifests.yaml`, lockBefore);
497
- }
498
- else {
499
- // 首装:装前本无锁文件,取消后也不该留空锁(空锁拒启 S1-1.2)
500
- await f.remove(`${req.sharedRoot}/manifests.yaml`, { force: true });
501
- }
512
+ await revertLock(req.sharedRoot, lockBefore, lockExisted);
502
513
  }
503
514
  catch {
504
515
  // 锁回退失败由 reconcile 对账兜底(D11)
@@ -570,6 +581,15 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
570
581
  catch {
571
582
  lockExisted = false;
572
583
  }
584
+ // P0 §3.2 质辩1 / E8:写锁前读 config 的 auth.anonymous_paths,注入 preflight 做残留检测
585
+ // (preflight 保持只读 ctx 的纯校验段纪律,不自己读盘)。config 缺失/解析失败 → 视为无残留。
586
+ try {
587
+ const cfg = new TextDecoder().decode(await fsFace().readFile(req.configPath ?? "config.yaml"));
588
+ req.existingAnonymousPaths = readAnonymousPaths(cfg);
589
+ }
590
+ catch {
591
+ req.existingAnonymousPaths = [];
592
+ }
573
593
  // P2-13 段内进度:migrating 段 spawn 流式路径逐块触发;写链串行(回调 fire-and-forget
574
594
  // 防交错),失败静默(进度观测面不阻断安装)
575
595
  let progressChain: Promise<void> = Promise.resolve();
@@ -658,8 +678,27 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
658
678
  }
659
679
  }
660
680
  else {
661
- // 失败语义②:锁后 partial——部分生效,不回滚已落位产物,给回滚指引 key
681
+ // 失败语义②:锁后 partial——执行补偿回滚到装前代(旧代继续服务、任务可重试),
682
+ // 不再「不回滚已落位产物」:消除失败放大匿名面/残留注册(final §0 缺陷之三)。
683
+ // 补偿各步独立 try/catch,单步失败仍尽力回滚其余步,结果进 task.stages 供运维定位。
684
+ const comp = await compensate({
685
+ name: req.name,
686
+ version: req.version,
687
+ configPath: req.configPath ?? "config.yaml",
688
+ deferredAnonymous: req.deferredAnonymous ?? [],
689
+ sharedRoot: req.sharedRoot,
690
+ modulesJsonPath: req.modulesJsonPath,
691
+ lockBefore,
692
+ lockExisted,
693
+ reload: req.reload,
694
+ });
695
+ for (const s of comp.steps) {
696
+ task.stages.push({ stage: `compensate.${s.step}`, ok: s.ok, message: s.message });
697
+ }
662
698
  task.status = "partial";
699
+ if (!comp.allOk) {
700
+ task.message = `${r.message};补偿回滚存在失败步(${comp.steps.filter(s => !s.ok).map(s => s.step).join("、")}),可重试`;
701
+ }
663
702
  }
664
703
  req.outcome = { ...req.outcome, status: task.status, failedStage: def.name };
665
704
  break;
@@ -737,8 +776,22 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
737
776
  });
738
777
  }
739
778
  if (task.status === "done") {
740
- task.stage = "done";
741
- task.progress = 100;
779
+ // 无守护形态部分生效判定(P0 §3.6 / 验收 15):热更返回的待重启段非空,且无 supervisor
780
+ // 自动换进程(P4 暂未实施)——安装产物已落位,但宿主须重启才完全生效(匿名接线/路由等
781
+ // restart_pending 段)。此时不判 done(避免「假成功」误导,final §0 缺陷之四),改标 partial
782
+ // + restartRequired,由 UI 展示重启提示。失败语义②的补偿不触发(本次安装成功,无需回滚)。
783
+ const pending = req.restartPending ?? [];
784
+ if (pending.length > 0) {
785
+ task.status = "partial";
786
+ task.restartRequired = true;
787
+ task.stage = "reloading";
788
+ req.outcome = { ...req.outcome, status: "partial", failedStage: "reloading" };
789
+ task.message = `安装已落位但需宿主重启才完全生效:待重启段 [${pending.join("、")}](无守护形态无自动换进程;请重启 oj serve 后生效)。其余阶段均成功(partial,可重试以复检)`;
790
+ }
791
+ else {
792
+ task.stage = "done";
793
+ task.progress = 100;
794
+ }
742
795
  }
743
796
  task.finishedAt = Date.now();
744
797
  await writeTask(req.taskDir, task);
@@ -93,6 +93,29 @@ export interface TaskLock {
93
93
  release: () => Promise<void>
94
94
  }
95
95
 
96
+ /**
97
+ * 回退锁到装前快照(D12 反向,与 setEnabled/cancelCleanup 同纪律):装前存在 → 写回
98
+ * lockBefore;首装(装前无锁文件)→ 删锁文件(空锁拒启 S1-1.2)。失败上抛由调用方兜底
99
+ * (reconcile 对账 / 任务 partial)。cancelCleanup 与补偿清单共用此原语,避免两处各写一份锁回退。
100
+ */
101
+ export async function revertLock(sharedRoot: string, lockBefore: Record<string, string>, lockExisted: boolean): Promise<void> {
102
+ const f = fsFace();
103
+ const lockPath = `${sharedRoot}/manifests.yaml`;
104
+ if (lockExisted) {
105
+ await writeLock(lockPath, lockBefore);
106
+ }
107
+ else {
108
+ try {
109
+ await f.remove(lockPath, { force: true });
110
+ }
111
+ catch (e) {
112
+ if (!isMissingPath(e)) {
113
+ throw e;
114
+ }
115
+ }
116
+ }
117
+ }
118
+
96
119
  /** stale 阈值:与任务超时判 failed 同款 30min(R13/B9/R31 口径一致) */
97
120
  export const LOCK_STALE_MS = 30 * 60_000;
98
121
 
@@ -188,3 +188,31 @@ export async function deployWeb(ctx: DeployWebContext): Promise<StageResult> {
188
188
  message: `部署完成:staging web/ → ${ctx.siteModulesDir}/,modules.json 已 upsert ${ctx.name}(entry ${entry.entry}${integrity ? `;integrity ${integrity}` : ""};D13 原子写,读者永不见半截 JSON)`,
189
189
  };
190
190
  }
191
+
192
+ /**
193
+ * 移除 modules.json 中指定 name 的条目(P0 补偿清单 §3.5):读→filter→原子写,与 upsert 同纪律
194
+ * (D13,原子写读者无感);清单不存在 = 该模块从未入清单,视为已移除返回 removed=false;清单损坏
195
+ * 上抛 fail-closed。返回 removed 标志供补偿流程断言「写出后该 name 条目确已不存在」。
196
+ */
197
+ export async function removeModulesJsonEntry(name: string, opts: { modulesJsonPath: string }): Promise<{ removed: boolean }> {
198
+ const f = fsFace();
199
+ let list: unknown;
200
+ try {
201
+ list = JSON.parse(new TextDecoder().decode(await f.readFile(opts.modulesJsonPath)));
202
+ }
203
+ catch (e) {
204
+ if (isMissingPath(e)) {
205
+ return { removed: false };
206
+ }
207
+ throw e;
208
+ }
209
+ if (!Array.isArray(list)) {
210
+ throw new TypeError("modules.json 顶层不是数组,拒绝修改(避免覆盖丢失既有条目)");
211
+ }
212
+ const next = list.filter(e => !(isObj(e) && e.name === name));
213
+ const removed = next.length !== list.length;
214
+ if (removed) {
215
+ await atomicWriteText(opts.modulesJsonPath, `${JSON.stringify(next, null, 2)}\n`);
216
+ }
217
+ return { removed };
218
+ }
@@ -52,6 +52,9 @@ const RANGE_RE = /^(>=|<=|[><^=])?([0-9A-Z.-]+)$/i;
52
52
  /** C2-26①:anonymous 相对路径语法(无前导斜杠、无通配符;首字符 [a-z0-9] 使 _system/_platform 天然禁入) */
53
53
  const ANONYMOUS_RE = /^[a-z0-9][a-z0-9\-_.]*(?:\/[a-z0-9][a-z0-9\-_.]*)*$/;
54
54
 
55
+ /** P0 §3.2 匿名保留段黑名单(显式拒装清单;与 C2-26 段内收敛互补——C2-26 挡「跨段」,本清单挡「保留段本身」) */
56
+ const ANON_RESERVED_PREFIXES = ["_platform", "_system", "auth", "_lifecycle"];
57
+
55
58
  /** C2-17:路由 path 语法(首段首字符为字母;无参数段/通配符/尾斜杠) */
56
59
  const ROUTE_PATH_RE = /^\/[a-z][a-z0-9\-_]*(?:\/[a-z0-9\-_]+)*$/;
57
60
 
@@ -205,6 +208,18 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
205
208
  return e === undefined ? undefined : `${key}@${e.version}`;
206
209
  };
207
210
 
211
+ // ── P0 §3.9 宿主能力门禁:模板声明所需 host 能力子集(manifest.requiresCapabilities),
212
+ // 宿主 ojInfo().capabilities 缺项即写锁前失败(零痕迹,不碰锁/库表/清单)。缺省 undefined 视为
213
+ // 宿主未上报 → fail-closed(能力缺失即拒)。既有模板不声明 → 门禁恒放行(不依赖上游 US-1 即先落地)。
214
+ const requires = Array.isArray(m.requiresCapabilities) ? m.requiresCapabilities as unknown[] : [];
215
+ if (requires.length > 0) {
216
+ const caps = ctx.capabilities ?? [];
217
+ const missing = requires.filter(c => typeof c === "string" && !caps.includes(c));
218
+ if (missing.length > 0) {
219
+ v.push({ clause: "C2-cap", code: "host_capability", detail: `宿主 oj 不支持本方案所需能力 ${JSON.stringify(missing)}(请升级 oj 至具备 ${JSON.stringify(requires)} 的版本后再装)` });
220
+ }
221
+ }
222
+
208
223
  // ── C2-01 身份 name:白名单 + 保留名(§4.2,market 经 R9 恢复保留)──
209
224
  if (typeof m.name !== "string" || !NAME_RE.test(m.name)) {
210
225
  v.push({ clause: "C2-01", detail: `name「${String(m.name)}」不匹配 ^[a-z][a-z0-9-]{0,63}$(小写字母开头,仅小写字母/数字/连字符)` });
@@ -288,6 +303,8 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
288
303
  // ── api 段(C2-12/12a/12b/13/26)──
289
304
  // C2-26② 段内收敛的「本方案段」:api.prefix 缺省 = name
290
305
  let apiSeg = name;
306
+ // P0 §3.2 质辩1 / E8:本 manifest 声明的匿名条目集合(供下方 config 残留检测比对)
307
+ const declaredSet = new Set<string>();
291
308
  const api = m.api;
292
309
  if (api !== undefined) {
293
310
  if (!isObj(api)) {
@@ -354,9 +371,13 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
354
371
  v.push({ clause: "C2-26", detail: `anonymous「${a}」不匹配相对路径语法 ^[a-z0-9][a-z0-9\\-_.]*(/[a-z0-9][a-z0-9\\-_.]*)*$(无前导斜杠/通配符;_system/_platform 段禁入)` });
355
372
  continue;
356
373
  }
374
+ declaredSet.add(a);
357
375
  if (a.split("/")[0] !== apiSeg) {
358
376
  v.push({ clause: "C2-26", detail: `anonymous「${a}」越段:首段必须 = 本方案段「${apiSeg}」(R8 段内收敛;_system/_platform 等保留段因此禁入)` });
359
377
  }
378
+ if (ANON_RESERVED_PREFIXES.includes(a.split("/")[0] ?? "")) {
379
+ v.push({ clause: "C2-26", detail: `anonymous「${a}」指向保留段(${ANON_RESERVED_PREFIXES.join("/")}),禁用于匿名接线(P0 §3.2 黑名单)` });
380
+ }
360
381
  if (LOGIN_ENDPOINT_RE.test(a)) {
361
382
  v.push({ clause: "C2-26", detail: `anonymous「${a}」命中登录语义端点禁令(R8/S1 纵深防御:login/logout/token/session/signin/signup/auth)` });
362
383
  }
@@ -370,6 +391,31 @@ export async function preflight(ctx: InstallContext): Promise<StageResult> {
370
391
  }
371
392
  }
372
393
 
394
+ // ── P0 §3.2 质辩1 / E8:config 残留匿名检测(写锁前拒装,避免同 prefix 换装静默继承)──
395
+ // 站点 config 已存在的 auth.anonymous_paths 中,凡落在目标 prefix(apiSeg)下却未被本次 manifest
396
+ // 声明的条目,一律拒装。运行期新增的匿名条目卸载即失效,仅「启动期已在 config 的残留」构成暴露面——
397
+ // 此处只挡安装入口,不挡已运行实例。真源 = ctx.existingAnonymousPaths(调用方写锁前读 config 注入;
398
+ // preflight 保持只读 ctx 的纯校验段纪律)。
399
+ // **保留段条目不在此查**:config 里 /auth/*、/_platform/* 等属宿主内建(如 /auth/login),方案永不
400
+ // 能注入(C2-26 段内收敛已挡住方案声明保留段匿名),故保留段 config 条目绝不会是方案残留——
401
+ // 纳入比对只会误伤宿主内建匿名端点(F4 假阳性)。
402
+ {
403
+ const existing = ctx.existingAnonymousPaths ?? [];
404
+ const residual: string[] = [];
405
+ // config 条目带前导斜杠(appendAnonymousPaths 统一补 /),归一去斜杠再取首段,
406
+ // 与 manifest 相对形态(无前导 /)保持一致比对口径。
407
+ const norm = (p: string) => p.replace(/^\//, "");
408
+ for (const e of existing) {
409
+ const seg = norm(e).split("/")[0] ?? "";
410
+ if (seg === apiSeg && !declaredSet.has(norm(e))) {
411
+ residual.push(e);
412
+ }
413
+ }
414
+ if (residual.length > 0) {
415
+ v.push({ clause: "C2-26", code: "anonymous_residual", detail: `config 已存在未被本次 manifest 声明的匿名条目(同 prefix 换装会静默继承):${residual.join("、")}——请先清理 config.yaml 的 auth.anonymous_paths 后再安装` });
416
+ }
417
+ }
418
+
373
419
  // ── web 段(C2-27 语法;integrity 重算比对属 deployWeb→smoke 段,不在本段)──
374
420
  const web = m.web;
375
421
  if (web !== undefined) {
@@ -1,12 +1,17 @@
1
- // 热更步骤(P1-9,十阶段 stage reloading;R18):经注入 reload 面调 oj server.reloadConfig()
2
- // (platform 特权 API,oj 0.1.75 起落地;S1-5.2 实测五键形状 {generation, unchanged, applied[],
3
- // restart_pending[], kept_listener[]},与 POST /_system/config 同一 executor 同一把串行锁——
4
- // 并发第二次触发抛 busy 409)。终态断言(R18):unchanged = config 改写未实际生效判失败
5
- // (幂等回显 = 热更目标未达成,重试不自愈);expected 段键须逐一 ∈ applied——缺省 ["vars"]
6
- // (B-11 安装触发面 = vars.ojm_install_seq 自增,PRD E5 实测 vars 可热生效;S1-5 实测
7
- // anonymous_paths 热改属 restart_pending 不可热生效,mounts 断言可经 ctx.expected 覆盖)。
8
- // reload 面经 ctx 注入(DIP):生产 = (globalThis as any).server.reloadConfig 包装(P1-10 接线),
9
- // 测试 stub 注入成功/unchanged/抛错三态。
1
+ // 热更步骤(P1-9,十阶段 stage reloading):经注入 reload 面调 oj server.reloadConfig()
2
+ // (platform 特权 API,oj 0.1.75 起落地;v0.1.122 起 manifests 锁纳入变化检测——装/卸只动锁即
3
+ // 触发装配,卸载空转由此解决)。返回形状恒定(api-manual §10):
4
+ // { generation, unchanged, applied[], restart_pending[], kept_listener[] }
5
+ // - applied 双符号契约(v0.1.122):模块条目 `+<name>@<ver>`(装/升级)/ `-<name>@<ver>`(卸),
6
+ // 段 diff 条目(mounts/tenant/tasks)同列一数组;升级 = `-旧@v` + `+新@v` 成对。
7
+ // - unchanged=true 时三清单为空(config 与 manifests 锁双双未变);v0.1.122 起 vars/auth 等
8
+ // 冻结/特权段**如实归入 restart_pending**——旧版把 vars 静默误标进 applied 已修正。
9
+ // 并发撞锁 → 抛 `reload in progress (busy)`(409);归一/装配失败 → 抛 HTTP 400;交接中断 / cron
10
+ // 拉起失败 → 抛 500(old task pool did not stop within 10s 等,新代任务就地收场)。
11
+ // 终态断言(R18 修订 / §2.7):安装路径 unchanged=true = 已是最新装配 = 幂等成功(不再判失败);
12
+ // 卸载 unchanged=true = 空转回归仍判失败。applied 符号:卸载强制 -name@ver;安装默认不强制
13
+ // +name@ver(smoke 段行为化校验挂载是否真通——探活拿到 200 即证模块已装配)。force 透传
14
+ // (v0.1.123)要求 reload 实际重跑(unchanged=false),否则判失败。reload 面经 ctx 注入(DIP)。
10
15
 
11
16
  import type { InstallContext } from "./preflight";
12
17
 
@@ -20,13 +25,17 @@ export interface ReloadResult {
20
25
  }
21
26
 
22
27
  export interface ReloadFace {
23
- reloadConfig: () => Promise<ReloadResult>
28
+ /** oj 0.1.123+ 支持 reloadConfig(force):force=true 强制重装配,绕过 unchanged 幂等短路 */
29
+ reloadConfig: (force?: boolean) => Promise<ReloadResult>
24
30
  }
25
31
 
26
32
  export interface ReloadContext extends InstallContext {
27
33
  /** 注入式 reload 面(生产 = platform 特权 server.reloadConfig 包装;测试 stub) */
28
34
  reload: ReloadFace
29
- /** 须实际生效的段键(R18 终态断言);缺省 ["vars"](B-11 安装触发面) */
35
+ /**
36
+ * 除模块符号外须逐一 ∈ applied 的附加段键(缺省空;v0.1.122 起 vars/auth 等冻结/特权段已归入
37
+ * restart_pending,不再入 applied——故不再默认期望 vars)
38
+ */
30
39
  expected?: string[]
31
40
  }
32
41
 
@@ -35,47 +44,102 @@ export interface StageResult {
35
44
  ok: boolean
36
45
  message: string
37
46
  retryable?: boolean
47
+ /**
48
+ * 热更返回的待重启段(v0.1.122 restart_pending);非空 = 部分生效、需宿主重启后才完全生效。
49
+ * install/卸载形态均据其标 partial + restartRequired(无守护形态,P0 §3.6 / 验收 15)
50
+ */
51
+ restartPending?: string[]
38
52
  }
39
53
 
40
- export async function reloadAndAssert(ctx: ReloadContext): Promise<StageResult> {
41
- let r: ReloadResult;
42
- try {
43
- r = await ctx.reload.reloadConfig();
44
- }
45
- catch (e) {
46
- return { stage: "reloading", ok: false, retryable: true, message: `热更失败:reloadConfig 抛错(${String(e)})——并发 busy 可重试(附录 A reloading;R18)` };
47
- }
54
+ function isObj(v: unknown): v is Record<string, unknown> {
55
+ return typeof v === "object" && v !== null && !Array.isArray(v);
56
+ }
57
+
58
+ /** 是否有 api 模块(决定 oj 是否产出装配条目):web-only(无 api 段)不强制模块符号,仅校验 reload 已探测变更 */
59
+ export function hasApiModule(manifest: unknown): boolean {
60
+ return isObj(manifest) && isObj(manifest.api);
61
+ }
62
+
63
+ /** 模块符号(v0.1.122 applied 双符号契约):装 = +<name>@<ver>;卸 = -<name>@<ver> */
64
+ export function moduleSymbolOf(name: string, version: string, kind: "install" | "uninstall"): string {
65
+ return `${kind === "install" ? "+" : "-"}${name}@${version}`;
66
+ }
67
+
68
+ /**
69
+ * reload 终态断言(R18 修订 / §2.7):
70
+ * - 形状违约(非对象 / applied 非数组 / unchanged 非布尔)→ 失败。
71
+ * - unchanged=true(config 与 manifests 锁双双未变):
72
+ * · 卸载(kind=uninstall)→ 失败:锁未摘除 = 卸载空转回归(重试不自愈)。
73
+ * · 安装 + force=true → 失败:--force 要求强制重装配,宿主却报告无变更可重做(装配径失效)。
74
+ * · 安装 + 非强制 → **成功**:unchanged = 已是最新装配 = 幂等成功(R18 修订:不再视为失败;
75
+ * 升级/重装本就由 manifests 锁 diff 自然触发 unchanged=false,盘上本就一致时 idempotent ok)。
76
+ * - 模块符号(卸 -name@ver):仅卸载且 hasApi 为真时强制——卸载必须显式拆装;安装**默认不强制**
77
+ * +name@ver(R18 修订:smoke 段行为化校验挂载是否真通——探活端点 200 即证模块已装配,不必在
78
+ * reload 层重复断言;锁 diff 自会驱动装配,applied 空属正常幂等态)。
79
+ * - 附加段键(ctx.expected,如 mounts/tenant/tasks)逐一 ∈ applied(覆盖项保留)。
80
+ * 返回 {ok, message?} 供调用方生成人话。
81
+ */
82
+ export function assertReloadApplied(r: ReloadResult, opts: { hasApi: boolean, kind: "install" | "uninstall", name: string, version: string, additional?: string[], /** 强制热更(oj 0.1.123+ reloadConfig(force)):unchanged 时须失败(强制装配未生效) */ force?: boolean }): { ok: boolean, message?: string } {
48
83
  if (typeof r !== "object" || r === null || !Array.isArray(r.applied) || typeof r.unchanged !== "boolean") {
49
84
  return {
50
- stage: "reloading",
51
85
  ok: false,
52
- retryable: false,
53
- message: `热更失败:reloadConfig 返回形状违约(S1-5.2 五键形状 {generation, unchanged, applied, restart_pending, kept_listener}):${JSON.stringify(r)?.slice(0, 200)}`,
86
+ message: `reloadConfig 返回形状违约(v0.1.122 五键 {generation, unchanged, applied[], restart_pending[], kept_listener[]}):${JSON.stringify(r)?.slice(0, 200)}`,
54
87
  };
55
88
  }
89
+ // 无代际信号(dev/embedded 的 reload 回落 no-op:不提供 generation)= 无法验证,放行(不阻挡
90
+ // 卸载/安装主流程;真实 reload 恒返回 generation,故此信号等价于「reload 未实际执行」)。
91
+ if (r.generation === undefined) {
92
+ return { ok: true };
93
+ }
56
94
  if (r.unchanged) {
95
+ if (opts.kind === "uninstall") {
96
+ return {
97
+ ok: false,
98
+ message: "reloadConfig 返回 unchanged——卸载后锁未变化,疑似卸载空转(锁未摘除);请确认宿主已开启 reload_api 且 oj ≥ v0.1.122",
99
+ };
100
+ }
101
+ if (opts.force === true) {
102
+ return {
103
+ ok: false,
104
+ message: "reloadConfig 返回 unchanged——--force 要求强制重装配,但宿主报告无变更可重做(锁 diff / 文档 sha 变化后仍 unchanged 说明强制装配未生效);请确认宿主 oj ≥ v0.1.123 且 reload_api 已开启",
105
+ };
106
+ }
107
+ // 非强制安装:unchanged = 已是最新装配 = 幂等成功(R18 修订)
57
108
  return {
58
- stage: "reloading",
59
- ok: false,
60
- retryable: false,
61
- message: "热更失败:reloadConfig 返回 unchanged——config 改写未实际生效(R18 终态断言:幂等回显 = 热更目标未达成,重试不自愈),请检查 config 写入段后重试安装",
109
+ ok: true,
110
+ message: "reloadConfig 返回 unchanged——当前已是最新装配(幂等成功,R18 修订:锁 / 文档未变化即无需重装配)",
62
111
  };
63
112
  }
64
- // 安装路径缺省期望 vars(B-11:reload 触发变更 = vars.ojm_install_seq 自增,PRD E5 实测
65
- // vars 可热生效;unchanged 判定基于 config 文档 sha,安装器 reloading 段前置落盘自增)。
66
- // anonymous 并入即 mounts 段生效的语义保留——调用方经 ctx.expected 显式覆盖。
67
- const expected = ctx.expected ?? ["vars"];
68
- const missing = expected.filter(k => !r.applied.includes(k));
113
+ // 模块符号:仅卸载且 hasApi 为真时强制 -name@ver(R18 修订:安装默认不强制 +name@ver,
114
+ // 由 smoke 段行为化校验挂载;本层只拦卸载空转——锁未摘除 = 未实际拆除)。
115
+ const requireSymbol = opts.kind === "uninstall" && opts.hasApi;
116
+ const symbol = requireSymbol ? moduleSymbolOf(opts.name, opts.version, "uninstall") : undefined;
117
+ const missing = [...(symbol ? [symbol] : []), ...(opts.additional ?? [])].filter(k => !r.applied.includes(k));
69
118
  if (missing.length > 0) {
70
119
  return {
71
- stage: "reloading",
72
120
  ok: false,
73
- retryable: false,
74
- message: `热更失败:applied [${r.applied.join("、")}] 未含预期生效段 ${missing.join("、")}(R18:以 reload 返回的 mounts/vars 实际生效值做终态断言)`,
121
+ message: `reloadConfig applied [${r.applied.join("、")}] 未含预期生效项 ${missing.join("、")}(R18 / §2.7:以 reload 实际 applied 做终态断言,卸载空转回归的唯一拦截点)`,
75
122
  };
76
123
  }
124
+ return { ok: true };
125
+ }
126
+
127
+ export async function reloadAndAssert(ctx: ReloadContext): Promise<StageResult> {
128
+ let r: ReloadResult;
129
+ try {
130
+ r = await ctx.reload.reloadConfig(ctx.force === true ? ctx.force : undefined);
131
+ }
132
+ catch (e) {
133
+ return { stage: "reloading", ok: false, retryable: true, message: `热更失败:reloadConfig 抛错(${String(e)})——并发 busy / 归一装配失败 / 交接中断 / cron 拉起失败均可重试(R18)` };
134
+ }
135
+ // 安装路径(R18 修订):默认不强制 +name@ver——模块装配由锁 diff 驱动,smoke 段行为化校验挂载;
136
+ // 仅 force 未生效 / ctx.expected 覆盖项缺失 / 形状违约会判失败。
137
+ const a = assertReloadApplied(r, { hasApi: hasApiModule(ctx.manifest), kind: "install", name: ctx.name, version: ctx.version, additional: ctx.expected, force: ctx.force === true });
138
+ if (!a.ok) {
139
+ return { stage: "reloading", ok: false, retryable: false, message: `热更失败:${a.message}` };
140
+ }
77
141
  const pending = Array.isArray(r.restart_pending) && r.restart_pending.length > 0
78
- ? `;待重启段 [${r.restart_pending.join("、")}](anonymous_paths 热改属 restart_pending,S1-5)`
142
+ ? `;待重启段 [${r.restart_pending.join("、")}](anonymous_paths / vars 等冻结·特权段热改属 restart_pending,v0.1.122)`
79
143
  : "";
80
- return { stage: "reloading", ok: true, message: `热更完成:generation ${r.generation ?? "?"},已生效段 [${r.applied.join("、")}]${pending}` };
144
+ return { stage: "reloading", ok: true, message: `热更完成:generation ${r.generation ?? "?"},已生效段 [${r.applied.join("、")}]${pending}`, restartPending: Array.isArray(r.restart_pending) ? r.restart_pending : [] };
81
145
  }