@oj-module/cli 0.2.2 → 0.2.5
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/devkit/README.md +14 -3
- package/devkit/SKILL.md +16 -4
- package/devkit/framework-dev.md +8 -6
- package/devkit/manual.md +243 -23
- package/package.json +2 -2
- package/shell-dist/assets/runtime.js +143 -8
- package/shell-dist/index.html +2 -2
- package/shell-dist/versions.json +3 -3
- package/src/args.ts +67 -0
- package/src/build.ts +11 -3
- package/src/bundle.ts +197 -0
- package/src/contract/__tests__/handler-scan.test.ts +82 -0
- package/src/contract/check.ts +2 -1
- package/src/contract/handler-scan.ts +32 -0
- package/src/deterministic-tar.ts +120 -0
- package/src/dev.ts +4 -2
- package/src/enable.ts +28 -0
- package/src/index.ts +63 -2
- package/src/info.ts +3 -2
- package/src/init.ts +147 -14
- package/src/install.ts +268 -0
- package/src/keygen.ts +88 -0
- package/src/layout.ts +31 -0
- package/src/lifecycle/__tests__/authoring.test.ts +109 -0
- package/src/lifecycle/__tests__/bundle.test.ts +150 -0
- package/src/lifecycle/__tests__/deterministic-tar.test.ts +145 -0
- package/src/lifecycle/__tests__/fixtures/README.md +59 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/EXPECTED-VIOLATIONS.md +27 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/api/src/Demo/manifest.yaml +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/manifest.yaml +40 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/entry.ts +14 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/en-US.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-invalid/web/src/Demo/locales/zh-CN.json +12 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/manifest.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/migrations/0001__init.sql +6 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/ping/api.ts +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/api/src/demo-solution/schema.yaml +9 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/manifest.yaml +96 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/entry.ts +23 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/en-US.json +29 -0
- package/src/lifecycle/__tests__/fixtures/fixture-valid/web/src/demo-solution/locales/zh-CN.json +29 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz +0 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.bad.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/demo-solution-0.2.0.tgz.unknown.sig +7 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.d.mts +21 -0
- package/src/lifecycle/__tests__/fixtures/sig/gen-signed-fixtures.mjs +196 -0
- package/src/lifecycle/__tests__/helpers.ts +78 -0
- package/src/lifecycle/__tests__/install.test.ts +514 -0
- package/src/lifecycle/__tests__/keygen.test.ts +61 -0
- package/src/lifecycle/__tests__/manifest-contract.test.ts +228 -0
- package/src/lifecycle/__tests__/publish.test.ts +80 -0
- package/src/lifecycle/__tests__/sig-fixtures.test.ts +100 -0
- package/src/lifecycle/__tests__/sign.test.ts +131 -0
- package/src/lifecycle/__tests__/smoke.test.ts +61 -0
- package/src/lifecycle/__tests__/trust.test.ts +177 -0
- package/src/lifecycle/__tests__/uninstall.test.ts +87 -0
- package/src/lifecycle/keygen.ts +40 -0
- package/src/lifecycle/sign.ts +74 -0
- package/src/manifest-contract.ts +696 -0
- package/src/oj-cert.ts +19 -0
- package/src/oj-cmd.ts +3 -3
- package/src/preview.ts +2 -2
- package/src/publish.ts +150 -0
- package/src/sign.ts +128 -0
- package/src/site.ts +146 -0
- package/src/trust.ts +116 -0
- package/src/uninstall.ts +29 -0
- package/src/usage.ts +35 -1
- package/templates/README.md +1 -1
- package/templates/api/config.yaml +47 -0
- package/templates/api/seed.mysql.sql +1 -1
- package/templates/api/seed.pg.sql +1 -1
- package/templates/api/src/_platform/_lifecycle/atomic.ts +98 -0
- package/templates/api/src/_platform/_lifecycle/history.ts +31 -0
- package/templates/api/src/_platform/_lifecycle/installer.ts +1080 -0
- package/templates/api/src/_platform/_lifecycle/lock.ts +148 -0
- package/templates/api/src/_platform/_lifecycle/semver.ts +61 -0
- package/templates/api/src/_platform/_lifecycle/sha384.ts +187 -0
- package/templates/api/src/_platform/_lifecycle/state.ts +256 -0
- package/templates/api/src/_platform/_lifecycle/steps/deploy-web.ts +182 -0
- package/templates/api/src/_platform/_lifecycle/steps/download.ts +90 -0
- package/templates/api/src/_platform/_lifecycle/steps/extract.ts +239 -0
- package/templates/api/src/_platform/_lifecycle/steps/migrate.ts +363 -0
- package/templates/api/src/_platform/_lifecycle/steps/preflight.ts +590 -0
- package/templates/api/src/_platform/_lifecycle/steps/register.ts +211 -0
- package/templates/api/src/_platform/_lifecycle/steps/reload.ts +81 -0
- package/templates/api/src/_platform/_lifecycle/steps/smoke.ts +216 -0
- package/templates/api/src/_platform/_lifecycle/steps/verify.ts +207 -0
- package/templates/api/src/_platform/_lifecycle/tasks.ts +119 -0
- package/templates/api/src/_platform/_lifecycle/text-edit.ts +110 -0
- package/templates/api/src/_platform/_lifecycle/tgz-guard.ts +41 -0
- package/templates/api/src/_platform/_lifecycle/trust.ts +229 -0
- package/templates/api/src/_platform/_lifecycle/types.ts +94 -0
- package/templates/api/src/_platform/_lifecycle/uninstall.ts +283 -0
- package/templates/api/src/_platform/_lifecycle/verify-checks.ts +228 -0
- package/templates/api/src/_platform/_lifecycle/verify-report.ts +92 -0
- package/templates/api/src/_platform/_shared/service/access.ts +2 -2
- package/templates/api/src/_platform/_shared/service/settings.ts +10 -1
- package/templates/api/src/_platform/_shared/service/solution-perms.ts +14 -7
- package/templates/api/src/_platform/_shared/site-facade.ts +44 -0
- package/templates/api/src/_platform/manifest.yaml +1 -0
- package/templates/api/src/_platform/market/api.ts +883 -0
- package/templates/api/src/_platform/market/bootstrap.ts +39 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.mysql.sql +17 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.pg.sql +16 -0
- package/templates/api/src/_platform/migrations/0010__sys_solution_route.sql +18 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.mysql.sql +2 -0
- package/templates/api/src/_platform/migrations/0011__sys_setting_group_label.sql +4 -0
- package/templates/api/src/_platform/migrations/0012__market_history.mysql.sql +13 -0
- package/templates/api/src/_platform/migrations/0012__market_history.pg.sql +12 -0
- package/templates/api/src/_platform/migrations/0012__market_history.sql +14 -0
- package/templates/api/src/_platform/schema.yaml +21 -4
- package/templates/api/src/_platform/seed.sql +2 -2
- package/templates/api/src/_platform/solutions/api.ts +212 -0
- package/templates/api/src/demo/manifest.yaml +5 -0
- package/templates/api/src/demo/migrations/0001__create_todos.sql +9 -0
- package/templates/api/src/demo/schema.yaml +11 -0
- package/templates/api/src/demo/seed.sql +5 -0
- package/templates/api/src/demo/todos/api.ts +59 -0
- package/templates/api/src/home/line/api.ts +57 -0
- package/templates/api/src/home/manifest.yaml +6 -0
- package/templates/api/src/home/migrations/0001__create_home_tables.sql +13 -0
- package/templates/api/src/home/pie/api.ts +40 -0
- package/templates/api/src/home/schema.yaml +14 -0
- package/templates/api/src/home/seed.sql +70 -0
- package/templates/api/src/system/oplog/api.ts +44 -2
- package/templates/api/tests/fixture.ts +16 -0
- package/templates/api/tests/lifecycle/atomic-state.spec.ts +183 -0
- package/templates/api/tests/lifecycle/bootstrap.spec.ts +116 -0
- package/templates/api/tests/lifecycle/deploy-smoke.spec.ts +492 -0
- package/templates/api/tests/lifecycle/installer.spec.ts +1218 -0
- package/templates/api/tests/lifecycle/market-api.spec.ts +971 -0
- package/templates/api/tests/lifecycle/migrate.spec.ts +575 -0
- package/templates/api/tests/lifecycle/preflight.spec.ts +255 -0
- package/templates/api/tests/lifecycle/register.spec.ts +194 -0
- package/templates/api/tests/lifecycle/semver.spec.ts +43 -0
- package/templates/api/tests/lifecycle/sha384.spec.ts +48 -0
- package/templates/api/tests/lifecycle/sig-fixtures.spec.ts +65 -0
- package/templates/api/tests/lifecycle/solutions-api.spec.ts +98 -0
- package/templates/api/tests/lifecycle/steps-fs.spec.ts +623 -0
- package/templates/api/tests/lifecycle/text-edit.spec.ts +177 -0
- package/templates/api/tests/lifecycle/tgz-guard.spec.ts +103 -0
- package/templates/api/tests/lifecycle/trust.spec.ts +310 -0
- package/templates/api/tests/lifecycle/uninstall.spec.ts +386 -0
- package/templates/api/tests/lifecycle/verify-checks.spec.ts +385 -0
- package/templates/api/tests/lifecycle/verify-report.spec.ts +290 -0
- package/templates/api/tests/rbac-settings.spec.ts +8 -0
- package/templates/init.include.yaml +56 -0
- package/templates/openapi.json +527 -0
- package/templates/package.json +52 -0
- package/templates/web/src/_platform/__client/api.schemas.ts +8 -0
- package/templates/web/src/_platform/__client/api.ts +335 -0
- package/templates/web/src/about/entry.ts +42 -0
- package/templates/web/src/about/locales/en-US.json +16 -0
- package/templates/web/src/about/locales/zh-CN.json +16 -0
- package/templates/web/src/about/pages/constants.ts +20 -0
- package/templates/web/src/about/pages/index.tsx +135 -0
- package/templates/web/src/access/entry.ts +74 -0
- package/templates/web/src/access/locales/en-US.json +46 -0
- package/templates/web/src/access/locales/zh-CN.json +46 -0
- package/templates/web/src/access/pages/access-mode/index.tsx +16 -0
- package/templates/web/src/access/pages/admin-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/button-control/index.tsx +181 -0
- package/templates/web/src/access/pages/common-visible/index.tsx +16 -0
- package/templates/web/src/access/pages/page-control/index.tsx +142 -0
- package/templates/web/src/demo/__client/api.schemas.ts +20 -0
- package/templates/web/src/demo/__client/api.ts +89 -0
- package/templates/web/src/exception/entry.ts +80 -0
- package/templates/web/src/exception/locales/en-US.json +13 -0
- package/templates/web/src/exception/locales/zh-CN.json +13 -0
- package/templates/web/src/exception/pages/403/index.tsx +30 -0
- package/templates/web/src/exception/pages/404/index.tsx +30 -0
- package/templates/web/src/exception/pages/500/index.tsx +30 -0
- package/templates/web/src/exception/pages/unknown-component/index.tsx +38 -0
- package/templates/web/src/home/__client/api.schemas.ts +22 -0
- package/templates/web/src/home/__client/api.ts +113 -0
- package/templates/web/src/login/locales/en-US.json +6 -0
- package/templates/web/src/login/locales/zh-CN.json +6 -0
- package/templates/web/src/login/pages/login.tsx +67 -18
- package/templates/web/src/oj-market/__tests__/CardGrid.test.tsx +93 -0
- package/templates/web/src/oj-market/__tests__/HistoryDrawer.test.tsx +28 -0
- package/templates/web/src/oj-market/__tests__/InstallDialog.test.tsx +69 -0
- package/templates/web/src/oj-market/__tests__/RollbackDialog.test.tsx +43 -0
- package/templates/web/src/oj-market/__tests__/UninstallConfirm.test.tsx +30 -0
- package/templates/web/src/oj-market/__tests__/marketClient.fake.ts +70 -0
- package/templates/web/src/oj-market/__tests__/poll.test.ts +24 -0
- package/templates/web/src/oj-market/__tests__/render.tsx +14 -0
- package/templates/web/src/oj-market/__tests__/runtime-stub.ts +14 -0
- package/templates/web/src/oj-market/__tests__/semver.test.ts +26 -0
- package/templates/web/src/oj-market/api/MarketClientProvider.tsx +12 -0
- package/templates/web/src/oj-market/api/client.ts +135 -0
- package/templates/web/src/oj-market/api/context.ts +11 -0
- package/templates/web/src/oj-market/api/defaultClient.ts +7 -0
- package/templates/web/src/oj-market/components/CardGrid.tsx +76 -0
- package/templates/web/src/oj-market/components/CardItem.tsx +102 -0
- package/templates/web/src/oj-market/components/DetailDrawer.tsx +53 -0
- package/templates/web/src/oj-market/components/HistoryDrawer.tsx +75 -0
- package/templates/web/src/oj-market/components/InstallDialog.tsx +112 -0
- package/templates/web/src/oj-market/components/RollbackDialog.tsx +79 -0
- package/templates/web/src/oj-market/components/UninstallConfirm.tsx +74 -0
- package/templates/web/src/oj-market/entry.ts +27 -0
- package/templates/web/src/oj-market/hooks/useMarket.ts +91 -0
- package/templates/web/src/oj-market/locales/en-US.json +100 -0
- package/templates/web/src/oj-market/locales/zh-CN.json +100 -0
- package/templates/web/src/oj-market/pages/MarketApp.tsx +136 -0
- package/templates/web/src/oj-market/types.ts +84 -0
- package/templates/web/src/oj-market/utils/poll.ts +36 -0
- package/templates/web/src/oj-market/utils/semver.ts +14 -0
- package/templates/web/src/outside/entry.ts +80 -0
- package/templates/web/src/outside/locales/en-US.json +10 -0
- package/templates/web/src/outside/locales/zh-CN.json +10 -0
- package/templates/web/src/personal-center/entry.ts +1 -10
- package/templates/web/src/personal-center/locales/en-US.json +1 -5
- package/templates/web/src/personal-center/locales/zh-CN.json +1 -5
- package/templates/web/src/route-nest/entry.ts +73 -0
- package/templates/web/src/route-nest/locales/en-US.json +9 -0
- package/templates/web/src/route-nest/locales/zh-CN.json +9 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-1/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu1/menu1-2/index.tsx +5 -0
- package/templates/web/src/route-nest/pages/menu2/index.tsx +5 -0
- package/templates/web/src/system/__client/api.schemas.ts +12 -0
- package/templates/web/src/system/__client/api.ts +25 -1
- package/templates/web/src/system/pages/oplog/index.tsx +1 -1
- package/templates/web/src/system/pages/roles/index.tsx +1 -0
- package/templates/web/src/system/pages/settings/index.tsx +1 -1
- package/templates/web/src/system/pages/users/index.tsx +1 -1
- package/templates/web.config.ts +32 -40
- package/vendor/host-versions.json +3 -3
- package/templates/pnpm-workspace.yaml +0 -14
- package/templates/web/src/personal-center/pages/settings/index.tsx +0 -5
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
// 安装状态机编排(P1-10,D11/OCP/SRP):把既有九个 step 收敛成声明式十阶段状态机——
|
|
2
|
+
// STAGES 表逐序遍历,步骤实现从各 step 模块导入适配,依赖(fetch/exec/reload/sha/db)经
|
|
3
|
+
// InstallRequest 注入(DIP,测试全 stub)。lockCommit 是显式 stage(位于 migrate 后,D11/R2:
|
|
4
|
+
// 迁移成功后才允许落锁;锁指向的目录先于锁落盘,S1-4 同 oj build 时序)。
|
|
5
|
+
// 失败语义三分(D11/契约附录 A):
|
|
6
|
+
// ① 锁前(preflight..lock_commit)失败 → 清场(staging 删除、status failed、零改锁零改库表
|
|
7
|
+
// 含零审计——什么都没发生就什么都不留),可重跑;
|
|
8
|
+
// ② 锁后(registering..smoke)失败 → status:"partial"(部分生效)+ 指引 key lifecycle.stage.*;
|
|
9
|
+
// ③ 切换前 verifyTarget() 复验 staging 产物完整性(sha256/迁移 seq 对齐分别在 verifying/
|
|
10
|
+
// migrating 段复核,不在此重做)。
|
|
11
|
+
// 终态审计(AC-P1-14):audit 恒末位,done/partial 都跑;audit 失败仅日志不阻断任务终态(附录 B-5)。
|
|
12
|
+
// reconcile() 入口对账自愈(D11):锁 ↔ 账本 head——锁指新版但账本落后 → 回指旧版(.bak + 旧版本
|
|
13
|
+
// 目录俱在)或摘除记 failed;超时 running 判 failed 同点执行(R13/B9/R31)。
|
|
14
|
+
// setEnabled 四处同写(D12/AC-P1-6):锁摘除/恢复 + sidecar + sys_solution_route.enabled +
|
|
15
|
+
// modules.json enabled;启用从 sidecar 摘除前记录恢复版本。db 为 isolate 全局(同模板 service 层)。
|
|
16
|
+
|
|
17
|
+
import type { OpEntry } from "../_shared/service/audit";
|
|
18
|
+
import type { TaskLock } from "./lock";
|
|
19
|
+
import type { StateStore } from "./state";
|
|
20
|
+
import type { FetchFace } from "./steps/download";
|
|
21
|
+
import type { ExecFace } from "./steps/extract";
|
|
22
|
+
import type { SettingDecl, SolutionDecl, SolutionSnapshot } from "./steps/register";
|
|
23
|
+
import type { ReloadFace } from "./steps/reload";
|
|
24
|
+
import type { InstallContext, InstallStage, StageResult, TaskRecord, TaskStatus } from "./types";
|
|
25
|
+
import { auditOp } from "../_shared/service/audit";
|
|
26
|
+
import { atomicWriteText, fsFace, randomId } from "./atomic";
|
|
27
|
+
import { recordHistory } from "./history";
|
|
28
|
+
import { acquireTaskLock, commitLockEntry, readLock, writeLock } from "./lock";
|
|
29
|
+
import { deployWeb } from "./steps/deploy-web";
|
|
30
|
+
import { download } from "./steps/download";
|
|
31
|
+
import { extract } from "./steps/extract";
|
|
32
|
+
import { maxAppliedSeq, migrate, rollbackGuard } from "./steps/migrate";
|
|
33
|
+
import { preflight } from "./steps/preflight";
|
|
34
|
+
import { registerSolution } from "./steps/register";
|
|
35
|
+
import { reloadAndAssert } from "./steps/reload";
|
|
36
|
+
import { smoke } from "./steps/smoke";
|
|
37
|
+
import { verify } from "./steps/verify";
|
|
38
|
+
import { getTask, readTasks, sweepTimedOut, writeTask } from "./tasks";
|
|
39
|
+
import { appendAnonymousPaths, bumpInstallSeq } from "./text-edit";
|
|
40
|
+
import { createTrustStore } from "./trust";
|
|
41
|
+
|
|
42
|
+
// db/http 为 oj isolate 注入全局(同模板 service 层惯例,register.ts/audit.ts 同款;测试由夹具装 globalThis.db)
|
|
43
|
+
declare const db: { exec: (sql: string, params: unknown[]) => Promise<unknown> };
|
|
44
|
+
|
|
45
|
+
export type { InstallContext, InstallStage, StageResult, TaskRecord, TaskStatus };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 安装请求:身份 + 全部站点路径 + 注入面(DIP)。路径全部注入不硬编码站点布局(P1-9 同纪律);
|
|
49
|
+
* `oj migrate -d` 实参由 migrate 段自暂存根推导(迁移服务目录,B-11——不再注入 dataDir),
|
|
50
|
+
* `dbPath`(R14 备份源)仍显式注入。
|
|
51
|
+
*/
|
|
52
|
+
export interface InstallRequest extends InstallContext {
|
|
53
|
+
/** 分发包完整 URL(download stage;安装器不拼 URL——Form B 索引解析属市场/CLI 侧) */
|
|
54
|
+
archiveUrl: string
|
|
55
|
+
/** 共享根(manifests.yaml 所在目录 = 版本目录父级,D3 单 mount 形态) */
|
|
56
|
+
sharedRoot: string
|
|
57
|
+
/** 暂存目录(.staging;download/verify/extract 接力,migrate svc 物化源,lockCommit rename 源) */
|
|
58
|
+
staging: string
|
|
59
|
+
/** 任务状态目录(.install-tasks) */
|
|
60
|
+
taskDir: string
|
|
61
|
+
/** sqlite 库文件路径(R14 前置备份源;备份落同目录,保留 3 份由 migrate 段管) */
|
|
62
|
+
dbPath: string
|
|
63
|
+
/** 站点 web 模块目录(<dist>/modules) */
|
|
64
|
+
siteModulesDir: string
|
|
65
|
+
/** 站点 modules.json 路径(与 siteModulesDir 同属 <dist>) */
|
|
66
|
+
modulesJsonPath: string
|
|
67
|
+
/** API 前缀(smoke 探针 URL 前缀,C2-13) */
|
|
68
|
+
apiPrefix: string
|
|
69
|
+
/** 站点 config.yaml 路径(reloading 段 vars.ojm_install_seq 自增落盘处;缺省 config.yaml) */
|
|
70
|
+
configPath?: string
|
|
71
|
+
/** 注入面(DIP):下载 / exec(tar+oj)/ 热更 / sha384(R10 integrity 重算) */
|
|
72
|
+
fetch: FetchFace
|
|
73
|
+
exec: ExecFace
|
|
74
|
+
reload: ReloadFace
|
|
75
|
+
sha384B64: (data: Uint8Array) => string
|
|
76
|
+
/** sha256 摘要面(verifying 段;缺省回落运行时全局,同 verify.ts) */
|
|
77
|
+
sha256Hex?: (data: Uint8Array) => string
|
|
78
|
+
/** 宿主信任库路径(P2-3 验签;.solutions/trusted-keys.yaml,端点注入 env.trustPath) */
|
|
79
|
+
trustPath?: string
|
|
80
|
+
/** Ed25519 验签面(P2-3;缺省回落运行时全局 crypto.verifyEd25519,同 verify.ts) */
|
|
81
|
+
verifyEd25519?: (publicKey: string, msg: string, sigBase64: string) => boolean
|
|
82
|
+
/** 迁移账本 head(rollbackGuard 真源;缺省跳过禁降版判定,同 migrate.ts) */
|
|
83
|
+
ledgerHead?: number | bigint | null
|
|
84
|
+
/** 注册快照查询(smoke;缺省回落 queryRegistered 的 db 全局) */
|
|
85
|
+
query?: (name: string) => Promise<SolutionSnapshot | null>
|
|
86
|
+
/** 审计出口(缺省 auditOp;测试注入记录器) */
|
|
87
|
+
audit?: (entry: OpEntry) => void
|
|
88
|
+
/** reload 终态断言期望生效段键(缺省 ["vars"],同 reload.ts——B-11 安装触发面) */
|
|
89
|
+
expected?: string[]
|
|
90
|
+
/** 安装器内部通道(外部调用方忽略):终态审计读 status/failedStage/taskId + migrate 捕获 appliedSeq */
|
|
91
|
+
outcome?: { taskId?: string, status?: TaskStatus, failedStage?: InstallStage, appliedSeq?: number | null, cancelled?: boolean }
|
|
92
|
+
/**
|
|
93
|
+
* 取消安装令牌(U5,P3-8):运行期外部置位(端点 `?cancel=<taskId>` 或任务状态 `cancelling` 驱动的闭包),
|
|
94
|
+
* 返回 true 即中止——PIPELINE 循环每段前探测 + migrating 段 spawn 流逐块探测;幂等、可重入
|
|
95
|
+
*/
|
|
96
|
+
cancel?: () => boolean
|
|
97
|
+
/** 安装器内部通道(P2-13):migrating 段内进度回调(runInstall 注入;spawn 流式路径逐块触发) */
|
|
98
|
+
onProgress?: (frac: number) => void
|
|
99
|
+
/** 操作人(历史留痕;端点按 http.user 反查注入,缺省 null) */
|
|
100
|
+
operator?: string
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 单个 stage 的声明式定义(OCP:新增/调整阶段只动本表与适配函数,不动遍历器) */
|
|
104
|
+
export interface StageDef {
|
|
105
|
+
name: InstallStage
|
|
106
|
+
run: (req: InstallRequest) => Promise<StageResult>
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 锁前 stage 集合(失败语义①清场重跑;lock_commit 收 D11①锁前语义——落锁即生效点之前最后一步) */
|
|
110
|
+
const PRE_LOCK_STAGES = new Set<InstallStage>(["preflight", "downloading", "verifying", "extracting", "migrating", "lock_commit"]);
|
|
111
|
+
|
|
112
|
+
/** P3-2 版本目录保留数(按 installedAt 倒序保留最近 N 个;对照 TASK_KEEP=10 / KEEP_BACKUPS=3 范式) */
|
|
113
|
+
const KEEP_VERSIONS = 3;
|
|
114
|
+
|
|
115
|
+
function isObj(v: unknown): v is Record<string, unknown> {
|
|
116
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 宿主层禁降版近似口径(P3-11):staging 迁移文件名 `NNNN__` 前缀取最大 seq——applied[]
|
|
121
|
+
* (oj migrate 真源)产生之前的近似真源。无 api 段 / 无 migrations 目录 / 无可解析文件名
|
|
122
|
+
* 返回 null(无近似真源不拦截,仍由 migrate 段 applied[] 事后复核兜底,OJ-9 双保险)。
|
|
123
|
+
*/
|
|
124
|
+
async function stagingMaxSeq(staging: string, name: string, version: string): Promise<number | null> {
|
|
125
|
+
try {
|
|
126
|
+
const entries = await fsFace().readdir(`${staging}/api/${name}-${version}/migrations`);
|
|
127
|
+
let max: number | null = null;
|
|
128
|
+
for (const e of entries) {
|
|
129
|
+
const m = /^(\d+)__/.exec(e);
|
|
130
|
+
if (m === null)
|
|
131
|
+
continue;
|
|
132
|
+
const v = Number(m[1]);
|
|
133
|
+
if (max === null || v > max)
|
|
134
|
+
max = v;
|
|
135
|
+
}
|
|
136
|
+
return max;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function migrateStage(req: InstallRequest): Promise<StageResult> {
|
|
144
|
+
// 宿主层禁降版预锁校验(P3-11,OJ-9):备份/exec 前先拦一道——包内迁移 max seq 已低于
|
|
145
|
+
// 账本 head 即拒绝(retryable=false),零 exec 零备份、库保持原状;不等 oj migrate 跑完
|
|
146
|
+
// 再由 parse 输出复核。近似真源缺失时放行,事后复核仍在(migrate.ts rollbackGuard)。
|
|
147
|
+
if (req.ledgerHead !== undefined && req.ledgerHead !== null) {
|
|
148
|
+
const seq = await stagingMaxSeq(req.staging, req.name, req.version);
|
|
149
|
+
if (seq !== null && !rollbackGuard(seq, req.ledgerHead)) {
|
|
150
|
+
return {
|
|
151
|
+
stage: "migrating",
|
|
152
|
+
ok: false,
|
|
153
|
+
retryable: false,
|
|
154
|
+
message: `迁移失败:包内迁移最大 seq ${seq} 低于账本 head ${req.ledgerHead}——禁降版部署(OJ-9 红线,宿主层预锁拦截,P3-11);未执行 oj migrate、库保持原状(D11)`,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return await migrate({
|
|
159
|
+
...req,
|
|
160
|
+
// P2-13:appliedSeq 捕获改走 migrate 解析成功回调(单点真源——run/spawn 双路径都在
|
|
161
|
+
// migrate 内聚合 stdout 后解析,包装器无法再从 ExecResult 读到流式产物)
|
|
162
|
+
onMigrateOutput: (output) => {
|
|
163
|
+
req.outcome = { ...req.outcome, appliedSeq: maxAppliedSeq(output) };
|
|
164
|
+
},
|
|
165
|
+
onProgress: req.onProgress,
|
|
166
|
+
// U5 取消安装(P3-8):令牌透传进迁移上下文——spawn 流式路径逐块探测,命中点 h.kill() 提前终止
|
|
167
|
+
cancel: req.cancel,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** P2-6 复验真源:从安装清单提取包摘要 + 签名记录(随 done/partial 落 sidecar,供切换前复验) */
|
|
172
|
+
function archiveRecordOf(manifest: unknown): { sha256: string, key_id: string, signature: Record<string, unknown> } | undefined {
|
|
173
|
+
const m = isObj(manifest) ? manifest : {};
|
|
174
|
+
const arch = isObj(m.archive) ? m.archive : undefined;
|
|
175
|
+
const sha256 = typeof arch?.sha256 === "string" ? arch.sha256 : "";
|
|
176
|
+
const sig = isObj(arch?.signature) ? arch.signature : undefined;
|
|
177
|
+
const keyId = typeof sig?.key_id === "string" ? sig.key_id : "";
|
|
178
|
+
if (sha256 === "" || sig === undefined || keyId === "")
|
|
179
|
+
return undefined; // 无签名真源(理论不可达:P2-3 验签已过即必带 signature)
|
|
180
|
+
return { sha256, key_id: keyId, signature: sig };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 切换前复验(失败语义③):staging 产物完整性——manifest.yaml 在、web 声明的 entry 在包内(R10 错配防线) */
|
|
184
|
+
async function verifyTarget(req: InstallRequest): Promise<{ ok: boolean, message: string }> {
|
|
185
|
+
const f = fsFace();
|
|
186
|
+
try {
|
|
187
|
+
await f.readFile(`${req.staging}/manifest.yaml`);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return { ok: false, message: "生效切换复验失败:staging 缺 manifest.yaml(解压产物不完整?)——附录 A lock_commit" };
|
|
191
|
+
}
|
|
192
|
+
const m = isObj(req.manifest) ? req.manifest : {};
|
|
193
|
+
const web = isObj(m.web) ? m.web : undefined;
|
|
194
|
+
const entry = web !== undefined && typeof web.entry === "string" ? web.entry : undefined;
|
|
195
|
+
if (entry !== undefined) {
|
|
196
|
+
try {
|
|
197
|
+
await f.readFile(`${req.staging}/web/${entry}`);
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return { ok: false, message: `生效切换复验失败:web.entry「${entry}」在包内缺失——manifest 与产物错配(R10),拒绝切换` };
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { ok: true, message: "复验通过" };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async function lockCommitStage(req: InstallRequest): Promise<StageResult> {
|
|
207
|
+
const vt = await verifyTarget(req);
|
|
208
|
+
if (!vt.ok) {
|
|
209
|
+
return { stage: "lock_commit", ok: false, retryable: false, message: vt.message };
|
|
210
|
+
}
|
|
211
|
+
const f = fsFace();
|
|
212
|
+
const versionDir = `${req.sharedRoot}/${req.name}-${req.version}`;
|
|
213
|
+
try {
|
|
214
|
+
await f.mkdir(req.sharedRoot, { recursive: true });
|
|
215
|
+
}
|
|
216
|
+
catch (e) {
|
|
217
|
+
return { stage: "lock_commit", ok: false, retryable: true, message: `生效切换失败:共享根不可建(${String(e)})——附录 A lock_commit:请稍后重试` };
|
|
218
|
+
}
|
|
219
|
+
// 同版本重装:目标已存在先移除(重装 = 目录内容 fresh copy,幂等);不同版本目录共存
|
|
220
|
+
// (AC-P1-3:旧版本目录永不 prune——回滚保留,GC 属 P3)。fsp.rm 对缺失路径会抛,缺省即首装。
|
|
221
|
+
try {
|
|
222
|
+
await f.remove(versionDir, { recursive: true });
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
// 目标不存在 = 首装,正常路径
|
|
226
|
+
}
|
|
227
|
+
// B-11:发布面 = 包内 api 模块目录(oj 原生清单 + routes.js + migrations/,serve 装配
|
|
228
|
+
// parse_one 消费面——staging 整体发布会让版本目录缺模块清单/多 web 树);web/ 与 C2 manifest
|
|
229
|
+
// 留在 staging(deploying_web 段消费)。无 api 段(web-only 包,extracting C2-31 已放行)
|
|
230
|
+
// 回退整体 rename——版本目录 = 包内容(无 serve 装配面的残留形态)。
|
|
231
|
+
let src = req.staging;
|
|
232
|
+
try {
|
|
233
|
+
await f.readdir(`${req.staging}/api/${req.name}-${req.version}`);
|
|
234
|
+
src = `${req.staging}/api/${req.name}-${req.version}`;
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// 无 api 段:整体 rename
|
|
238
|
+
}
|
|
239
|
+
try {
|
|
240
|
+
await f.rename(src, versionDir);
|
|
241
|
+
}
|
|
242
|
+
catch (e) {
|
|
243
|
+
return { stage: "lock_commit", ok: false, retryable: true, message: `生效切换失败:${src} rename 失败(${String(e)})——附录 A lock_commit:请稍后重试` };
|
|
244
|
+
}
|
|
245
|
+
const c = await commitLockEntry(`${req.sharedRoot}/manifests.yaml`, req.name, req.version);
|
|
246
|
+
if (!c.ok) {
|
|
247
|
+
// 锁写失败回退 rename(锁未变则多余版本目录撤回,由锁前清场统一删;回退失败由 reconcile 兜底,D11)
|
|
248
|
+
try {
|
|
249
|
+
await f.rename(versionDir, src);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// 残留版本目录不在锁内,release 装配忽略;对账自愈兜底
|
|
253
|
+
}
|
|
254
|
+
return { stage: "lock_commit", ok: false, retryable: true, message: c.message };
|
|
255
|
+
}
|
|
256
|
+
return { stage: "lock_commit", ok: true, message: `生效切换完成:${src} → ${versionDir} 就位 + ${c.message}(.bak 已备份原锁,D12;web 树留 staging 由部署段消费)` };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* manifest.yaml(C2 形状)→ SolutionDecl(D10 出口入参):roles[].title_key → title、
|
|
261
|
+
* settings.groups[].items[] 摊平、enum options 序列化为 JSON 字符串(sys_setting.options 形态)。
|
|
262
|
+
*/
|
|
263
|
+
export function declOf(req: InstallRequest): SolutionDecl {
|
|
264
|
+
const m = isObj(req.manifest) ? req.manifest : {};
|
|
265
|
+
const settings: SettingDecl[] = [];
|
|
266
|
+
const groups = isObj(m.settings) && Array.isArray(m.settings.groups) ? m.settings.groups : [];
|
|
267
|
+
for (const grp of groups) {
|
|
268
|
+
if (!isObj(grp) || !Array.isArray(grp.items)) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
// 组级 label_key(P2-15③ G7 承载:declOf 此前丢弃——组标题随注册面落库 sys_setting.group_label_key)
|
|
272
|
+
const groupLabelKey = typeof grp.label_key === "string" ? grp.label_key : null;
|
|
273
|
+
for (const it of grp.items) {
|
|
274
|
+
if (!isObj(it) || typeof it.key !== "string") {
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
settings.push({
|
|
278
|
+
key: it.key,
|
|
279
|
+
group: typeof grp.group === "string" ? grp.group : "",
|
|
280
|
+
type: typeof it.type === "string" ? it.type : "string",
|
|
281
|
+
value: typeof it.value === "string" ? it.value : undefined,
|
|
282
|
+
default_value: it.default_value == null ? null : String(it.default_value),
|
|
283
|
+
label_key: typeof it.label_key === "string" ? it.label_key : null,
|
|
284
|
+
group_label_key: groupLabelKey,
|
|
285
|
+
options: Array.isArray(it.options) ? JSON.stringify(it.options) : it.options == null ? null : String(it.options),
|
|
286
|
+
secret: it.secret === true,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return {
|
|
291
|
+
module: req.name,
|
|
292
|
+
version: req.version,
|
|
293
|
+
roles: (Array.isArray(m.roles) ? m.roles : []).filter(isObj).map(r => ({
|
|
294
|
+
name: typeof r.name === "string" ? r.name : "",
|
|
295
|
+
title: typeof r.title_key === "string" ? r.title_key : "",
|
|
296
|
+
permissions: Array.isArray(r.permissions) ? r.permissions.map(String) : [],
|
|
297
|
+
})),
|
|
298
|
+
settings,
|
|
299
|
+
routes: (Array.isArray(m.routes) ? m.routes : []).filter(isObj).map(r => ({
|
|
300
|
+
path: typeof r.path === "string" ? r.path : "",
|
|
301
|
+
category: r.category === "admin" || r.category === "hidden" ? r.category : "main",
|
|
302
|
+
order: typeof r.order === "number" ? r.order : 0,
|
|
303
|
+
title_key: typeof r.title_key === "string" ? r.title_key : undefined,
|
|
304
|
+
required_permission: typeof r.required_permission === "string" ? r.required_permission : undefined,
|
|
305
|
+
})),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function registerStage(req: InstallRequest): Promise<StageResult> {
|
|
310
|
+
return registerSolution(declOf(req));
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
async function deployWebStage(req: InstallRequest): Promise<StageResult> {
|
|
314
|
+
// R10 包内容口径:对验签后落位的 entry 产物重算 sha384 传入(防 manifest 与产物错配;
|
|
315
|
+
// 缺 web 段时 deployWeb 自行 no-op)。B-11 后 web 树不再随 lockCommit 进版本目录(发布面 =
|
|
316
|
+
// api 模块目录),故 stagingDir/integrity 源都在 staging。
|
|
317
|
+
const m = isObj(req.manifest) ? req.manifest : {};
|
|
318
|
+
const web = isObj(m.web) ? m.web : undefined;
|
|
319
|
+
const entry = web !== undefined && typeof web.entry === "string" ? web.entry : undefined;
|
|
320
|
+
let integrity: string | undefined;
|
|
321
|
+
if (entry !== undefined) {
|
|
322
|
+
try {
|
|
323
|
+
integrity = `sha384-${req.sha384B64(await fsFace().readFile(`${req.staging}/web/${entry}`))}`;
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
return { stage: "deploying_web", ok: false, retryable: false, message: `部署失败:web.entry「${entry}」在 staging 不可读(${String(e)})——manifest 与产物错配(R10)` };
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return deployWeb({ ...req, stagingDir: req.staging, integrity });
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* 热更段适配(B-11 reload 触发面):先落盘触发变更再 reloadAndAssert——reloadConfig 的
|
|
334
|
+
* unchanged 判定基于 config 文档 sha(oj-bin reload_bridge 真源),不落盘变更则热更恒
|
|
335
|
+
* unchanged 必失败。触发变更 = vars.ojm_install_seq 自增(PRD E5 实测先例:vars 段语义值
|
|
336
|
+
* 改动 applied 可热生效,无需改 mounts),经 text-edit 纯文本行操作(R18,注释/键序逐字保留)。
|
|
337
|
+
*/
|
|
338
|
+
async function reloadStage(req: InstallRequest): Promise<StageResult> {
|
|
339
|
+
const path = req.configPath ?? "config.yaml";
|
|
340
|
+
try {
|
|
341
|
+
const content = new TextDecoder().decode(await fsFace().readFile(path));
|
|
342
|
+
const bumped = bumpInstallSeq(content);
|
|
343
|
+
// C2-26 anonymous 运行时接线(P2-15③,观察项 6 收口):方案匿名端点补进
|
|
344
|
+
// auth.anonymous_paths(幂等;热改属 restart_pending——reload 消息已明示重启生效)
|
|
345
|
+
const m = isObj(req.manifest) ? req.manifest : {};
|
|
346
|
+
const api = isObj(m.api) ? m.api : {};
|
|
347
|
+
const anonymous = Array.isArray(api.anonymous) ? api.anonymous.filter((x): x is string => typeof x === "string") : [];
|
|
348
|
+
const patched = anonymous.length > 0 ? appendAnonymousPaths(bumped.content, anonymous) : { content: bumped.content, added: [] };
|
|
349
|
+
await atomicWriteText(path, patched.content);
|
|
350
|
+
}
|
|
351
|
+
catch (e) {
|
|
352
|
+
return { stage: "reloading", ok: false, retryable: true, message: `热更失败:config.yaml 安装序号自增/匿名端点接线失败(${String(e)})——reload unchanged 判定基于 config 文档 sha,无变更则热更不可能生效(B-11,PRD E5 口径)` };
|
|
353
|
+
}
|
|
354
|
+
return reloadAndAssert(req);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/** 终态审计(AC-P1-14):done/partial 都落 oplog;自身失败仅日志(附录 B-5,不阻断任务终态) */
|
|
358
|
+
async function auditStage(req: InstallRequest): Promise<StageResult> {
|
|
359
|
+
const status = req.outcome?.status ?? "done";
|
|
360
|
+
const detail = { status, failedStage: req.outcome?.failedStage, taskId: req.outcome?.taskId };
|
|
361
|
+
try {
|
|
362
|
+
(req.audit ?? auditOp)({ action: "solution.install", module: req.name, version: req.version, target: req.name, detail });
|
|
363
|
+
return { stage: "audit", ok: true, message: `终态审计已落 oplog(solution.install,status=${status};失败也审计,AC-P1-14)` };
|
|
364
|
+
}
|
|
365
|
+
catch (e) {
|
|
366
|
+
// 上下文随 stage message 进任务记录(禁 console——release isolate console 抛 BadResource 会毒死本 catch,Finding A)
|
|
367
|
+
return { stage: "audit", ok: true, message: `终态审计失败仅记日志:${String(e)}` };
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 十阶段声明式状态机表(契约附录 A stage 名逐字;lock_commit 为 D11①扩展组)。
|
|
373
|
+
* audit 恒末位:runInstall 两段调用——前十个 stage 顺序遍历,done/partial 终态确定后跑 audit
|
|
374
|
+
* (终态审计含 partial;预检段失败零痕迹含审计——失败语义①什么都没发生就什么都不留)。
|
|
375
|
+
*/
|
|
376
|
+
export const STAGES: StageDef[] = [
|
|
377
|
+
{ name: "preflight", run: req => preflight(req) },
|
|
378
|
+
{ name: "downloading", run: req => download(req) },
|
|
379
|
+
{ name: "verifying", run: req => verify(req) },
|
|
380
|
+
{ name: "extracting", run: req => extract(req) },
|
|
381
|
+
{ name: "migrating", run: migrateStage },
|
|
382
|
+
{ name: "lock_commit", run: lockCommitStage },
|
|
383
|
+
{ name: "registering", run: registerStage },
|
|
384
|
+
{ name: "deploying_web", run: deployWebStage },
|
|
385
|
+
{ name: "reloading", run: reloadStage },
|
|
386
|
+
{ name: "smoke", run: req => smoke(req) },
|
|
387
|
+
{ name: "audit", run: auditStage },
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
const PIPELINE = STAGES.filter(s => s.name !== "audit");
|
|
391
|
+
const AUDIT_DEF = STAGES[STAGES.length - 1]!;
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* P3-2 版本 GC:成功安装后清理旧版本目录,保留最近 KEEP_VERSIONS 个(按 installedAt 倒序),
|
|
395
|
+
* 当前锁版本强制保留(回滚目标不可被 GC 误删——否则 reconcile 回指旧版的 repoint 会因目录缺失降级为摘除)。
|
|
396
|
+
* 清理为运维性动作:缺锁/读锁失败/删目录失败均静默,不阻断安装主流程。
|
|
397
|
+
*/
|
|
398
|
+
export async function gcVersions(sharedRoot: string, name: string, state: StateStore): Promise<void> {
|
|
399
|
+
const f = fsFace();
|
|
400
|
+
let lock: Record<string, string>;
|
|
401
|
+
try {
|
|
402
|
+
lock = await readLock(`${sharedRoot}/manifests.yaml`);
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
return; // 无锁(首装即失败等极端态)不 GC
|
|
406
|
+
}
|
|
407
|
+
const locked = lock[name];
|
|
408
|
+
const entry = state.read().solutions[name];
|
|
409
|
+
if (entry?.history === undefined || entry.history.length <= KEEP_VERSIONS) {
|
|
410
|
+
return; // 版本数未超阈值,无需清理
|
|
411
|
+
}
|
|
412
|
+
// 保留集:最近 N 个 + 锁版本(强制,即使超出 N)
|
|
413
|
+
const keep = new Set<string>();
|
|
414
|
+
for (const h of [...entry.history].sort((a, b) => b.installedAt - a.installedAt).slice(0, KEEP_VERSIONS)) {
|
|
415
|
+
keep.add(h.version);
|
|
416
|
+
}
|
|
417
|
+
if (locked !== undefined) {
|
|
418
|
+
keep.add(locked);
|
|
419
|
+
}
|
|
420
|
+
let trimmed = false;
|
|
421
|
+
const nextHistory = entry.history.filter((h) => {
|
|
422
|
+
if (keep.has(h.version))
|
|
423
|
+
return true;
|
|
424
|
+
trimmed = true;
|
|
425
|
+
// 删除不在保留集的版本目录(best-effort:失败不阻断)
|
|
426
|
+
f.remove(`${sharedRoot}/${name}-${h.version}`, { recursive: true }).catch(() => {});
|
|
427
|
+
return false;
|
|
428
|
+
});
|
|
429
|
+
if (trimmed) {
|
|
430
|
+
await state.write({ solutions: { [name]: { ...entry, history: nextHistory } } });
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* U5 取消安装回退(P3-8):把锁/版本目录/staging 恢复到「本次安装发起前」的状态——
|
|
436
|
+
* ① staging 清空(下载/解压/迁移产物一并清);② 锁写回到装前快照(首装装前无锁则不落锁文件,
|
|
437
|
+
* 不污染空锁);③ 撤销本安装新建的版本目录(仅当装前锁未指向该版本——升级场景下旧版本目录保留,
|
|
438
|
+
* 新版本目录移除)。幂等、可重入:重复调用对已是装前态的盘无副作用。
|
|
439
|
+
*/
|
|
440
|
+
async function cancelCleanup(req: InstallRequest, lockBefore: Record<string, string>, lockExisted: boolean): Promise<void> {
|
|
441
|
+
const f = fsFace();
|
|
442
|
+
try {
|
|
443
|
+
await f.remove(req.staging, { recursive: true });
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
// staging 已不存在(首装取消 / 重复回退)不掩盖取消终态
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
if (lockExisted) {
|
|
450
|
+
await writeLock(`${req.sharedRoot}/manifests.yaml`, lockBefore);
|
|
451
|
+
}
|
|
452
|
+
else {
|
|
453
|
+
// 首装:装前本无锁文件,取消后也不该留空锁(空锁拒启 S1-1.2)
|
|
454
|
+
await f.remove(`${req.sharedRoot}/manifests.yaml`, { force: true });
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
// 锁回退失败由 reconcile 对账兜底(D11)
|
|
459
|
+
}
|
|
460
|
+
// 撤销本安装新建的版本目录(装前锁未指向该版本才移除——保留升级前的旧版本目录供回滚)
|
|
461
|
+
if (lockBefore[req.name] !== req.version) {
|
|
462
|
+
try {
|
|
463
|
+
await f.remove(`${req.sharedRoot}/${req.name}-${req.version}`, { recursive: true });
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
// 版本目录未创建(锁前取消)或已不存在不掩盖取消终态
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* 声明式遍历 stage 表执行安装(§10.2 任务模型):任务文件逐 stage 落盘(800ms 轮询消费面)、
|
|
473
|
+
* .install-tasks/.lock 互斥(R17/R31)、失败语义三分、终态审计含 partial(AC-P1-14)、
|
|
474
|
+
* U5 取消安装(P3-8:cancel token 触发清场+锁/目录回退装前+审计末位)。
|
|
475
|
+
* 返回终态 TaskRecord(done / failed / partial / cancelled-by-failed)。
|
|
476
|
+
*/
|
|
477
|
+
export async function runInstall(req: InstallRequest): Promise<TaskRecord> {
|
|
478
|
+
const now = Date.now();
|
|
479
|
+
const id = `install-${now}-${randomId().slice(0, 8)}`;
|
|
480
|
+
const task: TaskRecord = {
|
|
481
|
+
id,
|
|
482
|
+
name: req.name,
|
|
483
|
+
version: req.version,
|
|
484
|
+
status: "running",
|
|
485
|
+
stage: null,
|
|
486
|
+
progress: 0,
|
|
487
|
+
message: "任务已创建",
|
|
488
|
+
stages: [],
|
|
489
|
+
startedAt: now,
|
|
490
|
+
};
|
|
491
|
+
req.outcome = { ...req.outcome, taskId: id };
|
|
492
|
+
// 同名已装 = 升级/重装路径(AC-P1-3):放行位交 preflight(缺省拒装钉面不变)
|
|
493
|
+
req.reinstall = req.state.read().solutions[req.name] !== undefined;
|
|
494
|
+
// 任务互斥(R17/R31):并发安装 busy 即终(retryable——稍后重试)
|
|
495
|
+
let lock: TaskLock;
|
|
496
|
+
try {
|
|
497
|
+
lock = await acquireTaskLock(req.taskDir, { now });
|
|
498
|
+
}
|
|
499
|
+
catch (e) {
|
|
500
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
501
|
+
task.status = "failed";
|
|
502
|
+
task.message = msg;
|
|
503
|
+
task.error = { reason: msg, retryable: true };
|
|
504
|
+
task.finishedAt = Date.now();
|
|
505
|
+
try {
|
|
506
|
+
await writeTask(req.taskDir, task);
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
// 任务面写入失败不掩盖 busy 终态
|
|
510
|
+
}
|
|
511
|
+
return task;
|
|
512
|
+
}
|
|
513
|
+
try {
|
|
514
|
+
// 对账时点纪律(R13/B9/R31):残留 running 超时判 failed,再开新任务
|
|
515
|
+
await sweepTimedOut(req.taskDir, now);
|
|
516
|
+
await writeTask(req.taskDir, task);
|
|
517
|
+
// U5 取消安装(P3-8):装前锁快照——取消回退的「装前态」真源(首装时锁文件尚不存在)
|
|
518
|
+
let lockBefore: Record<string, string> = {};
|
|
519
|
+
let lockExisted = false;
|
|
520
|
+
try {
|
|
521
|
+
lockBefore = await readLock(`${req.sharedRoot}/manifests.yaml`);
|
|
522
|
+
lockExisted = true;
|
|
523
|
+
}
|
|
524
|
+
catch {
|
|
525
|
+
lockExisted = false;
|
|
526
|
+
}
|
|
527
|
+
// P2-13 段内进度:migrating 段 spawn 流式路径逐块触发;写链串行(回调 fire-and-forget
|
|
528
|
+
// 防交错),失败静默(进度观测面不阻断安装)
|
|
529
|
+
let progressChain: Promise<void> = Promise.resolve();
|
|
530
|
+
for (let i = 0; i < PIPELINE.length; i++) {
|
|
531
|
+
const def = PIPELINE[i]!;
|
|
532
|
+
// U5 取消入口一(区间外取消):进入任一 stage 前先探测 cancel token——段间取消即时响应,
|
|
533
|
+
// 含非 migrate 段;命中即走「清场 + 锁/目录回退装前 + 审计末位」语义(幂等、可重入)
|
|
534
|
+
if (req.cancel?.()) {
|
|
535
|
+
task.stage = def.name;
|
|
536
|
+
task.message = "安装已被用户取消(U5 取消安装)";
|
|
537
|
+
task.stages.push({ stage: def.name, ok: false, message: "安装已被用户取消(用户中止)" });
|
|
538
|
+
const postLock = !PRE_LOCK_STAGES.has(def.name);
|
|
539
|
+
task.status = postLock ? "partial" : "failed";
|
|
540
|
+
task.cancelled = true;
|
|
541
|
+
req.outcome = { ...req.outcome, status: task.status, cancelled: true, failedStage: def.name };
|
|
542
|
+
await cancelCleanup(req, lockBefore, lockExisted);
|
|
543
|
+
task.error = { reason: "安装已被用户取消(U5 取消安装)", retryable: true, hintKey: "lifecycle.stage.cancelled" };
|
|
544
|
+
break;
|
|
545
|
+
}
|
|
546
|
+
task.stage = def.name;
|
|
547
|
+
task.message = `执行中:${def.name}`;
|
|
548
|
+
await writeTask(req.taskDir, task);
|
|
549
|
+
if (def.name === "migrating") {
|
|
550
|
+
const stageIndex = i;
|
|
551
|
+
req.onProgress = (frac) => {
|
|
552
|
+
progressChain = progressChain.then(async () => {
|
|
553
|
+
task.progress = Math.round(((stageIndex + Math.min(frac, 0.99)) / STAGES.length) * 100);
|
|
554
|
+
await writeTask(req.taskDir, task);
|
|
555
|
+
}).catch(() => {});
|
|
556
|
+
};
|
|
557
|
+
}
|
|
558
|
+
else {
|
|
559
|
+
req.onProgress = undefined;
|
|
560
|
+
}
|
|
561
|
+
let r: StageResult;
|
|
562
|
+
try {
|
|
563
|
+
r = await def.run(req);
|
|
564
|
+
}
|
|
565
|
+
catch (e) {
|
|
566
|
+
const stack = e instanceof Error && e.stack !== undefined ? ` @ ${e.stack.split("\n")[1]?.trim() ?? ""}` : "";
|
|
567
|
+
r = { stage: def.name, ok: false, retryable: true, message: `${def.name} 阶段异常:${String(e)}${stack}` };
|
|
568
|
+
}
|
|
569
|
+
task.stages.push({ stage: def.name, ok: r.ok, message: r.message });
|
|
570
|
+
if (task.appliedSeq === undefined && req.outcome?.appliedSeq != null) {
|
|
571
|
+
task.appliedSeq = req.outcome.appliedSeq;
|
|
572
|
+
}
|
|
573
|
+
// U5 取消安装(P3-8):段内取消命中(migrating 段 spawn 流 h.kill 提前终止后返回 cancelled)。
|
|
574
|
+
// 先于通用失败分支处理——走「清场 + 锁/目录回退装前 + 审计末位」语义,终态取 failed(锁前)/
|
|
575
|
+
// partial(锁后),并打 cancelled 标记供 UI 区分「取消」与「失败」(D11 共用失败终态)
|
|
576
|
+
if (r.cancelled === true) {
|
|
577
|
+
task.stage = def.name;
|
|
578
|
+
task.message = r.message;
|
|
579
|
+
const postLock = !PRE_LOCK_STAGES.has(def.name);
|
|
580
|
+
task.status = postLock ? "partial" : "failed";
|
|
581
|
+
task.cancelled = true;
|
|
582
|
+
req.outcome = { ...req.outcome, status: task.status, cancelled: true, failedStage: def.name };
|
|
583
|
+
await cancelCleanup(req, lockBefore, lockExisted);
|
|
584
|
+
task.error = { reason: r.message, retryable: true, hintKey: "lifecycle.stage.cancelled" };
|
|
585
|
+
break;
|
|
586
|
+
}
|
|
587
|
+
if (!r.ok) {
|
|
588
|
+
task.stage = def.name;
|
|
589
|
+
task.message = r.message;
|
|
590
|
+
// 失败文案 key(契约附录 A/A.2):带 code(六码)走 code 级 key,否则 stage 级 .hint。
|
|
591
|
+
// reason 走 key:六码把 code 作为 reason(客户端按 locale 取附录 A.2 双语文案),
|
|
592
|
+
// 非六码失败(其它 stage)保留服务端可读串作 fallback(不丢信息)。task.message 仍留中文明细供服务端日志。
|
|
593
|
+
task.error = { reason: r.code ?? r.message, retryable: r.retryable ?? true, hintKey: r.code !== undefined ? `lifecycle.stage.${def.name}.${r.code}` : `lifecycle.stage.${def.name}.hint` };
|
|
594
|
+
if (r.needsTrust === true && r.trust !== undefined) {
|
|
595
|
+
// 第五终态 needs_trust(P2-3,G3):未知 key_id 待 TOFU 确认——预锁段停止,
|
|
596
|
+
// **staging 不清场**(file 源须重传、CDN 源重试自动重下,包体与 .sig 仍在场),
|
|
597
|
+
// 锁/库表/清单/sidecar/审计零改动(失败语义①同口径:什么都没装成就什么都不留);
|
|
598
|
+
// 确认入信任库后原样重试即从 verifying 继续。
|
|
599
|
+
task.status = "needs_trust";
|
|
600
|
+
task.trust = r.trust;
|
|
601
|
+
req.outcome = { ...req.outcome, status: "needs_trust", failedStage: def.name };
|
|
602
|
+
break;
|
|
603
|
+
}
|
|
604
|
+
if (PRE_LOCK_STAGES.has(def.name)) {
|
|
605
|
+
// 失败语义①:锁前清场重跑——staging 删除;锁/库表/清单/sidecar/审计零改动
|
|
606
|
+
task.status = "failed";
|
|
607
|
+
try {
|
|
608
|
+
await fsFace().remove(req.staging, { recursive: true });
|
|
609
|
+
}
|
|
610
|
+
catch {
|
|
611
|
+
// 清场失败不掩盖失败终态(staging 不在锁内,release 装配忽略)
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// 失败语义②:锁后 partial——部分生效,不回滚已落位产物,给回滚指引 key
|
|
616
|
+
task.status = "partial";
|
|
617
|
+
}
|
|
618
|
+
req.outcome = { ...req.outcome, status: task.status, failedStage: def.name };
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
task.progress = Math.round(((i + 1) / STAGES.length) * 100);
|
|
622
|
+
task.message = r.message;
|
|
623
|
+
await writeTask(req.taskDir, task);
|
|
624
|
+
}
|
|
625
|
+
if (task.status === "running") {
|
|
626
|
+
task.status = "done";
|
|
627
|
+
req.outcome = { ...req.outcome, status: "done" };
|
|
628
|
+
}
|
|
629
|
+
if (task.status === "done" || task.status === "partial") {
|
|
630
|
+
// 终态审计(audit 恒末位,进 stages 明细;失败仅日志不阻断)。守卫兜底:审计段自身
|
|
631
|
+
// 抛错(release isolate console 通道 BadResource 等)也转 stage 明细,不击穿终态段——
|
|
632
|
+
// 否则端点 500、任务滞留 running 直到 30min sweep(Finding A)
|
|
633
|
+
try {
|
|
634
|
+
const ar = await AUDIT_DEF.run(req);
|
|
635
|
+
task.stages.push({ stage: AUDIT_DEF.name, ok: ar.ok, message: ar.message });
|
|
636
|
+
}
|
|
637
|
+
catch (e) {
|
|
638
|
+
task.stages.push({ stage: AUDIT_DEF.name, ok: true, message: `终态审计失败仅记日志:${String(e)}` });
|
|
639
|
+
}
|
|
640
|
+
// sidecar 同步(D12 元数据;done/partial 均记录——版本已实际落位,enabled 与锁一致;
|
|
641
|
+
// P2-6:archive 复验真源(sha256+签名记录)随装落,供切换前复验/同版本重装摘要比对;
|
|
642
|
+
// P3-2:history 版本时间序记录(供 GC 保留最近 N=3 + 锁版本保护);写失败元数据滞后,
|
|
643
|
+
// 静默降级不阻断主流程——禁 console,Finding A)
|
|
644
|
+
try {
|
|
645
|
+
const prev = req.state.read().solutions[req.name];
|
|
646
|
+
const installedAt = Date.now();
|
|
647
|
+
const newHistory: Array<{ version: string, installedAt: number }> = [
|
|
648
|
+
{ version: req.version, installedAt },
|
|
649
|
+
...(prev?.history ?? []).filter(h => h.version !== req.version),
|
|
650
|
+
];
|
|
651
|
+
await req.state.write({ solutions: { [req.name]: { version: req.version, enabled: true, installedAt, ...(archiveRecordOf(req.manifest) !== undefined ? { archive: archiveRecordOf(req.manifest), trust_state: "trusted" as const } : {}), history: newHistory } } });
|
|
652
|
+
// P3-2 GC:保留最近 KEEP_VERSIONS 个版本目录(锁版本强制保留),多余清理(运维性动作,静默)
|
|
653
|
+
try {
|
|
654
|
+
await gcVersions(req.sharedRoot, req.name, req.state);
|
|
655
|
+
}
|
|
656
|
+
catch {
|
|
657
|
+
// GC 失败不阻断主流程
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
// 写失败静默:元数据滞后不影响安装主流程(下次操作再收敛)
|
|
662
|
+
}
|
|
663
|
+
// market_history 留痕(P1-15a 缺口 A,history.ts 守卫式:表不存在/写失败静默,不阻断终态)。
|
|
664
|
+
// message 按终态生成人话(task.message 此时是末 stage 文案,对历史消费面不可读)
|
|
665
|
+
await recordHistory({
|
|
666
|
+
solution: req.name,
|
|
667
|
+
version: req.version,
|
|
668
|
+
action: "install",
|
|
669
|
+
status: task.status,
|
|
670
|
+
operator: req.operator ?? null,
|
|
671
|
+
message: task.status === "done" ? `安装完成:${req.name}@${req.version}` : `安装部分生效(status=partial):${req.name}@${req.version}`,
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
// U5 取消安装(P3-8):取消终态也须落终态审计(AC-P1-14 口径——取消/失败都要可观测)——审计末位、
|
|
675
|
+
// market_history 留痕;**不写 sidecar**(锁/目录已回退装前,sidecar 维持装前态,不记录本次未生效安装)。
|
|
676
|
+
if (task.cancelled === true) {
|
|
677
|
+
try {
|
|
678
|
+
const ar = await AUDIT_DEF.run(req);
|
|
679
|
+
task.stages.push({ stage: AUDIT_DEF.name, ok: ar.ok, message: ar.message });
|
|
680
|
+
}
|
|
681
|
+
catch (e) {
|
|
682
|
+
task.stages.push({ stage: AUDIT_DEF.name, ok: true, message: `终态审计失败仅记日志:${String(e)}` });
|
|
683
|
+
}
|
|
684
|
+
await recordHistory({
|
|
685
|
+
solution: req.name,
|
|
686
|
+
version: req.version,
|
|
687
|
+
action: "install",
|
|
688
|
+
status: task.status,
|
|
689
|
+
operator: req.operator ?? null,
|
|
690
|
+
message: `安装已取消(U5 取消安装):${req.name}@${req.version}`,
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (task.status === "done") {
|
|
694
|
+
task.stage = "done";
|
|
695
|
+
task.progress = 100;
|
|
696
|
+
}
|
|
697
|
+
task.finishedAt = Date.now();
|
|
698
|
+
await writeTask(req.taskDir, task);
|
|
699
|
+
}
|
|
700
|
+
finally {
|
|
701
|
+
await lock.release();
|
|
702
|
+
}
|
|
703
|
+
return task;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/**
|
|
707
|
+
* 单条任务查询(§10.2 轮询消费面)。偏离简报签名:oj 的 fs 面是异步的,同步 `getTask(id)`
|
|
708
|
+
* 不可能——加 taskDir 首参(同 state/lock 各读函数的 AsyncFace 惯例)。
|
|
709
|
+
*/
|
|
710
|
+
export function taskOf(taskDir: string, id: string): Promise<TaskRecord | null> {
|
|
711
|
+
return getTask(taskDir, id);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export interface ReconcileInput {
|
|
715
|
+
/** 锁路径(<sharedRoot>/manifests.yaml) */
|
|
716
|
+
lockPath: string
|
|
717
|
+
sharedRoot: string
|
|
718
|
+
taskDir: string
|
|
719
|
+
/** 站点迁移账本 head(P1-16 接线 = oj journal 查询;测试注入) */
|
|
720
|
+
ledgerHeadOf: (name: string) => Promise<number | bigint | null>
|
|
721
|
+
/** 锁指版本要求的最低 head(生产缺省 = 安装任务记录 appliedSeq,见 latestAppliedSeqOf;P1-16 可换 oj journal 推导) */
|
|
722
|
+
headFloorOf: (name: string, version: string) => Promise<number | bigint | null>
|
|
723
|
+
/** sys_solution_route enabled 查询(缺省不查该项) */
|
|
724
|
+
routeEnabledOf?: (name: string) => Promise<boolean | null>
|
|
725
|
+
/** 站点 modules.json 路径(缺省不查该项) */
|
|
726
|
+
modulesJsonPath?: string
|
|
727
|
+
/** sidecar store(注入后 route/modules enabled=false 不一致项自动重跑 setEnabled 修复;缺省只报不修) */
|
|
728
|
+
state?: StateStore
|
|
729
|
+
/** 信任库路径(P3-9 live 信任核对:注入后 reconcile 自愈末位触发一次实时信任库核对,吊销 key 标 untrusted;缺省跳过) */
|
|
730
|
+
trustPath?: string
|
|
731
|
+
now?: number
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
export interface ReconcileReport {
|
|
735
|
+
checked: string[]
|
|
736
|
+
healed: { name: string, action: "repoint" | "remove" | "enable", from: string, to: string | null, reason: string }[]
|
|
737
|
+
inconsistencies: { name: string, kind: "lock_corrupt" | "modules_json" | "route_disabled", detail: string }[]
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
async function dirExists(path: string): Promise<boolean> {
|
|
741
|
+
try {
|
|
742
|
+
await fsFace().readdir(path);
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
return false;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** 对账摘除后的 failed 任务记录(可观测:市场页/CLI 能看到方案为何消失) */
|
|
751
|
+
async function writeReconcileFailed(taskDir: string, name: string, version: string, reason: string): Promise<void> {
|
|
752
|
+
const now = Date.now();
|
|
753
|
+
await writeTask(taskDir, {
|
|
754
|
+
id: `reconcile-${now}-${randomId().slice(0, 8)}`,
|
|
755
|
+
name,
|
|
756
|
+
version,
|
|
757
|
+
status: "failed",
|
|
758
|
+
stage: null,
|
|
759
|
+
progress: 0,
|
|
760
|
+
message: reason,
|
|
761
|
+
stages: [],
|
|
762
|
+
startedAt: now,
|
|
763
|
+
finishedAt: now,
|
|
764
|
+
error: { reason, retryable: true },
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* 入口对账自愈(D11):锁条目 ↔ 账本 head 一致性检查 + 自愈。锁指新版但账本 head 低于该版本
|
|
770
|
+
* 要求的 floor(M004 账本落后拒启形态)→ 有 .bak 且旧版本目录俱在则回指旧版;无旧版可指则摘除
|
|
771
|
+
* 条目并记 failed 任务。modules.json / route enabled 检查报告;注入 state 时对 enabled=false
|
|
772
|
+
* 不一致项自动重跑 setEnabled(true) 四处同写收敛(P1-16 接线④;缺条目 = web 未部署不可自愈,保留报告)。
|
|
773
|
+
* 对账时点顺带执行超时 running 判 failed(R13/B9/R31)。坏锁 fail-closed:只报告不猜。
|
|
774
|
+
*/
|
|
775
|
+
export async function reconcile(input: ReconcileInput): Promise<ReconcileReport> {
|
|
776
|
+
const report: ReconcileReport = { checked: [], healed: [], inconsistencies: [] };
|
|
777
|
+
const now = input.now ?? Date.now();
|
|
778
|
+
await sweepTimedOut(input.taskDir, now);
|
|
779
|
+
let lock: Record<string, string>;
|
|
780
|
+
try {
|
|
781
|
+
lock = await readLock(input.lockPath);
|
|
782
|
+
}
|
|
783
|
+
catch (e) {
|
|
784
|
+
report.inconsistencies.push({ name: "*", kind: "lock_corrupt", detail: `坏锁(fail-closed 不猜):${String(e)}` });
|
|
785
|
+
return report;
|
|
786
|
+
}
|
|
787
|
+
for (const [name, version] of Object.entries(lock)) {
|
|
788
|
+
report.checked.push(name);
|
|
789
|
+
const head = await input.ledgerHeadOf(name);
|
|
790
|
+
const floor = await input.headFloorOf(name, version);
|
|
791
|
+
if (head == null || floor == null || Number(head) >= Number(floor)) {
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
const reason = `账本 head ${head} 低于锁指 ${version} 要求的 seq ${floor}(M004 账本落后形态)——对账自愈`;
|
|
795
|
+
let healed = false;
|
|
796
|
+
try {
|
|
797
|
+
const prev = (await readLock(`${input.lockPath}.bak`))[name];
|
|
798
|
+
if (prev !== undefined && prev !== version && await dirExists(`${input.sharedRoot}/${name}-${prev}`)) {
|
|
799
|
+
const entries = { ...lock, [name]: prev };
|
|
800
|
+
await writeLock(input.lockPath, entries);
|
|
801
|
+
lock = entries;
|
|
802
|
+
report.healed.push({ name, action: "repoint", from: version, to: prev, reason: `${reason}:已回指旧版 ${prev}` });
|
|
803
|
+
healed = true;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
// .bak 不可读视同无旧版可指,走摘除
|
|
808
|
+
}
|
|
809
|
+
if (!healed) {
|
|
810
|
+
const entries = { ...lock };
|
|
811
|
+
delete entries[name];
|
|
812
|
+
if (Object.keys(entries).length === 0) {
|
|
813
|
+
report.inconsistencies.push({ name, kind: "lock_corrupt", detail: `${reason}:但摘除后锁为空(空锁拒启,S1-1.2)——需人工处置` });
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
await writeLock(input.lockPath, entries);
|
|
817
|
+
lock = entries;
|
|
818
|
+
report.healed.push({ name, action: "remove", from: version, to: null, reason: `${reason}:无旧版可指,已摘除` });
|
|
819
|
+
await writeReconcileFailed(input.taskDir, name, version, `${reason}:无旧版可指,已摘除`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
// modules.json 一致性(enabled=false 不一致可经注入 state 自动修复,见下方接线④)
|
|
823
|
+
if (input.modulesJsonPath !== undefined) {
|
|
824
|
+
try {
|
|
825
|
+
const list: unknown = JSON.parse(new TextDecoder().decode(await fsFace().readFile(input.modulesJsonPath)));
|
|
826
|
+
const entries = Array.isArray(list) ? list.filter(isObj) : null;
|
|
827
|
+
if (entries === null) {
|
|
828
|
+
report.inconsistencies.push({ name: "*", kind: "modules_json", detail: "modules.json 顶层不是数组" });
|
|
829
|
+
}
|
|
830
|
+
else {
|
|
831
|
+
for (const name of Object.keys(lock)) {
|
|
832
|
+
const e = entries.find(x => x.name === name);
|
|
833
|
+
if (e === undefined) {
|
|
834
|
+
report.inconsistencies.push({ name, kind: "modules_json", detail: "锁有条目而 modules.json 缺条目(web 侧未部署或被清)" });
|
|
835
|
+
}
|
|
836
|
+
else if (e.enabled === false) {
|
|
837
|
+
report.inconsistencies.push({ name, kind: "modules_json", detail: "锁在而 modules.json enabled=false(三处同写不齐,建议重跑 setEnabled 修复)" });
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
report.inconsistencies.push({ name: "*", kind: "modules_json", detail: "modules.json 不可读或损坏" });
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
// route enabled 一致性(只报不修,同上)
|
|
847
|
+
if (input.routeEnabledOf !== undefined) {
|
|
848
|
+
for (const name of Object.keys(lock)) {
|
|
849
|
+
const en = await input.routeEnabledOf(name).catch(() => null);
|
|
850
|
+
if (en === false) {
|
|
851
|
+
report.inconsistencies.push({ name, kind: "route_disabled", detail: "锁在而 sys_solution_route.enabled=0(建议重跑 setEnabled(true) 修复)" });
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
// route/modules enabled=false 不一致自动修复(P1-16 接线④):注入 state 时重跑 setEnabled(true)
|
|
856
|
+
// 四处同写收敛(锁/sidecar/route/modules)。不可自愈项(缺条目 = web 未部署、坏锁、空锁拒启、
|
|
857
|
+
// 版本目录缺失)保留报告不猜;未注入 state(缺省)维持只报不修。
|
|
858
|
+
if (input.state !== undefined && input.modulesJsonPath !== undefined) {
|
|
859
|
+
const fixable = new Set(report.inconsistencies
|
|
860
|
+
.filter(i => i.name !== "*" && (i.kind === "route_disabled" || (i.kind === "modules_json" && i.detail.includes("enabled=false"))))
|
|
861
|
+
.map(i => i.name));
|
|
862
|
+
for (const name of fixable) {
|
|
863
|
+
if (lock[name] === undefined) {
|
|
864
|
+
continue;
|
|
865
|
+
}
|
|
866
|
+
const r = await setEnabled({ name, enabled: true, sharedRoot: input.sharedRoot, lockPath: input.lockPath, state: input.state, modulesJsonPath: input.modulesJsonPath, now });
|
|
867
|
+
if (!r.ok) {
|
|
868
|
+
continue;
|
|
869
|
+
}
|
|
870
|
+
report.inconsistencies = report.inconsistencies.filter(i => !(i.name === name && (i.kind === "modules_json" || i.kind === "route_disabled")));
|
|
871
|
+
report.healed.push({ name, action: "enable", from: "disabled", to: "enabled", reason: `route/modules enabled 不一致已重跑 setEnabled(true) 收敛:${r.message}` });
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
// P3-9 live 信任核对(P2-6#1,G5 口径):reconcile 自愈末位触发一次实时信任库核对——
|
|
875
|
+
// 各已锁方案 sidecar.archive.key_id 若在信任库被吊销(get 返回 null / 公钥绑定不符),标
|
|
876
|
+
// trust_state="untrusted"(仍启用,存量可用性承诺 G5;显著告警 + 审计 + 深检标 untrusted),与
|
|
877
|
+
// P2-8 深检 live 查询口径一致。无复验真源(P1 时代无 archive 条目)/ 信任库缺失/查询失败 → 静默跳过。
|
|
878
|
+
if (input.trustPath !== undefined && input.state !== undefined) {
|
|
879
|
+
try {
|
|
880
|
+
await liveTrustReconcile(lock, input.sharedRoot, input.trustPath, input.state);
|
|
881
|
+
}
|
|
882
|
+
catch {
|
|
883
|
+
// live 核对失败不阻断 reconcile 主流程(下次刷新重试)
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return report;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* P3-9 live 信任核对(reconcile 末位调用):遍历已锁方案,读 sidecar.archive.key_id 实时查信任库——
|
|
891
|
+
* 吊销(库无该 key / 公钥绑定不符)→ 标 untrusted(G5:放行 + 告警 + 审计 + 深检标 untrusted,
|
|
892
|
+
* 不动已装方案);仍受信且曾被标 untrusted(历史吊销后重加)→ 恢复 trusted。仅更新 sidecar
|
|
893
|
+
* trust_state,不写锁/版本目录/route/modules(与启用期核对职责分离,P2-6)。不可自愈情形静默跳过。
|
|
894
|
+
*/
|
|
895
|
+
async function liveTrustReconcile(lock: Record<string, string>, sharedRoot: string, trustPath: string, state: StateStore): Promise<void> {
|
|
896
|
+
const store = createTrustStore(trustPath, { audit: () => {} });
|
|
897
|
+
for (const name of Object.keys(lock)) {
|
|
898
|
+
const entry = state.read().solutions[name];
|
|
899
|
+
const arch = entry?.archive;
|
|
900
|
+
if (!isObj(arch) || typeof arch.key_id !== "string") {
|
|
901
|
+
continue; // 无复验真源(P1 时代无 archive 条目)→ 不核对
|
|
902
|
+
}
|
|
903
|
+
let trusted: string | null = null;
|
|
904
|
+
try {
|
|
905
|
+
trusted = await store.get(arch.key_id);
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
continue; // 信任库查询失败(损坏等)→ 跳过该方案(下次对账重试)
|
|
909
|
+
}
|
|
910
|
+
const bound = isObj(arch.signature) && typeof arch.signature.public_key === "string" ? arch.signature.public_key : null;
|
|
911
|
+
const stillTrusted = trusted !== null && (bound === null || trusted === bound);
|
|
912
|
+
if (stillTrusted) {
|
|
913
|
+
if (entry.trust_state === "untrusted") {
|
|
914
|
+
try {
|
|
915
|
+
await state.write({ solutions: { [name]: { ...entry, trust_state: "trusted" } } });
|
|
916
|
+
}
|
|
917
|
+
catch {
|
|
918
|
+
// 写失败静默(下次对账重试)
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
continue;
|
|
922
|
+
}
|
|
923
|
+
// 已吊销:标 untrusted(G5 口径:存量可用性承诺,放行 + 显著告警 + 审计)
|
|
924
|
+
if (entry.trust_state !== "untrusted") {
|
|
925
|
+
try {
|
|
926
|
+
await state.write({ solutions: { [name]: { ...entry, trust_state: "untrusted" } } });
|
|
927
|
+
auditOp({ action: "solution.trust.recheck", module: name, target: name, detail: { key_id: arch.key_id, status: "untrusted", reason: "reconcile live 核对:发布方 key 已不在信任库(已吊销)" }, level: 4 });
|
|
928
|
+
}
|
|
929
|
+
catch {
|
|
930
|
+
// 写失败静默(下次对账重试)
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export interface SetEnabledInput {
|
|
937
|
+
name: string
|
|
938
|
+
enabled: boolean
|
|
939
|
+
/** 共享根(版本目录父级,启用恢复时校验版本目录仍在) */
|
|
940
|
+
sharedRoot: string
|
|
941
|
+
/** 锁路径(<sharedRoot>/manifests.yaml) */
|
|
942
|
+
lockPath: string
|
|
943
|
+
state: StateStore
|
|
944
|
+
/** 站点 modules.json 路径 */
|
|
945
|
+
modulesJsonPath: string
|
|
946
|
+
/** 宿主信任库路径(P2-6 切换前复验:启用时核对发布方 key 是否仍受信;缺省跳过核对) */
|
|
947
|
+
trustPath?: string
|
|
948
|
+
/** 操作人(历史留痕;端点按 http.user 反查注入,缺省 null) */
|
|
949
|
+
operator?: string
|
|
950
|
+
now?: number
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
/**
|
|
954
|
+
* 禁用/启用四处同写(D12/AC-P1-6):①锁摘除/恢复(禁用 = 从锁摘条目——目录保留,oj 按锁逐条
|
|
955
|
+
* 装配,摘除即不挂载;启用 = 从 sidecar 摘除前记录恢复版本目录)→ ②sidecar enabled →
|
|
956
|
+
* ③sys_solution_route.enabled → ④modules.json enabled。锁改写走 writeLock(.bak 备份);
|
|
957
|
+
* 摘除后锁为空拒绝(空锁拒启,S1-1.2)。生命周期开关操作不违 D10(D10 禁的是注册内容直写)。
|
|
958
|
+
* P2-6 切换前复验(D11③/G5 调和):启用(原版本恢复)不重新取包——无完整验签可做;改为核对
|
|
959
|
+
* sidecar.archive 的发布方 key 是否仍受信:已吊销 → **放行 + 显著告警 + 审计 + sidecar 标
|
|
960
|
+
* untrusted**(存量可用性承诺);P1 时代条目无 archive → 放行 +「无验签真源」告警。升级/回滚
|
|
961
|
+
* 走完整安装验签(P2-3 verifying 段,吊销即 needs_trust 拒)。
|
|
962
|
+
*/
|
|
963
|
+
export async function setEnabled(input: SetEnabledInput): Promise<{ ok: boolean, message: string }> {
|
|
964
|
+
const { name, enabled } = input;
|
|
965
|
+
const now = input.now ?? Date.now();
|
|
966
|
+
const f = fsFace();
|
|
967
|
+
const side = input.state.read().solutions[name];
|
|
968
|
+
// P2-6 启用发布方状态核对(切前复验的轻量形态:不取包,只查信任库)
|
|
969
|
+
let trustNote: string | null = null;
|
|
970
|
+
let trustState: "trusted" | "untrusted" | "absent" | undefined;
|
|
971
|
+
const audit = (ok: boolean, detail: string): void => {
|
|
972
|
+
auditOp({ action: enabled ? "solution.enable" : "solution.disable", module: name, target: name, detail: { ok, detail, ...(trustNote !== null ? { trust: trustNote } : {}) }, level: ok ? 6 : 4 });
|
|
973
|
+
};
|
|
974
|
+
// ① 锁摘除/恢复
|
|
975
|
+
const lock = await readLock(input.lockPath);
|
|
976
|
+
let version: string;
|
|
977
|
+
if (enabled) {
|
|
978
|
+
if (side === undefined) {
|
|
979
|
+
const msg = `启用失败:sidecar 无「${name}」记录(从未安装成功或元数据丢失),无可恢复版本`;
|
|
980
|
+
audit(false, msg);
|
|
981
|
+
return { ok: false, message: msg };
|
|
982
|
+
}
|
|
983
|
+
if (!await dirExists(`${input.sharedRoot}/${name}-${side.version}`)) {
|
|
984
|
+
const msg = `启用失败:版本目录 ${name}-${side.version} 缺失(已卸载?),无可恢复版本`;
|
|
985
|
+
audit(false, msg);
|
|
986
|
+
return { ok: false, message: msg };
|
|
987
|
+
}
|
|
988
|
+
if (side.archive === undefined) {
|
|
989
|
+
// P1 时代安装(P2 前):无复验真源——放行但告警(升级走完整验签由安装路径保证)
|
|
990
|
+
trustNote = "无验签真源(P2 之前的旧版安装)——启用放行,但无法核验发布方状态,建议升级重装";
|
|
991
|
+
trustState = "absent";
|
|
992
|
+
}
|
|
993
|
+
else if (input.trustPath !== undefined && input.trustPath !== "") {
|
|
994
|
+
const trusted = await createTrustStore(input.trustPath).get(side.archive.key_id);
|
|
995
|
+
const bound = isObj(side.archive.signature) && typeof side.archive.signature.public_key === "string" ? side.archive.signature.public_key : null;
|
|
996
|
+
if (trusted === null || (bound !== null && trusted !== bound)) {
|
|
997
|
+
trustNote = `发布方「${side.archive.key_id}」已不在信任库(已吊销或换绑)——启用放行(G5:吊销不动已装方案),但该版本发布方状态不可信,建议升级到新签名版本`;
|
|
998
|
+
trustState = "untrusted";
|
|
999
|
+
}
|
|
1000
|
+
else {
|
|
1001
|
+
trustState = "trusted";
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
version = side.version;
|
|
1005
|
+
lock[name] = version;
|
|
1006
|
+
}
|
|
1007
|
+
else {
|
|
1008
|
+
const v = lock[name] ?? side?.version;
|
|
1009
|
+
if (v === undefined) {
|
|
1010
|
+
const msg = `禁用失败:「${name}」未安装`;
|
|
1011
|
+
audit(false, msg);
|
|
1012
|
+
return { ok: false, message: msg };
|
|
1013
|
+
}
|
|
1014
|
+
version = v;
|
|
1015
|
+
delete lock[name];
|
|
1016
|
+
if (Object.keys(lock).length === 0) {
|
|
1017
|
+
const msg = "禁用失败:摘除后锁为空(空锁拒启,S1-1.2)——站点至少保留一个已锁模块";
|
|
1018
|
+
audit(false, msg);
|
|
1019
|
+
return { ok: false, message: msg };
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
try {
|
|
1023
|
+
await writeLock(input.lockPath, lock);
|
|
1024
|
+
}
|
|
1025
|
+
catch (e) {
|
|
1026
|
+
audit(false, `锁写失败:${String(e)}`);
|
|
1027
|
+
return { ok: false, message: `禁用/启用失败:锁写失败(${String(e)})——四处同写中止,状态未变` };
|
|
1028
|
+
}
|
|
1029
|
+
// ② sidecar(version 保留摘除前记录,启用从它恢复)
|
|
1030
|
+
// P2-6:archive 复验真源随启用保留(install 时落;P1 时代条目无此字段),trust_state 按发布方
|
|
1031
|
+
// key 信任状态刷新(吊销后启用 = 放行 + untrusted,存量可用性承诺 G5)。
|
|
1032
|
+
// P3-2:history 版本时间序记录随启用保留(GC 依据不因启用/禁用丢失)。
|
|
1033
|
+
try {
|
|
1034
|
+
await input.state.write({ solutions: { [name]: { version, enabled, installedAt: side?.installedAt ?? now, ...(side?.archive !== undefined ? { archive: side.archive } : {}), ...(trustState !== undefined ? { trust_state: trustState } : {}), ...(side?.history !== undefined ? { history: side.history } : {}) } } });
|
|
1035
|
+
}
|
|
1036
|
+
catch (e) {
|
|
1037
|
+
audit(false, `sidecar 写失败:${String(e)}`);
|
|
1038
|
+
return { ok: false, message: `禁用/启用失败:sidecar 写失败(${String(e)})——锁已更新,元数据滞后` };
|
|
1039
|
+
}
|
|
1040
|
+
// ③ route 表 enabled(生命周期开关,非注册内容直写,D10 不适用)
|
|
1041
|
+
try {
|
|
1042
|
+
await db.exec("update sys_solution_route set enabled = ? where module = ?", [enabled ? 1 : 0, name]);
|
|
1043
|
+
}
|
|
1044
|
+
catch (e) {
|
|
1045
|
+
audit(false, `route enabled 写失败:${String(e)}`);
|
|
1046
|
+
return { ok: false, message: `禁用/启用失败:sys_solution_route 写失败(${String(e)})——锁与 sidecar 已更新,路由开关滞后` };
|
|
1047
|
+
}
|
|
1048
|
+
// ④ modules.json enabled(条目不存在 = api-only 方案,该项天然一致)
|
|
1049
|
+
try {
|
|
1050
|
+
const list = JSON.parse(new TextDecoder().decode(await f.readFile(input.modulesJsonPath))) as unknown[];
|
|
1051
|
+
if (!Array.isArray(list)) {
|
|
1052
|
+
throw new TypeError("modules.json 顶层不是数组");
|
|
1053
|
+
}
|
|
1054
|
+
let hit = false;
|
|
1055
|
+
const next = list.map(e => (isObj(e) && e.name === name) ? ((hit = true), { ...e, enabled }) : e);
|
|
1056
|
+
if (hit) {
|
|
1057
|
+
await atomicWriteText(input.modulesJsonPath, `${JSON.stringify(next, null, 2)}\n`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
catch (e) {
|
|
1061
|
+
audit(false, `modules.json 写失败:${String(e)}`);
|
|
1062
|
+
return { ok: false, message: `禁用/启用失败:modules.json 写失败(${String(e)})——锁/sidecar/route 已更新,前端开关滞后` };
|
|
1063
|
+
}
|
|
1064
|
+
const msg = `${enabled ? "启用" : "禁用"}完成:锁${enabled ? "恢复" : "摘除"} + sidecar + sys_solution_route.enabled + modules.json enabled 四处同写(D12/AC-P1-6)${trustNote !== null ? `;⚠ ${trustNote}` : ""}`;
|
|
1065
|
+
audit(true, msg);
|
|
1066
|
+
// market_history 留痕(P1-15a 缺口 A,history.ts 守卫式:表不存在/写失败静默,不阻断开关结果)
|
|
1067
|
+
await recordHistory({ solution: name, version, action: enabled ? "enable" : "disable", status: "success", operator: input.operator ?? null, message: msg });
|
|
1068
|
+
return { ok: true, message: msg };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
/**
|
|
1072
|
+
* 启用恢复的 floor 依据:安装任务记录里该 name@version 最近一次成功(done/partial)的 appliedSeq
|
|
1073
|
+
* (reconcile headFloorOf 的生产缺省实现;P1-16 可换 oj journal 推导)。
|
|
1074
|
+
*/
|
|
1075
|
+
export async function latestAppliedSeqOf(taskDir: string, name: string, version: string): Promise<number | null> {
|
|
1076
|
+
const hits = (await readTasks(taskDir))
|
|
1077
|
+
.filter(t => t.name === name && t.version === version && t.appliedSeq != null && (t.status === "done" || t.status === "partial"))
|
|
1078
|
+
.sort((a, b) => b.startedAt - a.startedAt);
|
|
1079
|
+
return hits[0]?.appliedSeq ?? null;
|
|
1080
|
+
}
|