@oj-module/cli 0.2.66 → 0.2.67
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.
- package/bin/.oj-version +1 -0
- package/bin/oj +4 -0
- package/devkit/README.md +15 -7
- package/devkit/SKILL.md +30 -1
- package/devkit/framework-dev.md +10 -4
- package/devkit/manual.md +38 -12
- package/devkit/publish-walkthrough.md +110 -37
- package/devkit/wecom-internal-app.md +7 -2
- package/devkit/wecom-multi-corp-links.md +163 -0
- package/package.json +2 -2
- package/shell-dist/assets/{index.i0kqbxp050.js → index.6alqyx6ae0.js} +21 -1
- package/shell-dist/assets/{runtime.1xfsxcajj0.js → runtime.2dgstpjsu0.js} +3449 -3449
- package/shell-dist/index.html +4 -4
- package/shell-dist/versions.json +3 -3
- package/src/__tests__/publish-keygen.test.ts +157 -0
- package/src/__tests__/templates-migration-schema-parity.test.ts +540 -0
- package/src/__tests__/templates-schema-columns.test.ts +15 -2
- package/src/__tests__/templates-tenant-scope.test.ts +6 -2
- package/src/bundle.ts +8 -3
- package/src/cli.ts +17 -5
- package/src/dev.ts +15 -10
- package/src/install.ts +11 -4
- package/src/keygen.ts +54 -0
- package/src/lifecycle/__tests__/bundle.test.ts +21 -3
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +2 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +5 -2
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +1 -1
- package/src/lifecycle/__tests__/manifest-contract.test.ts +75 -3
- package/src/lifecycle/__tests__/smoke.test.ts +2 -2
- package/src/manifest-contract.ts +91 -8
- package/src/market/bootstrap.ts +26 -2
- package/src/market/command.ts +1 -0
- package/src/market/publish.ts +5 -1
- package/src/market/walkthrough.ts +24 -8
- package/src/site.ts +2 -0
- package/src/usage.ts +8 -4
- package/src/vendor.ts +5 -1
- package/templates/config.yaml +4 -2
- package/templates/init.include.yaml +8 -3
- package/templates/openapi.json +460 -1
- package/templates/src/app/api/_platform/_lifecycle/compensate.ts +136 -0
- package/templates/src/app/api/_platform/_lifecycle/installer.ts +71 -22
- package/templates/src/app/api/_platform/_lifecycle/lock.ts +23 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/deploy-web.ts +28 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/preflight.ts +46 -0
- package/templates/src/app/api/_platform/_lifecycle/steps/reload.ts +100 -36
- package/templates/src/app/api/_platform/_lifecycle/steps/smoke.ts +7 -15
- package/templates/src/app/api/_platform/_lifecycle/text-edit.ts +71 -19
- package/templates/src/app/api/_platform/_lifecycle/types.ts +26 -2
- package/templates/src/app/api/_platform/_lifecycle/uninstall.ts +93 -14
- package/templates/src/app/api/_platform/_shared/service/domain/settings.ts +8 -4
- package/templates/src/app/api/_platform/manifest.yaml +1 -0
- package/templates/src/app/api/_platform/market/api.ts +235 -10
- package/templates/src/app/api/_platform/migrations/0001__baseline.mysql.sql +178 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.pg.sql +173 -0
- package/templates/src/app/api/_platform/migrations/0001__baseline.sql +188 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.mysql.sql +17 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.pg.sql +16 -0
- package/templates/src/app/api/_platform/migrations/0002__market_release.sql +33 -0
- package/templates/src/app/api/_platform/schema.yaml +18 -0
- package/templates/src/app/api/_platform/seed.mysql.sql +10 -0
- package/templates/src/app/api/_platform/seed.pg.sql +10 -0
- package/templates/src/app/api/auth/_shared/wecom/client.ts +24 -4
- package/templates/src/app/api/home/migrations/0001__create_home_tables.mysql.sql +14 -0
- package/templates/src/app/api/home/migrations/0001__create_home_tables.pg.sql +14 -0
- package/templates/src/app/api/home/migrations/0002__tenant.pg.sql +4 -0
- package/templates/src/app/api/home/seed.mysql.sql +71 -0
- package/templates/src/app/api/home/seed.pg.sql +71 -0
- package/templates/src/app/api/notification/_shared/notify.ts +6 -6
- package/templates/src/app/api/notification/migrations/0001__create_notifications.mysql.sql +11 -0
- package/templates/src/app/api/notification/migrations/0001__create_notifications.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0002__notifications_user.mysql.sql +8 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.mysql.sql +21 -0
- package/templates/src/app/api/notification/migrations/0003__notifications_broadcast.pg.sql +22 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.mysql.sql +10 -0
- package/templates/src/app/api/notification/migrations/0004__notifications_broadcast_hidden.pg.sql +11 -0
- package/templates/src/app/api/notification/migrations/0005__tenant.pg.sql +6 -0
- package/templates/src/app/api/notification/seed.mysql.sql +8 -0
- package/templates/src/app/api/notification/seed.pg.sql +11 -0
- package/templates/src/app/api/publish/_shared/identity.ts +69 -0
- package/templates/src/app/api/publish/api.ts +349 -0
- package/templates/src/app/api/publish/manifest.yaml +8 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.mysql.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.pg.sql +23 -0
- package/templates/src/app/api/publish/migrations/0001__publish_credential.sql +39 -0
- package/templates/src/app/api/publish/schema.yaml +33 -0
- package/templates/src/app/api/system/login-methods/api.ts +2 -1
- package/templates/src/app/web/_platform/__client/api.schemas.ts +15 -0
- package/templates/src/app/web/_platform/__client/index.ts +25 -1
- package/templates/src/app/web/entry.ts +9 -5
- package/templates/src/app/web/home/routes.ts +5 -0
- package/templates/src/app/web/login/locales/en-US.json +7 -1
- package/templates/src/app/web/login/locales/zh-CN.json +7 -1
- package/templates/src/app/web/login/pages/login.tsx +79 -86
- package/templates/src/app/web/login/routes.ts +54 -1
- package/templates/src/app/web/personal-center/locales/en-US.json +1 -0
- package/templates/src/app/web/personal-center/locales/zh-CN.json +1 -0
- package/templates/src/app/web/personal-center/pages/my-profile/index.tsx +5 -0
- package/templates/src/app/web/system/__client/api.schemas.ts +2 -1
- package/templates/src/app/web/system/locales/en-US.json +1 -8
- package/templates/src/app/web/system/locales/zh-CN.json +1 -8
- package/templates/src/app/web/system/pages/settings/index.tsx +17 -4
- package/templates/src/app/web/system/routes.ts +0 -6
- package/templates/src/oj-market/web/__tests__/CardGrid.test.tsx +33 -0
- package/templates/src/oj-market/web/__tests__/DetailDrawer.test.tsx +69 -0
- package/templates/src/oj-market/web/__tests__/marketClient.fake.ts +7 -1
- package/templates/src/oj-market/web/api/client.ts +3 -0
- package/templates/src/oj-market/web/api/defaultClient.ts +5 -0
- package/templates/src/oj-market/web/components/CardItem.tsx +44 -1
- package/templates/src/oj-market/web/components/DetailDrawer.tsx +17 -1
- package/templates/src/oj-market/web/entry.ts +31 -2
- package/templates/src/oj-market/web/locales/en-US.json +5 -1
- package/templates/src/oj-market/web/locales/zh-CN.json +5 -1
- package/templates/src/oj-market/web/publish/__client/api.schemas.ts +61 -0
- package/templates/src/oj-market/web/publish/__client/index.ts +164 -0
- package/templates/src/oj-market/web/publish/locales/en-US.json +80 -0
- package/templates/src/oj-market/web/publish/locales/zh-CN.json +80 -0
- package/templates/src/oj-market/web/publish/pages/history.tsx +112 -0
- package/templates/src/oj-market/web/publish/pages/index.tsx +196 -0
- package/templates/src/oj-market/web/publish/pages/model.ts +52 -0
- package/templates/src/oj-market/web/publish/pages/token-modal.tsx +82 -0
- package/templates/src/oj-market/web/publish/routes.ts +41 -0
- package/templates/src/oj-market/web/types.ts +30 -0
- package/templates/tests/lifecycle/compensate.spec.ts +207 -0
- package/templates/tests/lifecycle/deploy-smoke.spec.ts +38 -14
- package/templates/tests/lifecycle/installer.spec.ts +28 -9
- package/templates/tests/lifecycle/market-api.spec.ts +164 -12
- package/templates/tests/lifecycle/preflight.spec.ts +68 -0
- package/templates/tests/lifecycle/role-title-translate.spec.ts +2 -32
- package/templates/tests/lifecycle/text-edit.spec.ts +45 -27
- package/templates/tests/lifecycle/uninstall.spec.ts +36 -0
- package/templates/tests/publish-credential.spec.ts +354 -0
- package/templates/tests/publish-identity.spec.ts +44 -0
- package/templates/tests/wecom-client-timeout.spec.ts +33 -3
- package/templates/tests/wecom-landing.spec.ts +63 -1
- package/templates/tsconfig.paths.json +1 -0
- package/templates/web.config.ts +6 -4
- package/vendor/host-versions.json +3 -3
- package/templates/seed.mysql.sql +0 -6
- package/templates/seed.pg.sql +0 -6
- package/templates/src/app/api/_platform/migrations/0001__create_users.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.pg.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0001__create_users.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0002__add_users_avatar_base64.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.mysql.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0003__users_source_display_name.sql +0 -5
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.mysql.sql +0 -70
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.pg.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0004__sys_rbac.sql +0 -67
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0005__users_must_change.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0006__users_is_forbidden.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0007__users_email.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.mysql.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.pg.sql +0 -10
- package/templates/src/app/api/_platform/migrations/0008__sys_refresh_token.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0009__sys_throttle.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.mysql.sql +0 -17
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.pg.sql +0 -16
- package/templates/src/app/api/_platform/migrations/0010__sys_solution_route.sql +0 -18
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.mysql.sql +0 -2
- package/templates/src/app/api/_platform/migrations/0011__sys_setting_group_label.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0012__market_history.mysql.sql +0 -13
- package/templates/src/app/api/_platform/migrations/0012__market_history.pg.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0012__market_history.sql +0 -14
- package/templates/src/app/api/_platform/migrations/0013__users_profile.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.mysql.sql +0 -9
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.pg.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0014__reg_source_seq.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0015__tenant.mysql.sql +0 -12
- package/templates/src/app/api/_platform/migrations/0015__tenant.pg.sql +0 -11
- package/templates/src/app/api/_platform/migrations/0015__tenant.sql +0 -44
- package/templates/src/app/api/_platform/migrations/0016__org.mysql.sql +0 -41
- package/templates/src/app/api/_platform/migrations/0016__org.pg.sql +0 -40
- package/templates/src/app/api/_platform/migrations/0016__org.sql +0 -62
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.mysql.sql +0 -7
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.pg.sql +0 -4
- package/templates/src/app/api/_platform/migrations/0017__users_employee_no.sql +0 -8
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.mysql.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.pg.sql +0 -3
- package/templates/src/app/api/_platform/migrations/0018__sys_role_title_key.sql +0 -8
- package/templates/src/app/web/system/pages/login-methods/index.tsx +0 -30
|
@@ -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,
|
|
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 段
|
|
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
|
|
@@ -352,7 +353,11 @@ function previewSiblingOf(configPath: string): string {
|
|
|
352
353
|
return cut < 0 ? PREVIEW_CONFIG_NAME : `${configPath.slice(0, cut + 1)}${PREVIEW_CONFIG_NAME}`;
|
|
353
354
|
}
|
|
354
355
|
|
|
355
|
-
/**
|
|
356
|
+
/**
|
|
357
|
+
* 把「匿名端点接线」写到一份 config 文本上;`optional` 用于派生文件不存在即跳过。
|
|
358
|
+
* 仅当匿名端点确有新增(added 非空)才落盘——无谓重写会改动 config 文档 sha,反而引发假阳性
|
|
359
|
+
* (reload unchanged 判定基于文档 sha + 锁 diff;盘上本就一致时什么都不写才是正确的幂等态)。
|
|
360
|
+
*/
|
|
356
361
|
async function patchConfigAt(configPath: string, anonymous: string[], optional = false): Promise<string[]> {
|
|
357
362
|
let content: string;
|
|
358
363
|
try {
|
|
@@ -363,17 +368,22 @@ async function patchConfigAt(configPath: string, anonymous: string[], optional =
|
|
|
363
368
|
return [];
|
|
364
369
|
throw e;
|
|
365
370
|
}
|
|
366
|
-
|
|
367
|
-
|
|
371
|
+
if (anonymous.length === 0)
|
|
372
|
+
return [];
|
|
373
|
+
const patched = appendAnonymousPaths(content, anonymous);
|
|
374
|
+
if (patched.added.length === 0)
|
|
375
|
+
return [];
|
|
368
376
|
await atomicWriteText(configPath, patched.content);
|
|
369
377
|
return patched.added;
|
|
370
378
|
}
|
|
371
379
|
|
|
372
380
|
/**
|
|
373
|
-
* 热更段适配(B-11 reload
|
|
374
|
-
* unchanged
|
|
375
|
-
*
|
|
376
|
-
*
|
|
381
|
+
* 热更段适配(B-11 reload 触发面,R18 修订):先落盘 anonymous_paths 接线再 reloadAndAssert。
|
|
382
|
+
* oj 0.1.122+ 的 `unchanged` 由 **manifests 锁 diff 自然触发**(安装/升级/降级即锁变化),
|
|
383
|
+
* 不再依赖 config 文档 sha(旧的 vars.ojm_install_seq 哨兵已删);本段只做 anonymous_paths
|
|
384
|
+
* 接线(且幂等、仅 added 非空才落盘),其余变更(模块装配)由锁 diff 驱动,无需此处制造。
|
|
385
|
+
* 触发变更判定 = 文档 sha 或锁 diff 任一变化(oj-bin reload_bridge 真源);盘上本就一致则
|
|
386
|
+
* `unchanged=true` = 已是最新装配 = 幂等成功(R18 修订:不再视为失败)。
|
|
377
387
|
*
|
|
378
388
|
* **preview 形态(`ojm preview`)**:服务端读的是同目录派生出来的 `.ojm-preview.yaml`(spa 挂载 +
|
|
379
389
|
* platform.root 改写到 dist),安装器改的 `config.yaml` 它根本不看——于是 reload 按文档 sha 判
|
|
@@ -395,9 +405,12 @@ async function reloadStage(req: InstallRequest): Promise<StageResult> {
|
|
|
395
405
|
req.deferredAnonymous = added;
|
|
396
406
|
}
|
|
397
407
|
catch (e) {
|
|
398
|
-
return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml
|
|
408
|
+
return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml 匿名端点接线失败(${String(e)})——reload 由锁 diff / 文档 sha 变化触发,接线本身失败则热更无变更可落` };
|
|
399
409
|
}
|
|
400
|
-
|
|
410
|
+
const rr = await reloadAndAssert(req);
|
|
411
|
+
// 捕获待重启段(P0 §3.6 / 验收 15):非空 = 部分生效,installer 终态据其标 partial + restartRequired
|
|
412
|
+
req.restartPending = rr.restartPending;
|
|
413
|
+
return rr;
|
|
401
414
|
}
|
|
402
415
|
|
|
403
416
|
/** 终态审计(AC-P1-14):done/partial 都落 oplog;自身失败仅日志(附录 B-5,不阻断任务终态) */
|
|
@@ -492,13 +505,7 @@ async function cancelCleanup(req: InstallRequest, lockBefore: Record<string, str
|
|
|
492
505
|
// staging 已不存在(首装取消 / 重复回退)不掩盖取消终态
|
|
493
506
|
}
|
|
494
507
|
try {
|
|
495
|
-
|
|
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
|
-
}
|
|
508
|
+
await revertLock(req.sharedRoot, lockBefore, lockExisted);
|
|
502
509
|
}
|
|
503
510
|
catch {
|
|
504
511
|
// 锁回退失败由 reconcile 对账兜底(D11)
|
|
@@ -570,6 +577,15 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
|
|
|
570
577
|
catch {
|
|
571
578
|
lockExisted = false;
|
|
572
579
|
}
|
|
580
|
+
// P0 §3.2 质辩1 / E8:写锁前读 config 的 auth.anonymous_paths,注入 preflight 做残留检测
|
|
581
|
+
// (preflight 保持只读 ctx 的纯校验段纪律,不自己读盘)。config 缺失/解析失败 → 视为无残留。
|
|
582
|
+
try {
|
|
583
|
+
const cfg = new TextDecoder().decode(await fsFace().readFile(req.configPath ?? "config.yaml"));
|
|
584
|
+
req.existingAnonymousPaths = readAnonymousPaths(cfg);
|
|
585
|
+
}
|
|
586
|
+
catch {
|
|
587
|
+
req.existingAnonymousPaths = [];
|
|
588
|
+
}
|
|
573
589
|
// P2-13 段内进度:migrating 段 spawn 流式路径逐块触发;写链串行(回调 fire-and-forget
|
|
574
590
|
// 防交错),失败静默(进度观测面不阻断安装)
|
|
575
591
|
let progressChain: Promise<void> = Promise.resolve();
|
|
@@ -658,8 +674,27 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
|
|
|
658
674
|
}
|
|
659
675
|
}
|
|
660
676
|
else {
|
|
661
|
-
|
|
677
|
+
// 失败语义②:锁后 partial——执行补偿回滚到装前代(旧代继续服务、任务可重试),
|
|
678
|
+
// 不再「不回滚已落位产物」:消除失败放大匿名面/残留注册(final §0 缺陷之三)。
|
|
679
|
+
// 补偿各步独立 try/catch,单步失败仍尽力回滚其余步,结果进 task.stages 供运维定位。
|
|
680
|
+
const comp = await compensate({
|
|
681
|
+
name: req.name,
|
|
682
|
+
version: req.version,
|
|
683
|
+
configPath: req.configPath ?? "config.yaml",
|
|
684
|
+
deferredAnonymous: req.deferredAnonymous ?? [],
|
|
685
|
+
sharedRoot: req.sharedRoot,
|
|
686
|
+
modulesJsonPath: req.modulesJsonPath,
|
|
687
|
+
lockBefore,
|
|
688
|
+
lockExisted,
|
|
689
|
+
reload: req.reload,
|
|
690
|
+
});
|
|
691
|
+
for (const s of comp.steps) {
|
|
692
|
+
task.stages.push({ stage: `compensate.${s.step}`, ok: s.ok, message: s.message });
|
|
693
|
+
}
|
|
662
694
|
task.status = "partial";
|
|
695
|
+
if (!comp.allOk) {
|
|
696
|
+
task.message = `${r.message};补偿回滚存在失败步(${comp.steps.filter(s => !s.ok).map(s => s.step).join("、")}),可重试`;
|
|
697
|
+
}
|
|
663
698
|
}
|
|
664
699
|
req.outcome = { ...req.outcome, status: task.status, failedStage: def.name };
|
|
665
700
|
break;
|
|
@@ -737,8 +772,22 @@ export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
|
|
|
737
772
|
});
|
|
738
773
|
}
|
|
739
774
|
if (task.status === "done") {
|
|
740
|
-
|
|
741
|
-
|
|
775
|
+
// 无守护形态部分生效判定(P0 §3.6 / 验收 15):热更返回的待重启段非空,且无 supervisor
|
|
776
|
+
// 自动换进程(P4 暂未实施)——安装产物已落位,但宿主须重启才完全生效(匿名接线/路由等
|
|
777
|
+
// restart_pending 段)。此时不判 done(避免「假成功」误导,final §0 缺陷之四),改标 partial
|
|
778
|
+
// + restartRequired,由 UI 展示重启提示。失败语义②的补偿不触发(本次安装成功,无需回滚)。
|
|
779
|
+
const pending = req.restartPending ?? [];
|
|
780
|
+
if (pending.length > 0) {
|
|
781
|
+
task.status = "partial";
|
|
782
|
+
task.restartRequired = true;
|
|
783
|
+
task.stage = "reloading";
|
|
784
|
+
req.outcome = { ...req.outcome, status: "partial", failedStage: "reloading" };
|
|
785
|
+
task.message = `安装已落位但需宿主重启才完全生效:待重启段 [${pending.join("、")}](无守护形态无自动换进程;请重启 oj serve 后生效)。其余阶段均成功(partial,可重试以复检)`;
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
task.stage = "done";
|
|
789
|
+
task.progress = 100;
|
|
790
|
+
}
|
|
742
791
|
}
|
|
743
792
|
task.finishedAt = Date.now();
|
|
744
793
|
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
|
|
2
|
-
// (platform 特权 API,oj 0.1.75 起落地;
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
retryable: false,
|
|
61
|
-
message: "热更失败:reloadConfig 返回 unchanged——config 改写未实际生效(R18 终态断言:幂等回显 = 热更目标未达成,重试不自愈),请检查 config 写入段后重试安装",
|
|
109
|
+
ok: true,
|
|
110
|
+
message: "reloadConfig 返回 unchanged——当前已是最新装配(幂等成功,R18 修订:锁 / 文档未变化即无需重装配)",
|
|
62
111
|
};
|
|
63
112
|
}
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
const missing =
|
|
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
|
-
|
|
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
|
|
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
|
}
|
|
@@ -30,12 +30,6 @@ export interface SmokeContext extends InstallContext {
|
|
|
30
30
|
modulesJsonPath: string
|
|
31
31
|
/** 注册快照查询;缺省回落 queryRegistered(isolate db 全局;测试注入 stub) */
|
|
32
32
|
query?: (name: string) => Promise<SolutionSnapshot | null>
|
|
33
|
-
/**
|
|
34
|
-
* 本次安装**新追加**进 config.yaml `auth.anonymous_paths` 的路径(reloading 段注入,带前导 /)。
|
|
35
|
-
* 匿名接线属 restart_pending(reload 段已明示),本进程内尚未生效——探活对这些路径的
|
|
36
|
-
* 401 判「待重启」而非失败;已在配置里却仍 401 才是真失败(说明接线或守卫有问题)。
|
|
37
|
-
*/
|
|
38
|
-
deferredAnonymous?: string[]
|
|
39
33
|
}
|
|
40
34
|
|
|
41
35
|
export interface StageResult {
|
|
@@ -138,17 +132,15 @@ export async function smoke(ctx: SmokeContext): Promise<StageResult> {
|
|
|
138
132
|
if (target !== undefined) {
|
|
139
133
|
const url = probeUrlOf(ctx.apiPrefix, target);
|
|
140
134
|
const res = await ctx.fetch(url, { headers: { [PROBE_TENANT_HEADER]: PROBE_TENANT } });
|
|
141
|
-
if (
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
if (deferred) {
|
|
148
|
-
notes.push(`探活 ${url} 返回 401:health 端点为无凭证探针,宿主重启加载新配置(路由/匿名接线)后即 2xx——属 restart_pending,重启宿主后生效,不计入失败`);
|
|
135
|
+
if (!res.ok) {
|
|
136
|
+
// 无凭证探针(含租户化站点强制 X-TENANT-ID,仍无业务凭证)收 401 是 restart_pending 常态——
|
|
137
|
+
// 宿主重启加载新配置(路由/匿名接线)后即 2xx,不是包或站点问题,一律判 deferred 不计入失败。
|
|
138
|
+
// 非 401(如 404/500)视为真实失败(P0 §3.5:首个路由 404/连通失败 = 冒烟失败,避免假成功)。
|
|
139
|
+
if (res.status === 401) {
|
|
140
|
+
notes.push(`探活 ${url} 返回 401:无凭证探针,宿主重启加载新配置(路由/匿名接线)后即 2xx——属 restart_pending,重启宿主后生效,不计入失败`);
|
|
149
141
|
}
|
|
150
142
|
else {
|
|
151
|
-
failures.push({ item: "探活", retryable: true, reason:
|
|
143
|
+
failures.push({ item: "探活", retryable: true, reason: `探针 ${url} 返回 HTTP ${res.status}(期望 2xx;404/5xx 视为冒烟失败)` });
|
|
152
144
|
}
|
|
153
145
|
}
|
|
154
146
|
}
|
|
@@ -49,25 +49,6 @@ export function ensureReloadApi(content: string): { content: string, changed: bo
|
|
|
49
49
|
return { content: insertAnchor(content, "# ojm:reload", "reload_api: true"), changed: true };
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
* 安装序号自增(B-11 reload 触发面;PRD E5 实测先例:vars 段语义值改动可热生效,而
|
|
54
|
-
* reloadConfig 的 unchanged 判定基于 config 文档 sha——不落盘变更则热更恒 unchanged)。
|
|
55
|
-
* R18 同纪律:纯文本行操作,注释/缩进/键序逐字保留,`ojm_install_seq: N` 整行自增为 N+1
|
|
56
|
-
* (行内尾注释原样保留)。键缺失/形状不符 = 可读报错不猜位置(键随模板 config.yaml 发布;
|
|
57
|
-
* 用户删行 = 显式退出热更触发,安装器拒绝静默安装)。
|
|
58
|
-
*/
|
|
59
|
-
export function bumpInstallSeq(content: string): { content: string, seq: number } {
|
|
60
|
-
const lines = content.split("\n");
|
|
61
|
-
const idx = lines.findIndex(l => /^\s*ojm_install_seq\s*:\s*\d+\s*(?:#.*)?$/.test(l));
|
|
62
|
-
if (idx === -1) {
|
|
63
|
-
throw new Error("text-edit: config.yaml 缺 ojm_install_seq: N 行(vars 安装序号,模板 config.yaml 随发,勿删)——reload 热更无变更可落(unchanged 恒真),安装终止于 reloading 段");
|
|
64
|
-
}
|
|
65
|
-
const m = /^(\s*ojm_install_seq\s*:\s*)(\d+)/.exec(lines[idx]!)!;
|
|
66
|
-
const seq = Number(m[2]) + 1;
|
|
67
|
-
lines[idx] = `${m[1]}${seq}${lines[idx]!.slice(m[0].length)}`;
|
|
68
|
-
return { content: lines.join("\n"), seq };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
52
|
/**
|
|
72
53
|
* C2-26 anonymous 运行时接线(P2-15③ 承载,观察项 6 收口):把方案的匿名端点追加进
|
|
73
54
|
* config.yaml `auth.anonymous_paths` 列表(oj 匿名模型 config 独占;热改属
|
|
@@ -108,3 +89,74 @@ export function appendAnonymousPaths(content: string, paths: string[]): { conten
|
|
|
108
89
|
lines.splice(last + 1, 0, ...added.map(p => `${itemIndent}- ${p}`));
|
|
109
90
|
return { content: lines.join("\n"), added };
|
|
110
91
|
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* `appendAnonymousPaths` 的逆操作(P0 补偿清单 §3.5):把指定匿名路径从 `auth.anonymous_paths`
|
|
95
|
+
* 列表移除。纯文本判定,与 append 同源——按行 `- <path>`(trim 匹配)删除,列表其余项/注释/空行
|
|
96
|
+
* 原样保留;幂等:路径不在列表内则 no-op、返回 removed=[]。键缺失(从未接线)= 没东西可删,no-op。
|
|
97
|
+
* `paths` 形态应与 append 传入一致(方案相对形态),本函数统一补前导 `/` 对齐写入形态。
|
|
98
|
+
*/
|
|
99
|
+
export function removeAnonymousPaths(content: string, paths: string[]): { content: string, removed: string[] } {
|
|
100
|
+
if (paths.length === 0) {
|
|
101
|
+
return { content, removed: [] };
|
|
102
|
+
}
|
|
103
|
+
const wanted = new Set(paths.map(p => (p.startsWith("/") ? p : `/${p}`)));
|
|
104
|
+
const lines = content.split("\n");
|
|
105
|
+
const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
|
|
106
|
+
if (idx === -1) {
|
|
107
|
+
return { content, removed: [] };
|
|
108
|
+
}
|
|
109
|
+
// 收集列表项行区间(键行之后连续 `- item`,跳过空行/注释,遇首个其他行即止)
|
|
110
|
+
const itemLines: number[] = [];
|
|
111
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
112
|
+
const line = lines[i]!;
|
|
113
|
+
if (line.trim() === "" || /^\s*#/.test(line)) {
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
const stripped = line.replace(/#.*$/, "").trim();
|
|
117
|
+
if (!stripped.startsWith("- ")) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
itemLines.push(i);
|
|
121
|
+
}
|
|
122
|
+
const removeSet = new Set<number>();
|
|
123
|
+
const removed: string[] = [];
|
|
124
|
+
for (const i of itemLines) {
|
|
125
|
+
const val = lines[i]!.replace(/#.*$/, "").trim().slice(2).trim();
|
|
126
|
+
if (wanted.has(val)) {
|
|
127
|
+
removeSet.add(i);
|
|
128
|
+
removed.push(val);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (removed.length === 0) {
|
|
132
|
+
return { content, removed: [] };
|
|
133
|
+
}
|
|
134
|
+
const next = lines.filter((_, i) => !removeSet.has(i));
|
|
135
|
+
return { content: next.join("\n"), removed };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 纯文本解析 `auth.anonymous_paths` 列表(P0 §3.2 质辩1 / E8 残留检测消费面):与 append/remove
|
|
140
|
+
* 同源的列表收集口径——从 `anonymous_paths:` 键行起,连续 `- item` 行(跳过空行/注释)逐行取值。
|
|
141
|
+
* 用于「读」已装站点的匿名条目,不修改文本。键缺失 = 返回 []。
|
|
142
|
+
*/
|
|
143
|
+
export function readAnonymousPaths(content: string): string[] {
|
|
144
|
+
const lines = content.split("\n");
|
|
145
|
+
const idx = lines.findIndex(l => /^\s*anonymous_paths\s*:/.test(l));
|
|
146
|
+
if (idx === -1) {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
const out: string[] = [];
|
|
150
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
151
|
+
const line = lines[i]!;
|
|
152
|
+
if (line.trim() === "" || /^\s*#/.test(line)) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const stripped = line.replace(/#.*$/, "").trim();
|
|
156
|
+
if (!stripped.startsWith("- ")) {
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
out.push(stripped.slice(2).trim());
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|