@marktrue/dsh-mobile-remote 3.2.1-alpha.1-app → 3.2.1-alpha.1
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/CHANGELOG.md +796 -0
- package/CONTRIBUTING.md +59 -0
- package/FAQ.md +111 -0
- package/README.md +253 -6
- package/cordis.patch.yml +14 -0
- package/docs/00-/345/274/200/345/217/221/346/200/273/347/272/262.md +122 -0
- package/docs/01-PRD.md +148 -0
- package/docs/02-architecture.md +249 -0
- package/docs/03-api.md +816 -0
- package/docs/04-security.md +96 -0
- package/docs/05-test-cases.md +326 -0
- package/docs/06-install-run.md +308 -0
- package/docs/07-user-manual.md +65 -0
- package/docs/08-/346/211/251/345/261/225/345/274/200/345/217/221/346/214/207/345/215/227.md +86 -0
- package/docs/09-compatibility.md +195 -0
- package/docs/10-release.md +927 -0
- package/docs/screenshots/01-home.png +0 -0
- package/docs/screenshots/02-notifications.png +0 -0
- package/docs/screenshots/03-sessions.png +0 -0
- package/docs/screenshots/04-chat.png +0 -0
- package/docs/screenshots/05-settings.png +0 -0
- package/docs/screenshots/06-new-session.png +0 -0
- package/docs/screenshots/pgy-appstore.jpg +0 -0
- package/docs/screenshots/pgy-mobile-app.jpg +0 -0
- package/docs/screenshots/pgy-pc-client.jpg +0 -0
- package/lib/account-usage.js +145 -0
- package/lib/app-alignment.js +112 -0
- package/lib/app-carrier-store.js +1972 -0
- package/lib/app-carrier.js +602 -0
- package/lib/client.js +706 -0
- package/lib/desktop-form.js +738 -0
- package/lib/endpoint-registry.js +162 -0
- package/lib/git-read-bundled-provider.js +255 -0
- package/lib/git-read-service.js +322 -0
- package/lib/index.js +9292 -0
- package/lib/plugin-content-digest.js +80 -0
- package/lib/plugin-self-update.js +758 -0
- package/lib/plugin-update-controller.js +95 -0
- package/lib/plugin-update-transaction.js +322 -0
- package/lib/plugin-update.js +309 -0
- package/lib/runtime-dependency-snapshot.js +345 -0
- package/lib/session-title-cache.js +123 -0
- package/lib/session-title-refresh.js +261 -0
- package/lib/update-fetch.js +24 -0
- package/lib/update-lock-arbiter.js +158 -0
- package/lib/update-supervisor.js +312 -0
- package/node_modules/ansi-regex/index.d.ts +37 -0
- package/node_modules/ansi-regex/index.js +10 -0
- package/node_modules/ansi-regex/license +9 -0
- package/node_modules/ansi-regex/package.json +55 -0
- package/node_modules/ansi-regex/readme.md +78 -0
- package/node_modules/ansi-styles/index.d.ts +345 -0
- package/node_modules/ansi-styles/index.js +163 -0
- package/node_modules/ansi-styles/license +9 -0
- package/node_modules/ansi-styles/package.json +56 -0
- package/node_modules/ansi-styles/readme.md +152 -0
- package/node_modules/camelcase/index.d.ts +63 -0
- package/node_modules/camelcase/index.js +76 -0
- package/node_modules/camelcase/license +9 -0
- package/node_modules/camelcase/package.json +43 -0
- package/node_modules/camelcase/readme.md +99 -0
- package/node_modules/cliui/CHANGELOG.md +76 -0
- package/node_modules/cliui/LICENSE.txt +14 -0
- package/node_modules/cliui/README.md +115 -0
- package/node_modules/cliui/index.js +354 -0
- package/node_modules/cliui/package.json +65 -0
- package/node_modules/color-convert/CHANGELOG.md +54 -0
- package/node_modules/color-convert/LICENSE +21 -0
- package/node_modules/color-convert/README.md +68 -0
- package/node_modules/color-convert/conversions.js +839 -0
- package/node_modules/color-convert/index.js +81 -0
- package/node_modules/color-convert/package.json +48 -0
- package/node_modules/color-convert/route.js +97 -0
- package/node_modules/color-name/LICENSE +8 -0
- package/node_modules/color-name/README.md +11 -0
- package/node_modules/color-name/index.js +152 -0
- package/node_modules/color-name/package.json +28 -0
- package/node_modules/decamelize/index.js +13 -0
- package/node_modules/decamelize/license +21 -0
- package/node_modules/decamelize/package.json +38 -0
- package/node_modules/decamelize/readme.md +48 -0
- package/node_modules/dijkstrajs/.travis.yml +4 -0
- package/node_modules/dijkstrajs/CONTRIBUTING.md +8 -0
- package/node_modules/dijkstrajs/LICENSE.md +19 -0
- package/node_modules/dijkstrajs/README.md +18 -0
- package/node_modules/dijkstrajs/dijkstra.js +165 -0
- package/node_modules/dijkstrajs/package.json +60 -0
- package/node_modules/dijkstrajs/test/dijkstra.test.js +96 -0
- package/node_modules/emoji-regex/LICENSE-MIT.txt +20 -0
- package/node_modules/emoji-regex/README.md +73 -0
- package/node_modules/emoji-regex/es2015/index.js +6 -0
- package/node_modules/emoji-regex/es2015/text.js +6 -0
- package/node_modules/emoji-regex/index.d.ts +23 -0
- package/node_modules/emoji-regex/index.js +6 -0
- package/node_modules/emoji-regex/package.json +50 -0
- package/node_modules/emoji-regex/text.js +6 -0
- package/node_modules/find-up/index.d.ts +137 -0
- package/node_modules/find-up/index.js +89 -0
- package/node_modules/find-up/license +9 -0
- package/node_modules/find-up/package.json +53 -0
- package/node_modules/find-up/readme.md +156 -0
- package/node_modules/get-caller-file/LICENSE.md +6 -0
- package/node_modules/get-caller-file/README.md +41 -0
- package/node_modules/get-caller-file/index.d.ts +2 -0
- package/node_modules/get-caller-file/index.js +22 -0
- package/node_modules/get-caller-file/index.js.map +1 -0
- package/node_modules/get-caller-file/package.json +42 -0
- package/node_modules/is-fullwidth-code-point/index.d.ts +17 -0
- package/node_modules/is-fullwidth-code-point/index.js +50 -0
- package/node_modules/is-fullwidth-code-point/license +9 -0
- package/node_modules/is-fullwidth-code-point/package.json +42 -0
- package/node_modules/is-fullwidth-code-point/readme.md +39 -0
- package/node_modules/locate-path/index.d.ts +83 -0
- package/node_modules/locate-path/index.js +65 -0
- package/node_modules/locate-path/license +9 -0
- package/node_modules/locate-path/package.json +45 -0
- package/node_modules/locate-path/readme.md +122 -0
- package/node_modules/p-limit/index.d.ts +38 -0
- package/node_modules/p-limit/index.js +57 -0
- package/node_modules/p-limit/license +9 -0
- package/node_modules/p-limit/package.json +52 -0
- package/node_modules/p-limit/readme.md +101 -0
- package/node_modules/p-locate/index.d.ts +64 -0
- package/node_modules/p-locate/index.js +52 -0
- package/node_modules/p-locate/license +9 -0
- package/node_modules/p-locate/package.json +53 -0
- package/node_modules/p-locate/readme.md +90 -0
- package/node_modules/p-try/index.d.ts +39 -0
- package/node_modules/p-try/index.js +9 -0
- package/node_modules/p-try/license +9 -0
- package/node_modules/p-try/package.json +42 -0
- package/node_modules/p-try/readme.md +58 -0
- package/node_modules/path-exists/index.d.ts +28 -0
- package/node_modules/path-exists/index.js +23 -0
- package/node_modules/path-exists/license +9 -0
- package/node_modules/path-exists/package.json +39 -0
- package/node_modules/path-exists/readme.md +52 -0
- package/node_modules/pngjs/.eslintignore +2 -0
- package/node_modules/pngjs/.eslintrc.json +19 -0
- package/node_modules/pngjs/.prettierignore +3 -0
- package/node_modules/pngjs/LICENSE +20 -0
- package/node_modules/pngjs/README.md +429 -0
- package/node_modules/pngjs/browser.js +16096 -0
- package/node_modules/pngjs/coverage/lcov-report/base.css +354 -0
- package/node_modules/pngjs/coverage/lcov-report/bitmapper.js.html +881 -0
- package/node_modules/pngjs/coverage/lcov-report/bitpacker.js.html +554 -0
- package/node_modules/pngjs/coverage/lcov-report/block-navigation.js +77 -0
- package/node_modules/pngjs/coverage/lcov-report/chunkstream.js.html +647 -0
- package/node_modules/pngjs/coverage/lcov-report/constants.js.html +176 -0
- package/node_modules/pngjs/coverage/lcov-report/crc.js.html +200 -0
- package/node_modules/pngjs/coverage/lcov-report/favicon.png +0 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-pack.js.html +593 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse-async.js.html +152 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse-sync.js.html +143 -0
- package/node_modules/pngjs/coverage/lcov-report/filter-parse.js.html +611 -0
- package/node_modules/pngjs/coverage/lcov-report/format-normaliser.js.html +359 -0
- package/node_modules/pngjs/coverage/lcov-report/index.html +426 -0
- package/node_modules/pngjs/coverage/lcov-report/interlace.js.html +365 -0
- package/node_modules/pngjs/coverage/lcov-report/packer-async.js.html +230 -0
- package/node_modules/pngjs/coverage/lcov-report/packer-sync.js.html +248 -0
- package/node_modules/pngjs/coverage/lcov-report/packer.js.html +467 -0
- package/node_modules/pngjs/coverage/lcov-report/paeth-predictor.js.html +128 -0
- package/node_modules/pngjs/coverage/lcov-report/parser-async.js.html +575 -0
- package/node_modules/pngjs/coverage/lcov-report/parser-sync.js.html +404 -0
- package/node_modules/pngjs/coverage/lcov-report/parser.js.html +950 -0
- package/node_modules/pngjs/coverage/lcov-report/png-sync.js.html +116 -0
- package/node_modules/pngjs/coverage/lcov-report/png.js.html +662 -0
- package/node_modules/pngjs/coverage/lcov-report/prettify.css +101 -0
- package/node_modules/pngjs/coverage/lcov-report/prettify.js +1007 -0
- package/node_modules/pngjs/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/node_modules/pngjs/coverage/lcov-report/sorter.js +169 -0
- package/node_modules/pngjs/coverage/lcov-report/sync-inflate.js.html +584 -0
- package/node_modules/pngjs/coverage/lcov-report/sync-reader.js.html +215 -0
- package/node_modules/pngjs/coverage/lcov.info +2177 -0
- package/node_modules/pngjs/lib/bitmapper.js +267 -0
- package/node_modules/pngjs/lib/bitpacker.js +158 -0
- package/node_modules/pngjs/lib/chunkstream.js +189 -0
- package/node_modules/pngjs/lib/constants.js +32 -0
- package/node_modules/pngjs/lib/crc.js +40 -0
- package/node_modules/pngjs/lib/filter-pack.js +171 -0
- package/node_modules/pngjs/lib/filter-parse-async.js +24 -0
- package/node_modules/pngjs/lib/filter-parse-sync.js +21 -0
- package/node_modules/pngjs/lib/filter-parse.js +177 -0
- package/node_modules/pngjs/lib/format-normaliser.js +93 -0
- package/node_modules/pngjs/lib/interlace.js +95 -0
- package/node_modules/pngjs/lib/packer-async.js +50 -0
- package/node_modules/pngjs/lib/packer-sync.js +56 -0
- package/node_modules/pngjs/lib/packer.js +129 -0
- package/node_modules/pngjs/lib/paeth-predictor.js +16 -0
- package/node_modules/pngjs/lib/parser-async.js +165 -0
- package/node_modules/pngjs/lib/parser-sync.js +108 -0
- package/node_modules/pngjs/lib/parser.js +290 -0
- package/node_modules/pngjs/lib/png-sync.js +12 -0
- package/node_modules/pngjs/lib/png.js +194 -0
- package/node_modules/pngjs/lib/sync-inflate.js +168 -0
- package/node_modules/pngjs/lib/sync-reader.js +45 -0
- package/node_modules/pngjs/package.json +73 -0
- package/node_modules/qrcode/README.md +770 -0
- package/node_modules/qrcode/bin/qrcode +159 -0
- package/node_modules/qrcode/helper/to-sjis-browser.js +2 -0
- package/node_modules/qrcode/helper/to-sjis.js +105 -0
- package/node_modules/qrcode/lib/browser.js +76 -0
- package/node_modules/qrcode/lib/can-promise.js +7 -0
- package/node_modules/qrcode/lib/core/alignment-pattern.js +83 -0
- package/node_modules/qrcode/lib/core/alphanumeric-data.js +59 -0
- package/node_modules/qrcode/lib/core/bit-buffer.js +37 -0
- package/node_modules/qrcode/lib/core/bit-matrix.js +65 -0
- package/node_modules/qrcode/lib/core/byte-data.js +30 -0
- package/node_modules/qrcode/lib/core/error-correction-code.js +135 -0
- package/node_modules/qrcode/lib/core/error-correction-level.js +50 -0
- package/node_modules/qrcode/lib/core/finder-pattern.js +22 -0
- package/node_modules/qrcode/lib/core/format-info.js +29 -0
- package/node_modules/qrcode/lib/core/galois-field.js +69 -0
- package/node_modules/qrcode/lib/core/kanji-data.js +54 -0
- package/node_modules/qrcode/lib/core/mask-pattern.js +234 -0
- package/node_modules/qrcode/lib/core/mode.js +167 -0
- package/node_modules/qrcode/lib/core/numeric-data.js +43 -0
- package/node_modules/qrcode/lib/core/polynomial.js +62 -0
- package/node_modules/qrcode/lib/core/qrcode.js +495 -0
- package/node_modules/qrcode/lib/core/reed-solomon-encoder.js +56 -0
- package/node_modules/qrcode/lib/core/regex.js +31 -0
- package/node_modules/qrcode/lib/core/segments.js +330 -0
- package/node_modules/qrcode/lib/core/utils.js +63 -0
- package/node_modules/qrcode/lib/core/version-check.js +9 -0
- package/node_modules/qrcode/lib/core/version.js +163 -0
- package/node_modules/qrcode/lib/index.js +12 -0
- package/node_modules/qrcode/lib/renderer/canvas.js +63 -0
- package/node_modules/qrcode/lib/renderer/png.js +78 -0
- package/node_modules/qrcode/lib/renderer/svg-tag.js +81 -0
- package/node_modules/qrcode/lib/renderer/svg.js +19 -0
- package/node_modules/qrcode/lib/renderer/terminal/terminal-small.js +85 -0
- package/node_modules/qrcode/lib/renderer/terminal/terminal.js +49 -0
- package/node_modules/qrcode/lib/renderer/terminal.js +9 -0
- package/node_modules/qrcode/lib/renderer/utf8.js +71 -0
- package/node_modules/qrcode/lib/renderer/utils.js +99 -0
- package/node_modules/qrcode/lib/server.js +138 -0
- package/node_modules/qrcode/license +10 -0
- package/node_modules/qrcode/package.json +77 -0
- package/node_modules/require-directory/.jshintrc +67 -0
- package/node_modules/require-directory/.npmignore +1 -0
- package/node_modules/require-directory/.travis.yml +3 -0
- package/node_modules/require-directory/LICENSE +22 -0
- package/node_modules/require-directory/README.markdown +184 -0
- package/node_modules/require-directory/index.js +86 -0
- package/node_modules/require-directory/package.json +40 -0
- package/node_modules/require-main-filename/CHANGELOG.md +26 -0
- package/node_modules/require-main-filename/LICENSE.txt +14 -0
- package/node_modules/require-main-filename/README.md +26 -0
- package/node_modules/require-main-filename/index.js +18 -0
- package/node_modules/require-main-filename/package.json +35 -0
- package/node_modules/set-blocking/CHANGELOG.md +26 -0
- package/node_modules/set-blocking/LICENSE.txt +14 -0
- package/node_modules/set-blocking/README.md +31 -0
- package/node_modules/set-blocking/index.js +7 -0
- package/node_modules/set-blocking/package.json +42 -0
- package/node_modules/string-width/index.d.ts +29 -0
- package/node_modules/string-width/index.js +47 -0
- package/node_modules/string-width/license +9 -0
- package/node_modules/string-width/package.json +56 -0
- package/node_modules/string-width/readme.md +50 -0
- package/node_modules/strip-ansi/index.d.ts +17 -0
- package/node_modules/strip-ansi/index.js +4 -0
- package/node_modules/strip-ansi/license +9 -0
- package/node_modules/strip-ansi/package.json +54 -0
- package/node_modules/strip-ansi/readme.md +46 -0
- package/node_modules/which-module/LICENSE +13 -0
- package/node_modules/which-module/README.md +58 -0
- package/node_modules/which-module/index.js +9 -0
- package/node_modules/which-module/package.json +41 -0
- package/node_modules/wrap-ansi/index.js +186 -0
- package/node_modules/wrap-ansi/license +9 -0
- package/node_modules/wrap-ansi/package.json +61 -0
- package/node_modules/wrap-ansi/readme.md +97 -0
- package/node_modules/y18n/CHANGELOG.md +35 -0
- package/node_modules/y18n/LICENSE +13 -0
- package/node_modules/y18n/README.md +109 -0
- package/node_modules/y18n/index.js +188 -0
- package/node_modules/y18n/package.json +39 -0
- package/node_modules/yargs/CHANGELOG.md +420 -0
- package/node_modules/yargs/LICENSE +21 -0
- package/node_modules/yargs/README.md +140 -0
- package/node_modules/yargs/build/lib/apply-extends.d.ts +2 -0
- package/node_modules/yargs/build/lib/apply-extends.js +65 -0
- package/node_modules/yargs/build/lib/argsert.d.ts +2 -0
- package/node_modules/yargs/build/lib/argsert.js +65 -0
- package/node_modules/yargs/build/lib/command.d.ts +64 -0
- package/node_modules/yargs/build/lib/command.js +416 -0
- package/node_modules/yargs/build/lib/common-types.d.ts +36 -0
- package/node_modules/yargs/build/lib/common-types.js +25 -0
- package/node_modules/yargs/build/lib/completion-templates.d.ts +2 -0
- package/node_modules/yargs/build/lib/completion-templates.js +50 -0
- package/node_modules/yargs/build/lib/completion.d.ts +21 -0
- package/node_modules/yargs/build/lib/completion.js +135 -0
- package/node_modules/yargs/build/lib/is-promise.d.ts +1 -0
- package/node_modules/yargs/build/lib/is-promise.js +9 -0
- package/node_modules/yargs/build/lib/levenshtein.d.ts +1 -0
- package/node_modules/yargs/build/lib/levenshtein.js +58 -0
- package/node_modules/yargs/build/lib/middleware.d.ts +10 -0
- package/node_modules/yargs/build/lib/middleware.js +57 -0
- package/node_modules/yargs/build/lib/obj-filter.d.ts +1 -0
- package/node_modules/yargs/build/lib/obj-filter.js +14 -0
- package/node_modules/yargs/build/lib/parse-command.d.ts +11 -0
- package/node_modules/yargs/build/lib/parse-command.js +36 -0
- package/node_modules/yargs/build/lib/process-argv.d.ts +2 -0
- package/node_modules/yargs/build/lib/process-argv.js +31 -0
- package/node_modules/yargs/build/lib/usage.d.ts +49 -0
- package/node_modules/yargs/build/lib/usage.js +540 -0
- package/node_modules/yargs/build/lib/validation.d.ts +34 -0
- package/node_modules/yargs/build/lib/validation.js +330 -0
- package/node_modules/yargs/build/lib/yargs.d.ts +274 -0
- package/node_modules/yargs/build/lib/yargs.js +1190 -0
- package/node_modules/yargs/build/lib/yerror.d.ts +4 -0
- package/node_modules/yargs/build/lib/yerror.js +11 -0
- package/node_modules/yargs/index.js +40 -0
- package/node_modules/yargs/locales/be.json +46 -0
- package/node_modules/yargs/locales/de.json +46 -0
- package/node_modules/yargs/locales/en.json +51 -0
- package/node_modules/yargs/locales/es.json +46 -0
- package/node_modules/yargs/locales/fi.json +49 -0
- package/node_modules/yargs/locales/fr.json +53 -0
- package/node_modules/yargs/locales/hi.json +49 -0
- package/node_modules/yargs/locales/hu.json +46 -0
- package/node_modules/yargs/locales/id.json +50 -0
- package/node_modules/yargs/locales/it.json +46 -0
- package/node_modules/yargs/locales/ja.json +51 -0
- package/node_modules/yargs/locales/ko.json +49 -0
- package/node_modules/yargs/locales/nb.json +44 -0
- package/node_modules/yargs/locales/nl.json +49 -0
- package/node_modules/yargs/locales/nn.json +44 -0
- package/node_modules/yargs/locales/pirate.json +13 -0
- package/node_modules/yargs/locales/pl.json +49 -0
- package/node_modules/yargs/locales/pt.json +45 -0
- package/node_modules/yargs/locales/pt_BR.json +48 -0
- package/node_modules/yargs/locales/ru.json +46 -0
- package/node_modules/yargs/locales/th.json +46 -0
- package/node_modules/yargs/locales/tr.json +48 -0
- package/node_modules/yargs/locales/zh_CN.json +48 -0
- package/node_modules/yargs/locales/zh_TW.json +47 -0
- package/node_modules/yargs/package.json +92 -0
- package/node_modules/yargs/yargs.js +14 -0
- package/node_modules/yargs-parser/CHANGELOG.md +601 -0
- package/node_modules/yargs-parser/LICENSE.txt +14 -0
- package/node_modules/yargs-parser/README.md +449 -0
- package/node_modules/yargs-parser/index.js +1032 -0
- package/node_modules/yargs-parser/lib/tokenize-arg-string.js +40 -0
- package/node_modules/yargs-parser/package.json +46 -0
- package/package.json +65 -10
- package/DSH-Remote-v3.2.1.apk +0 -4
- package/manifest.json +0 -21
|
@@ -0,0 +1,1972 @@
|
|
|
1
|
+
// App 载体托管区(设计稿 §5.4「目录、所有权与发布协议」、§5.8「保留、事务引用与回收」、
|
|
2
|
+
// §5.3/§10.1 的 R2/R3/R5)。
|
|
3
|
+
//
|
|
4
|
+
// 这个 module 是**唯一**被允许写 `<effectiveUpdateDir>/.app-carrier/` 的地方:自动导入、
|
|
5
|
+
// 发布记录、profile 当前指针、事务租约、暂存区与 GC 都走同一把提交锁与同一个提交协议,
|
|
6
|
+
// 避免并发导入/发布/GC 留下"可服务的半成品"。
|
|
7
|
+
//
|
|
8
|
+
// 契约要点(对外行为,不是实现细节):
|
|
9
|
+
// 1. **所有权与布局版本**:托管区根带 `owner.json`(module/kind/layout)。目录已存在但
|
|
10
|
+
// 没有合法标记、或布局版本未知时一律"不接管"并返回诊断(`ownership-missing` /
|
|
11
|
+
// `layout-version-unknown`),绝不递归清理;人工区(updateDir 下其它文件)永不读写。
|
|
12
|
+
// 2. **提交协议**:暂存(staging,不参与服务)→ 校验 size/sha256 → 发布不可变对象
|
|
13
|
+
// (objects/<kind>/<sha256>/…,按对象哈希寻址)→ 原子发布 release 记录 → 最后原子
|
|
14
|
+
// 更新本 profile 指针。任一步失败/中断都不产生可服务的半成品:没有 release 记录就
|
|
15
|
+
// 无法寻址,指针永远最后写。
|
|
16
|
+
// 3. **不可变 inode**:已发布对象绝不原地覆盖;暂存文件经同文件系统 rename 发布。载体源
|
|
17
|
+
// 只用硬链接(源永不移动/改写),不支持/跨文件系统时回退复制;人工导入一律复制成独立
|
|
18
|
+
// inode,避免人工区文件被后续覆盖时污染托管对象。
|
|
19
|
+
// 4. **profile 隔离**:`profiles/<profileKey>/current.json` 按 profile 分开;无法确定
|
|
20
|
+
// profile 身份时返回明确不可用,绝不"找第一个"。
|
|
21
|
+
// 5. **releaseId/v1**:对规范序列化的两段产物身份字段(两端版本、文件名、大小、字节哈希、
|
|
22
|
+
// 插件内容标识;不含 URL/备注/临时地址)求 sha256。序列化顺序与归一化规则是 P0 契约:
|
|
23
|
+
// 只增字段,改变顺序/归一化必须升版本前缀(`RELEASE_ID_FIELD_ORDER` 与测试向量钉住)。
|
|
24
|
+
// 6. **保留与 GC**:当前发布不因时间过期;被替代后按 profile 与真实选用周期保留 24h
|
|
25
|
+
// (重复调用/重启不重置);有效租约与已打开句柄保护的对象不删;GC 汇总所有 profile
|
|
26
|
+
// 当前引用、保留截止与有效租约取并集;GC 前核对 profile 存活性(profile 目录与
|
|
27
|
+
// package.json 仍存在),遗弃指针先写墓碑再释放引用;只删本 module 的托管对象。
|
|
28
|
+
// 7. **状态语义**:所有面向调用方的函数返回稳定状态
|
|
29
|
+
// (ready / staged / not-ready / expired / mismatch / unusable / invalid / busy + reason),
|
|
30
|
+
// 不抛裸异常。纯函数(releaseId 计算)对非法身份抛错,由 releaseIdentityFromManifest
|
|
31
|
+
// 转成稳定状态。
|
|
32
|
+
// 8. **锁顺序**:上层按"既有更新租约 → 托管区提交锁"的固定顺序使用;本 module 自持一把
|
|
33
|
+
// 提交锁(`withCarrierCommitLock`),GC 只取这一把锁;持锁期间不做网络/子进程等待
|
|
34
|
+
// (回调必须是同步函数),忙时立即返回 busy 而不是阻塞等待。
|
|
35
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
36
|
+
import {
|
|
37
|
+
chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, linkSync, lstatSync, mkdirSync,
|
|
38
|
+
openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmdirSync,
|
|
39
|
+
rmSync, statSync, writeFileSync,
|
|
40
|
+
} from "node:fs";
|
|
41
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
42
|
+
|
|
43
|
+
// ── 常量 ─────────────────────────────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
/** 托管区目录名(位于配置的 updateDir 之下,见设计稿 §5.4)。 */
|
|
46
|
+
export const APP_CARRIER_DIR = ".app-carrier";
|
|
47
|
+
/** 所有权标记里的 module 名。 */
|
|
48
|
+
export const APP_CARRIER_MODULE = "dsh-mobile-remote";
|
|
49
|
+
/** 所有权标记里的 kind,避免与将来别的托管区混用。 */
|
|
50
|
+
export const APP_CARRIER_KIND = "app-carrier";
|
|
51
|
+
/** 布局版本;目录结构/字段语义变化时递增。未知版本 → 不接管。 */
|
|
52
|
+
export const APP_CARRIER_LAYOUT_VERSION = 1;
|
|
53
|
+
/** 所有权标记文件名(托管区根下)。 */
|
|
54
|
+
export const CARRIER_OWNER_FILE = "owner.json";
|
|
55
|
+
/** releaseId 的版本化前缀;改变序列化顺序或归一化规则必须递增。 */
|
|
56
|
+
export const RELEASE_ID_PREFIX = "releaseId/v1";
|
|
57
|
+
/**
|
|
58
|
+
* releaseId 参与字段及其**固定序列化顺序**(只增字段,不得重排/改变归一化)。
|
|
59
|
+
* 不含 URL、备注、临时地址等非身份字段。
|
|
60
|
+
*/
|
|
61
|
+
export const RELEASE_ID_FIELD_ORDER = Object.freeze([
|
|
62
|
+
"app.version",
|
|
63
|
+
"app.file",
|
|
64
|
+
"app.size",
|
|
65
|
+
"app.sha256",
|
|
66
|
+
"plugin.version",
|
|
67
|
+
"plugin.file",
|
|
68
|
+
"plugin.size",
|
|
69
|
+
"plugin.sha256",
|
|
70
|
+
"plugin.contentSha256",
|
|
71
|
+
]);
|
|
72
|
+
/** 默认保留窗口:24 小时(设计稿 A5/§5.8)。 */
|
|
73
|
+
export const DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
74
|
+
/** 暂存区残留的默认回收年龄:1 小时(未提交产物本来就不参与服务)。 */
|
|
75
|
+
export const DEFAULT_STAGING_TTL_MS = 60 * 60 * 1000;
|
|
76
|
+
/** 租约心跳默认有效期:10 分钟(与既有更新事务的恢复窗口同量级)。 */
|
|
77
|
+
export const DEFAULT_LEASE_TTL_MS = 10 * 60 * 1000;
|
|
78
|
+
/** 提交锁在"持有者缺失但锁目录还在"时的判定年龄。 */
|
|
79
|
+
export const DEFAULT_COMMIT_LOCK_STALE_MS = 2 * 60 * 1000;
|
|
80
|
+
|
|
81
|
+
/** 终态租约阶段:终态释放事务引用,但不能缩短已承诺的保留窗口。 */
|
|
82
|
+
export const TERMINAL_LEASE_PHASES = Object.freeze(["settled", "rolled-back", "failed", "expired", "cancelled"]);
|
|
83
|
+
|
|
84
|
+
/** 对外稳定状态。 */
|
|
85
|
+
export const CARRIER_STATUS = Object.freeze({
|
|
86
|
+
READY: "ready",
|
|
87
|
+
STAGED: "staged",
|
|
88
|
+
NOT_READY: "not-ready",
|
|
89
|
+
EXPIRED: "expired",
|
|
90
|
+
MISMATCH: "mismatch",
|
|
91
|
+
UNUSABLE: "unusable",
|
|
92
|
+
INVALID: "invalid",
|
|
93
|
+
BUSY: "busy",
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
/** 对象种类 → 托管区文件名。 */
|
|
97
|
+
const OBJECT_NAMES = Object.freeze({ apk: "app.apk", plugin: "plugin.tgz" });
|
|
98
|
+
const OBJECT_KINDS = Object.freeze(["apk", "plugin"]);
|
|
99
|
+
/** 暂存目录里允许出现的条目(GC 只清理完全属于本 module 的暂存目录)。 */
|
|
100
|
+
const STAGING_ENTRY_PATTERN = /^(?:app\.apk|plugin\.tgz|staged\.json|staged\.json\.[A-Za-z0-9-]+\.tmp)$/;
|
|
101
|
+
/** release 临时目录(原子发布 release 记录用)。 */
|
|
102
|
+
const RELEASE_TMP_PREFIX = ".tmp-";
|
|
103
|
+
/** 硬链接不可用时的回退错误码(跨文件系统 / 不支持 / 权限受限)。 */
|
|
104
|
+
const LINK_FALLBACK_CODES = new Set(["EXDEV", "EPERM", "EACCES", "EMLINK", "ENOSYS", "ENOTSUP", "EOPNOTSUPP"]);
|
|
105
|
+
/** 本进程内持有中的提交锁:root → { token, depth }(支持同一锁的重入)。 */
|
|
106
|
+
const heldCommitLocks = new Map();
|
|
107
|
+
/** 本进程内已打开对象句柄的引用计数:`<kind>:<sha256>` → count(GC 用)。 */
|
|
108
|
+
const openObjectPins = new Map();
|
|
109
|
+
|
|
110
|
+
const HEX64 = /^[a-f0-9]{64}$/;
|
|
111
|
+
const PROFILE_KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
112
|
+
const STAGING_ID = /^[A-Za-z0-9][A-Za-z0-9-]{7,63}$/;
|
|
113
|
+
const LEASE_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,63}$/;
|
|
114
|
+
|
|
115
|
+
// ── 通用小工具 ───────────────────────────────────────────────────────────────
|
|
116
|
+
|
|
117
|
+
function ready(extra = {}) {
|
|
118
|
+
return { ok: true, status: CARRIER_STATUS.READY, ...extra };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function failed(status, reason, extra = {}) {
|
|
122
|
+
return { ok: false, status, reason, ...extra };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function nowIso(ms) {
|
|
126
|
+
return new Date(ms).toISOString();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function readJson(file) {
|
|
130
|
+
try {
|
|
131
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
132
|
+
} catch {
|
|
133
|
+
return null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 尽力同步落盘(平台不支持/权限不允许时忽略——正确性不依赖它,崩溃恢复才有差异)。 */
|
|
138
|
+
function fsyncDir(dir) {
|
|
139
|
+
try {
|
|
140
|
+
const fd = openSync(dir, "r");
|
|
141
|
+
try {
|
|
142
|
+
fsyncSync(fd);
|
|
143
|
+
} finally {
|
|
144
|
+
closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
} catch {
|
|
147
|
+
// 某些文件系统不允许对目录 fsync:忽略
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 原子写 JSON:同目录 tmp + rename,持锁调用者保证目标不被并发改写。 */
|
|
152
|
+
function atomicWriteJson(file, value) {
|
|
153
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
154
|
+
const tmp = `${file}.${randomUUID()}.tmp`;
|
|
155
|
+
try {
|
|
156
|
+
const fd = openSync(tmp, "w", 0o600);
|
|
157
|
+
try {
|
|
158
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, "utf8");
|
|
159
|
+
fsyncSync(fd);
|
|
160
|
+
} finally {
|
|
161
|
+
closeSync(fd);
|
|
162
|
+
}
|
|
163
|
+
renameSync(tmp, file);
|
|
164
|
+
fsyncDir(dirname(file));
|
|
165
|
+
} finally {
|
|
166
|
+
if (existsSync(tmp)) rmSync(tmp, { force: true });
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 分块算 sha256,避免把 72MB APK 整个读进内存。 */
|
|
171
|
+
function hashFileSync(file) {
|
|
172
|
+
const hash = createHash("sha256");
|
|
173
|
+
const fd = openSync(file, "r");
|
|
174
|
+
try {
|
|
175
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
176
|
+
for (;;) {
|
|
177
|
+
const read = readSync(fd, buffer, 0, buffer.length, null);
|
|
178
|
+
if (read <= 0) break;
|
|
179
|
+
hash.update(buffer.subarray(0, read));
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
closeSync(fd);
|
|
183
|
+
}
|
|
184
|
+
return hash.digest("hex");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function pidAlive(pid) {
|
|
188
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
189
|
+
try {
|
|
190
|
+
process.kill(pid, 0);
|
|
191
|
+
return true;
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return error.code === "EPERM";
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function listDir(dir) {
|
|
198
|
+
try {
|
|
199
|
+
return readdirSync(dir, { withFileTypes: true });
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function listSubdirs(dir) {
|
|
206
|
+
return listDir(dir).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function listFiles(dir) {
|
|
210
|
+
return listDir(dir).filter((entry) => entry.isFile()).map((entry) => entry.name);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 布局 ─────────────────────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* 托管区布局路径。`updateDir` 非法(空/非字符串)时返回 null——调用方据此报
|
|
217
|
+
* `update-dir-missing`,不把当前工作目录当成更新目录。
|
|
218
|
+
*/
|
|
219
|
+
export function carrierPaths(updateDir) {
|
|
220
|
+
if (typeof updateDir !== "string" || updateDir.trim() === "") return null;
|
|
221
|
+
const base = resolve(updateDir);
|
|
222
|
+
const root = join(base, APP_CARRIER_DIR);
|
|
223
|
+
return {
|
|
224
|
+
updateDir: base,
|
|
225
|
+
root,
|
|
226
|
+
ownerFile: join(root, CARRIER_OWNER_FILE),
|
|
227
|
+
objects: join(root, "objects"),
|
|
228
|
+
apkObjects: join(root, "objects", "apk"),
|
|
229
|
+
pluginObjects: join(root, "objects", "plugin"),
|
|
230
|
+
releases: join(root, "releases"),
|
|
231
|
+
profiles: join(root, "profiles"),
|
|
232
|
+
leases: join(root, "leases"),
|
|
233
|
+
staging: join(root, "staging"),
|
|
234
|
+
retentionFile: join(root, "retention.json"),
|
|
235
|
+
commitLock: join(root, "commit.lock"),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 托管区根目录;`updateDir` 非法时返回 null。 */
|
|
240
|
+
export function carrierRoot(updateDir) {
|
|
241
|
+
return carrierPaths(updateDir)?.root ?? null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function objectPath(paths, kind, sha256) {
|
|
245
|
+
return join(kind === "apk" ? paths.apkObjects : paths.pluginObjects, sha256, OBJECT_NAMES[kind]);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function releaseDir(paths, releaseId) {
|
|
249
|
+
return join(paths.releases, releaseId);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function profileDir(paths, profileKey) {
|
|
253
|
+
return join(paths.profiles, profileKey);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function pointerPath(paths, profileKey) {
|
|
257
|
+
return join(profileDir(paths, profileKey), "current.json");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function leasePath(paths, leaseId) {
|
|
261
|
+
return join(paths.leases, `${leaseId}.json`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function stagingDir(paths, stagingId) {
|
|
265
|
+
return join(paths.staging, stagingId);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** 读所有权标记;返回 { ok, ownership } 或 { ok:false, reason }。 */
|
|
269
|
+
function inspectOwnership(paths) {
|
|
270
|
+
if (!existsSync(paths.root)) {
|
|
271
|
+
return failed(CARRIER_STATUS.NOT_READY, "carrier-not-initialized", { root: paths.root });
|
|
272
|
+
}
|
|
273
|
+
let stat;
|
|
274
|
+
try {
|
|
275
|
+
stat = statSync(paths.root);
|
|
276
|
+
} catch {
|
|
277
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-root-unreadable", { root: paths.root });
|
|
278
|
+
}
|
|
279
|
+
if (!stat.isDirectory()) {
|
|
280
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-root-not-directory", { root: paths.root });
|
|
281
|
+
}
|
|
282
|
+
const ownership = readJson(paths.ownerFile);
|
|
283
|
+
if (!ownership || typeof ownership !== "object") {
|
|
284
|
+
return failed(CARRIER_STATUS.UNUSABLE, "ownership-missing", { root: paths.root });
|
|
285
|
+
}
|
|
286
|
+
if (ownership.module !== APP_CARRIER_MODULE || ownership.kind !== APP_CARRIER_KIND) {
|
|
287
|
+
return failed(CARRIER_STATUS.UNUSABLE, "ownership-foreign", { root: paths.root, ownership });
|
|
288
|
+
}
|
|
289
|
+
if (ownership.layout !== APP_CARRIER_LAYOUT_VERSION) {
|
|
290
|
+
return failed(CARRIER_STATUS.UNUSABLE, "layout-version-unknown", { root: paths.root, layout: ownership.layout ?? null });
|
|
291
|
+
}
|
|
292
|
+
return { ok: true, ownership, root: paths.root };
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 确保托管区存在(幂等)。已有目录但所有权缺失/版本未知时**不接管**,原样返回诊断;
|
|
297
|
+
* 绝不递归清理或覆盖。`create:false` 只检查不创建。
|
|
298
|
+
*/
|
|
299
|
+
export function ensureCarrierStore({ updateDir, now = Date.now(), create = true } = {}) {
|
|
300
|
+
try {
|
|
301
|
+
const paths = carrierPaths(updateDir);
|
|
302
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
303
|
+
const inspected = inspectOwnership(paths);
|
|
304
|
+
if (inspected.ok) return { ...ready({ root: paths.root, ownership: inspected.ownership }), created: false };
|
|
305
|
+
if (inspected.reason !== "carrier-not-initialized" || !create) return inspected;
|
|
306
|
+
try {
|
|
307
|
+
mkdirSync(paths.root, { recursive: true });
|
|
308
|
+
} catch (error) {
|
|
309
|
+
return failed(CARRIER_STATUS.UNUSABLE, "store-not-writable", { detail: error.code ?? String(error) });
|
|
310
|
+
}
|
|
311
|
+
const ownership = {
|
|
312
|
+
module: APP_CARRIER_MODULE,
|
|
313
|
+
kind: APP_CARRIER_KIND,
|
|
314
|
+
schema: 1,
|
|
315
|
+
layout: APP_CARRIER_LAYOUT_VERSION,
|
|
316
|
+
createdAt: nowIso(now),
|
|
317
|
+
updatedAt: nowIso(now),
|
|
318
|
+
};
|
|
319
|
+
try {
|
|
320
|
+
// `wx`:绝不覆盖别人已经落下的所有权标记(并发初始化时以先写者为准)。
|
|
321
|
+
let fd;
|
|
322
|
+
try {
|
|
323
|
+
fd = openSync(paths.ownerFile, "wx", 0o600);
|
|
324
|
+
} catch (error) {
|
|
325
|
+
if (error?.code !== "EEXIST") throw error;
|
|
326
|
+
const raced = inspectOwnership(paths);
|
|
327
|
+
if (!raced.ok) return raced;
|
|
328
|
+
return { ...ready({ root: paths.root, ownership: raced.ownership }), created: false };
|
|
329
|
+
}
|
|
330
|
+
try {
|
|
331
|
+
writeFileSync(fd, `${JSON.stringify(ownership, null, 2)}\n`, "utf8");
|
|
332
|
+
fsyncSync(fd);
|
|
333
|
+
} finally {
|
|
334
|
+
closeSync(fd);
|
|
335
|
+
}
|
|
336
|
+
fsyncDir(paths.root);
|
|
337
|
+
for (const dir of [paths.objects, paths.apkObjects, paths.pluginObjects, paths.releases, paths.profiles, paths.leases, paths.staging]) {
|
|
338
|
+
mkdirSync(dir, { recursive: true });
|
|
339
|
+
}
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return failed(CARRIER_STATUS.UNUSABLE, "store-not-writable", { detail: error.code ?? String(error) });
|
|
342
|
+
}
|
|
343
|
+
return { ...ready({ root: paths.root, ownership }), created: true };
|
|
344
|
+
} catch (error) {
|
|
345
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** 只读检查托管区所有权与布局版本(诊断用,不创建任何东西)。 */
|
|
350
|
+
export function readCarrierOwnership({ updateDir } = {}) {
|
|
351
|
+
try {
|
|
352
|
+
const paths = carrierPaths(updateDir);
|
|
353
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
354
|
+
const inspected = inspectOwnership(paths);
|
|
355
|
+
return inspected.ok ? { ...ready({ root: paths.root, ownership: inspected.ownership }) } : inspected;
|
|
356
|
+
} catch (error) {
|
|
357
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// ── releaseId/v1 ─────────────────────────────────────────────────────────────
|
|
362
|
+
|
|
363
|
+
function identityError(field, code) {
|
|
364
|
+
const error = new Error(`${field}:${code}`);
|
|
365
|
+
error.field = field;
|
|
366
|
+
error.identityCode = code;
|
|
367
|
+
return error;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function normalizeVersion(value, field) {
|
|
371
|
+
if (typeof value !== "string") throw identityError(field, "not-a-string");
|
|
372
|
+
const text = value.trim();
|
|
373
|
+
// 换行/控制字符会破坏规范序列化的可判定性;版本号本身不应包含它们。
|
|
374
|
+
if (text === "" || /[\r\n\u0000-\u001f]/.test(text)) throw identityError(field, "invalid");
|
|
375
|
+
return text;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function normalizeFileName(value, field) {
|
|
379
|
+
if (typeof value !== "string") throw identityError(field, "not-a-string");
|
|
380
|
+
const text = value.trim();
|
|
381
|
+
if (text === "" || text === "." || text === ".." || /[/\\]/.test(text) || /[\r\n\u0000-\u001f]/.test(text)) {
|
|
382
|
+
throw identityError(field, "invalid");
|
|
383
|
+
}
|
|
384
|
+
return text;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function normalizeSize(value, field) {
|
|
388
|
+
// 严格只接受非负安全整数:接受数字字符串会让 "42" 与 42 有两种序列化,破坏规范形式。
|
|
389
|
+
if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw identityError(field, "invalid");
|
|
390
|
+
return value;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizeSha256(value, field) {
|
|
394
|
+
if (typeof value !== "string") throw identityError(field, "not-a-string");
|
|
395
|
+
const text = value.trim().toLowerCase();
|
|
396
|
+
if (!HEX64.test(text)) throw identityError(field, "invalid");
|
|
397
|
+
return text;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* 归一化两段产物身份字段(只保留 releaseId 参与字段,剔除 URL/备注等)。
|
|
402
|
+
* 非法身份抛错(带 field/code),由 `releaseIdentityFromManifest` 转成稳定状态。
|
|
403
|
+
*/
|
|
404
|
+
export function normalizeReleaseIdentity(identity) {
|
|
405
|
+
if (!identity || typeof identity !== "object") throw identityError("identity", "not-an-object");
|
|
406
|
+
const app = identity.app;
|
|
407
|
+
const plugin = identity.plugin;
|
|
408
|
+
if (!app || typeof app !== "object") throw identityError("app", "missing");
|
|
409
|
+
if (!plugin || typeof plugin !== "object") throw identityError("plugin", "missing");
|
|
410
|
+
return {
|
|
411
|
+
app: {
|
|
412
|
+
version: normalizeVersion(app.version, "app.version"),
|
|
413
|
+
file: normalizeFileName(app.file, "app.file"),
|
|
414
|
+
size: normalizeSize(app.size, "app.size"),
|
|
415
|
+
sha256: normalizeSha256(app.sha256, "app.sha256"),
|
|
416
|
+
},
|
|
417
|
+
plugin: {
|
|
418
|
+
version: normalizeVersion(plugin.version, "plugin.version"),
|
|
419
|
+
file: normalizeFileName(plugin.file, "plugin.file"),
|
|
420
|
+
size: normalizeSize(plugin.size, "plugin.size"),
|
|
421
|
+
sha256: normalizeSha256(plugin.sha256, "plugin.sha256"),
|
|
422
|
+
contentSha256: normalizeSha256(plugin.contentSha256, "plugin.contentSha256"),
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* releaseId 的**规范序列化**(P0 契约):前缀行 + 固定字段顺序的紧凑 JSON。
|
|
429
|
+
* 任何顺序/归一化变化都会改变同一身份的 releaseId,因此必须提升 `RELEASE_ID_PREFIX`。
|
|
430
|
+
*/
|
|
431
|
+
export function releaseIdCanonical(identity) {
|
|
432
|
+
const normalized = normalizeReleaseIdentity(identity);
|
|
433
|
+
const body = {
|
|
434
|
+
app: {
|
|
435
|
+
version: normalized.app.version,
|
|
436
|
+
file: normalized.app.file,
|
|
437
|
+
size: normalized.app.size,
|
|
438
|
+
sha256: normalized.app.sha256,
|
|
439
|
+
},
|
|
440
|
+
plugin: {
|
|
441
|
+
version: normalized.plugin.version,
|
|
442
|
+
file: normalized.plugin.file,
|
|
443
|
+
size: normalized.plugin.size,
|
|
444
|
+
sha256: normalized.plugin.sha256,
|
|
445
|
+
contentSha256: normalized.plugin.contentSha256,
|
|
446
|
+
},
|
|
447
|
+
};
|
|
448
|
+
return `${RELEASE_ID_PREFIX}\n${JSON.stringify(body)}`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** 计算 releaseId:sha256(规范序列化)。非法身份抛错(纯函数)。 */
|
|
452
|
+
export function computeReleaseId(identity) {
|
|
453
|
+
return createHash("sha256").update(releaseIdCanonical(identity), "utf8").digest("hex");
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* 从完整发布清单(schema 1,含 app/plugin 两段)求 releaseId;非法清单返回稳定状态。
|
|
458
|
+
* 清单中的 URL、备注、临时地址不参与身份。
|
|
459
|
+
*/
|
|
460
|
+
export function releaseIdentityFromManifest(manifest) {
|
|
461
|
+
try {
|
|
462
|
+
if (!manifest || typeof manifest !== "object") return failed(CARRIER_STATUS.MISMATCH, "manifest-invalid");
|
|
463
|
+
if (manifest.schema !== 1) {
|
|
464
|
+
return failed(CARRIER_STATUS.MISMATCH, "manifest-schema-unknown", { schema: manifest.schema ?? null });
|
|
465
|
+
}
|
|
466
|
+
const identity = normalizeReleaseIdentity({ app: manifest.app, plugin: manifest.plugin });
|
|
467
|
+
return { ok: true, identity, releaseId: computeReleaseId(identity) };
|
|
468
|
+
} catch (error) {
|
|
469
|
+
return failed(CARRIER_STATUS.MISMATCH, "manifest-identity-invalid", {
|
|
470
|
+
field: error?.field ?? null,
|
|
471
|
+
code: error?.identityCode ?? "invalid",
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
// ── 文件/对象校验 ─────────────────────────────────────────────────────────────
|
|
477
|
+
|
|
478
|
+
/** 按身份段校验文件 size + sha256;`hash:false` 只校验大小。 */
|
|
479
|
+
function verifyFile(file, expected, { hash = true } = {}) {
|
|
480
|
+
let stat;
|
|
481
|
+
try {
|
|
482
|
+
stat = statSync(file);
|
|
483
|
+
} catch (error) {
|
|
484
|
+
return failed(CARRIER_STATUS.NOT_READY, "file-missing", { detail: error.code ?? String(error) });
|
|
485
|
+
}
|
|
486
|
+
if (!stat.isFile()) return failed(CARRIER_STATUS.MISMATCH, "not-a-regular-file");
|
|
487
|
+
if (stat.size !== expected.size) {
|
|
488
|
+
return failed(CARRIER_STATUS.MISMATCH, "size-mismatch", { expectedSize: expected.size, actualSize: stat.size });
|
|
489
|
+
}
|
|
490
|
+
if (hash) {
|
|
491
|
+
let actual;
|
|
492
|
+
try {
|
|
493
|
+
actual = hashFileSync(file);
|
|
494
|
+
} catch (error) {
|
|
495
|
+
return failed(CARRIER_STATUS.NOT_READY, "file-unreadable", { detail: error.code ?? String(error) });
|
|
496
|
+
}
|
|
497
|
+
if (actual !== expected.sha256) {
|
|
498
|
+
return failed(CARRIER_STATUS.MISMATCH, "hash-mismatch", { expected: expected.sha256, actual });
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { ok: true, size: stat.size };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function pinKey(kind, sha256) {
|
|
505
|
+
return `${kind}:${sha256}`;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function registerPin(kind, sha256) {
|
|
509
|
+
const key = pinKey(kind, sha256);
|
|
510
|
+
openObjectPins.set(key, (openObjectPins.get(key) ?? 0) + 1);
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
function releasePin(kind, sha256) {
|
|
514
|
+
const key = pinKey(kind, sha256);
|
|
515
|
+
const count = openObjectPins.get(key) ?? 0;
|
|
516
|
+
if (count <= 1) openObjectPins.delete(key);
|
|
517
|
+
else openObjectPins.set(key, count - 1);
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ── 暂存 ─────────────────────────────────────────────────────────────────────
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* 把一个源文件放进暂存区:carrier 源优先硬链接(源永不移动/改写),
|
|
524
|
+
* 不支持/跨文件系统/权限受限时回退复制;manual 源一律复制成独立 inode。
|
|
525
|
+
*/
|
|
526
|
+
function placeStagedFile({ source, dest, carrier, linkFile = linkSync, copyFile = copyFileSync }) {
|
|
527
|
+
let real;
|
|
528
|
+
try {
|
|
529
|
+
real = realpathSync(source);
|
|
530
|
+
} catch {
|
|
531
|
+
return failed(CARRIER_STATUS.NOT_READY, "source-missing");
|
|
532
|
+
}
|
|
533
|
+
let stat;
|
|
534
|
+
try {
|
|
535
|
+
stat = statSync(real);
|
|
536
|
+
} catch {
|
|
537
|
+
return failed(CARRIER_STATUS.NOT_READY, "source-missing");
|
|
538
|
+
}
|
|
539
|
+
if (!stat.isFile()) return failed(CARRIER_STATUS.MISMATCH, "source-not-a-regular-file");
|
|
540
|
+
let mode = "copy";
|
|
541
|
+
if (carrier) {
|
|
542
|
+
try {
|
|
543
|
+
linkFile(real, dest);
|
|
544
|
+
return { ok: true, mode: "link" };
|
|
545
|
+
} catch (error) {
|
|
546
|
+
if (!LINK_FALLBACK_CODES.has(error?.code)) {
|
|
547
|
+
return failed(CARRIER_STATUS.UNUSABLE, "link-failed", { detail: error?.code ?? String(error) });
|
|
548
|
+
}
|
|
549
|
+
// 跨文件系统 / 不支持 / 权限受限 → 回退复制(绝不移动载体原件)
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
let copyResult;
|
|
553
|
+
try {
|
|
554
|
+
copyResult = copyFile(real, dest);
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return failed(CARRIER_STATUS.UNUSABLE, "copy-failed", { detail: error?.code ?? String(error) });
|
|
557
|
+
}
|
|
558
|
+
if (copyResult && copyResult.ok === false) {
|
|
559
|
+
return failed(CARRIER_STATUS.UNUSABLE, "copy-failed", { detail: copyResult.error ?? "copy-rejected" });
|
|
560
|
+
}
|
|
561
|
+
mode = "copy";
|
|
562
|
+
// 复制出来的托管对象是独立 inode,可以安全置只读;硬链接绝不能 chmod(会改到载体原件)。
|
|
563
|
+
try {
|
|
564
|
+
chmodSync(dest, 0o444);
|
|
565
|
+
} catch {
|
|
566
|
+
// 置只读失败不改变正确性:对象仍按不可变约定使用
|
|
567
|
+
}
|
|
568
|
+
return { ok: true, mode };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** 尽最大努力删掉暂存目录(只删本 module 认识的条目,未知文件留下)。 */
|
|
572
|
+
function cleanupStagingDir(dir) {
|
|
573
|
+
for (const name of [OBJECT_NAMES.apk, OBJECT_NAMES.plugin, "staged.json"]) {
|
|
574
|
+
try {
|
|
575
|
+
rmSync(join(dir, name), { force: true });
|
|
576
|
+
} catch {
|
|
577
|
+
// 忽略
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
for (const name of listFiles(dir).filter((entry) => /\.tmp$/.test(entry))) {
|
|
581
|
+
try {
|
|
582
|
+
rmSync(join(dir, name), { force: true });
|
|
583
|
+
} catch {
|
|
584
|
+
// 忽略
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
try {
|
|
588
|
+
rmdirSync(dir);
|
|
589
|
+
} catch {
|
|
590
|
+
// 非空(含未知文件)就留着,交给人工/诊断
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** 暂存目录是否只包含本 module 认识的条目(GC 删除前的校验)。 */
|
|
595
|
+
function stagingDirIsOurs(dir) {
|
|
596
|
+
for (const name of listDir(dir).map((entry) => entry.name)) {
|
|
597
|
+
if (!STAGING_ENTRY_PATTERN.test(name)) return false;
|
|
598
|
+
}
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* 校验清单并把两段产物放入暂存区(**不发布**,因此不可能被服务)。
|
|
604
|
+
*
|
|
605
|
+
* @param {object} input
|
|
606
|
+
* @param {string} input.updateDir 生效更新目录
|
|
607
|
+
* @param {object} input.manifest 完整发布清单(schema 1)
|
|
608
|
+
* @param {string} input.appPath 已核验 APK 源路径
|
|
609
|
+
* @param {string} [input.pluginPath] 本地插件 tgz 源路径(可选;没有时该对象不导入)
|
|
610
|
+
* @param {"carrier"|"manual"} [input.source="manual"] carrier 可硬链接;manual 必复制
|
|
611
|
+
* @param {number} [input.now] 时间注入(测试用)
|
|
612
|
+
* @param {Function} [input.linkFile] 硬链接实现注入(测试模拟 EXDEV)
|
|
613
|
+
* @param {Function} [input.copyFile] 复制实现注入
|
|
614
|
+
*/
|
|
615
|
+
export function stageRelease({
|
|
616
|
+
updateDir,
|
|
617
|
+
manifest,
|
|
618
|
+
appPath,
|
|
619
|
+
pluginPath = "",
|
|
620
|
+
source = "manual",
|
|
621
|
+
now = Date.now(),
|
|
622
|
+
linkFile,
|
|
623
|
+
copyFile,
|
|
624
|
+
} = {}) {
|
|
625
|
+
try {
|
|
626
|
+
if (source !== "carrier" && source !== "manual") return failed(CARRIER_STATUS.INVALID, "source-kind-invalid");
|
|
627
|
+
const identityResult = releaseIdentityFromManifest(manifest);
|
|
628
|
+
if (!identityResult.ok) return identityResult;
|
|
629
|
+
const { identity, releaseId } = identityResult;
|
|
630
|
+
if (typeof appPath !== "string" || appPath.trim() === "") return failed(CARRIER_STATUS.MISMATCH, "app-path-missing");
|
|
631
|
+
if (pluginPath !== "" && typeof pluginPath !== "string") return failed(CARRIER_STATUS.INVALID, "plugin-path-invalid");
|
|
632
|
+
const store = ensureCarrierStore({ updateDir, now, create: true });
|
|
633
|
+
if (!store.ok) return store;
|
|
634
|
+
const paths = carrierPaths(updateDir);
|
|
635
|
+
// 先按清单校验源(大小检查在前,避免大文件无谓哈希)。
|
|
636
|
+
const appSource = verifyFile(appPath, identity.app);
|
|
637
|
+
if (!appSource.ok) return { ...appSource, reason: `app-${appSource.reason}`, stage: "app" };
|
|
638
|
+
let pluginSource = null;
|
|
639
|
+
if (pluginPath) {
|
|
640
|
+
pluginSource = verifyFile(pluginPath, identity.plugin);
|
|
641
|
+
if (!pluginSource.ok) return { ...pluginSource, reason: `plugin-${pluginSource.reason}`, stage: "plugin" };
|
|
642
|
+
}
|
|
643
|
+
const stagingId = randomUUID();
|
|
644
|
+
const dir = stagingDir(paths, stagingId);
|
|
645
|
+
mkdirSync(dir, { recursive: true });
|
|
646
|
+
try {
|
|
647
|
+
const placedApp = placeStagedFile({
|
|
648
|
+
source: appPath,
|
|
649
|
+
dest: join(dir, OBJECT_NAMES.apk),
|
|
650
|
+
carrier: source === "carrier",
|
|
651
|
+
linkFile,
|
|
652
|
+
copyFile,
|
|
653
|
+
});
|
|
654
|
+
if (!placedApp.ok) {
|
|
655
|
+
cleanupStagingDir(dir);
|
|
656
|
+
return { ...placedApp, stage: "app" };
|
|
657
|
+
}
|
|
658
|
+
// 从暂存位置复算:反制"校验后源被改写"(人工区文件可能变化)。
|
|
659
|
+
const appStaged = verifyFile(join(dir, OBJECT_NAMES.apk), identity.app);
|
|
660
|
+
if (!appStaged.ok) {
|
|
661
|
+
cleanupStagingDir(dir);
|
|
662
|
+
return { ...appStaged, reason: "staged-app-integrity-mismatch", stage: "app" };
|
|
663
|
+
}
|
|
664
|
+
let placedPlugin = null;
|
|
665
|
+
let pluginStaged = null;
|
|
666
|
+
if (pluginSource) {
|
|
667
|
+
placedPlugin = placeStagedFile({
|
|
668
|
+
source: pluginPath,
|
|
669
|
+
dest: join(dir, OBJECT_NAMES.plugin),
|
|
670
|
+
carrier: source === "carrier",
|
|
671
|
+
linkFile,
|
|
672
|
+
copyFile,
|
|
673
|
+
});
|
|
674
|
+
if (!placedPlugin.ok) {
|
|
675
|
+
cleanupStagingDir(dir);
|
|
676
|
+
return { ...placedPlugin, stage: "plugin" };
|
|
677
|
+
}
|
|
678
|
+
pluginStaged = verifyFile(join(dir, OBJECT_NAMES.plugin), identity.plugin);
|
|
679
|
+
if (!pluginStaged.ok) {
|
|
680
|
+
cleanupStagingDir(dir);
|
|
681
|
+
return { ...pluginStaged, reason: "staged-plugin-integrity-mismatch", stage: "plugin" };
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
const staged = {
|
|
685
|
+
schema: 1,
|
|
686
|
+
stagingId,
|
|
687
|
+
createdAt: nowIso(now),
|
|
688
|
+
createdAtMs: now,
|
|
689
|
+
releaseId,
|
|
690
|
+
identity,
|
|
691
|
+
source,
|
|
692
|
+
manifest,
|
|
693
|
+
app: { sha256: identity.app.sha256, size: identity.app.size, mode: placedApp.mode },
|
|
694
|
+
plugin: pluginStaged
|
|
695
|
+
? { sha256: identity.plugin.sha256, size: identity.plugin.size, mode: placedPlugin.mode }
|
|
696
|
+
: null,
|
|
697
|
+
};
|
|
698
|
+
// staged.json 最后写:它存在才算"暂存完整";进程中断只留不可服务的残片。
|
|
699
|
+
atomicWriteJson(join(dir, "staged.json"), staged);
|
|
700
|
+
return {
|
|
701
|
+
ok: true,
|
|
702
|
+
status: CARRIER_STATUS.STAGED,
|
|
703
|
+
stagingId,
|
|
704
|
+
releaseId,
|
|
705
|
+
identity,
|
|
706
|
+
source,
|
|
707
|
+
app: staged.app,
|
|
708
|
+
plugin: staged.plugin,
|
|
709
|
+
stagingDir: dir,
|
|
710
|
+
};
|
|
711
|
+
} catch (error) {
|
|
712
|
+
cleanupStagingDir(dir);
|
|
713
|
+
return failed(CARRIER_STATUS.UNUSABLE, "staging-failed", { detail: String(error?.message ?? error) });
|
|
714
|
+
}
|
|
715
|
+
} catch (error) {
|
|
716
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/** 读一个已完成暂存的元数据(诊断/测试用;不发布)。 */
|
|
721
|
+
export function readStagedRelease({ updateDir, stagingId } = {}) {
|
|
722
|
+
try {
|
|
723
|
+
const paths = carrierPaths(updateDir);
|
|
724
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
725
|
+
if (typeof stagingId !== "string" || !STAGING_ID.test(stagingId)) {
|
|
726
|
+
return failed(CARRIER_STATUS.INVALID, "staging-id-invalid");
|
|
727
|
+
}
|
|
728
|
+
const staged = readJson(join(stagingDir(paths, stagingId), "staged.json"));
|
|
729
|
+
if (!staged || staged.stagingId !== stagingId || !HEX64.test(staged.releaseId ?? "")) {
|
|
730
|
+
return failed(CARRIER_STATUS.NOT_READY, "staging-unknown");
|
|
731
|
+
}
|
|
732
|
+
return ready({ staging: staged });
|
|
733
|
+
} catch (error) {
|
|
734
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/** 丢弃一个暂存(幂等;只动 staging/ 下的目录)。 */
|
|
739
|
+
export function discardStagedRelease({ updateDir, stagingId, now = Date.now(), lockToken = "" } = {}) {
|
|
740
|
+
try {
|
|
741
|
+
const paths = carrierPaths(updateDir);
|
|
742
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
743
|
+
if (typeof stagingId !== "string" || !STAGING_ID.test(stagingId)) {
|
|
744
|
+
return failed(CARRIER_STATUS.INVALID, "staging-id-invalid");
|
|
745
|
+
}
|
|
746
|
+
return runLocked({ updateDir, now, lockToken }, (locked) => {
|
|
747
|
+
const dir = stagingDir(locked, stagingId);
|
|
748
|
+
if (!existsSync(dir)) return ready({ discarded: false });
|
|
749
|
+
if (!stagingDirIsOurs(dir)) return failed(CARRIER_STATUS.UNUSABLE, "staging-foreign-entries", { stagingId });
|
|
750
|
+
cleanupStagingDir(dir);
|
|
751
|
+
return ready({ discarded: true });
|
|
752
|
+
});
|
|
753
|
+
} catch (error) {
|
|
754
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
// ── 提交锁 ───────────────────────────────────────────────────────────────────
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 取提交锁:`<root>/commit.lock`(mkdir 原子独占)。忙时立即返回 busy,不阻塞等待;
|
|
762
|
+
* 持有者进程已死(或锁目录无 owner 且超过 staleMs)时回收一次。
|
|
763
|
+
* 持锁期间只做同步文件系统操作——绝不等待网络、子进程或宿主重启。
|
|
764
|
+
*/
|
|
765
|
+
function acquireCommitLock(paths, { now, staleMs }) {
|
|
766
|
+
const lockDir = paths.commitLock;
|
|
767
|
+
const token = randomUUID();
|
|
768
|
+
const write = () => {
|
|
769
|
+
mkdirSync(lockDir);
|
|
770
|
+
atomicWriteJson(join(lockDir, "owner.json"), {
|
|
771
|
+
schema: 1,
|
|
772
|
+
token,
|
|
773
|
+
pid: process.pid,
|
|
774
|
+
startedAt: nowIso(now),
|
|
775
|
+
startedAtMs: now,
|
|
776
|
+
});
|
|
777
|
+
return { ok: true, token };
|
|
778
|
+
};
|
|
779
|
+
try {
|
|
780
|
+
return write();
|
|
781
|
+
} catch (error) {
|
|
782
|
+
if (error?.code !== "EEXIST") {
|
|
783
|
+
return failed(CARRIER_STATUS.UNUSABLE, "commit-lock-unavailable", { detail: error?.code ?? String(error) });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const owner = readJson(join(lockDir, "owner.json"));
|
|
787
|
+
let reclaimable;
|
|
788
|
+
if (owner && Number.isInteger(owner.pid)) {
|
|
789
|
+
reclaimable = !pidAlive(owner.pid);
|
|
790
|
+
} else {
|
|
791
|
+
let age = Infinity;
|
|
792
|
+
try {
|
|
793
|
+
age = now - lstatSync(lockDir).mtimeMs;
|
|
794
|
+
} catch {
|
|
795
|
+
age = Infinity;
|
|
796
|
+
}
|
|
797
|
+
reclaimable = age >= staleMs;
|
|
798
|
+
}
|
|
799
|
+
if (!reclaimable) {
|
|
800
|
+
return failed(CARRIER_STATUS.BUSY, "commit-lock-busy", {
|
|
801
|
+
holder: owner ? { pid: owner.pid ?? null, startedAt: owner.startedAt ?? null } : null,
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
try {
|
|
805
|
+
rmSync(lockDir, { recursive: true, force: true });
|
|
806
|
+
} catch {
|
|
807
|
+
// 回收失败则下面重试会再报 busy
|
|
808
|
+
}
|
|
809
|
+
try {
|
|
810
|
+
return write();
|
|
811
|
+
} catch (error) {
|
|
812
|
+
return failed(CARRIER_STATUS.BUSY, "commit-lock-busy", { detail: error?.code ?? String(error) });
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function releaseCommitLock(paths, token) {
|
|
817
|
+
const owner = readJson(join(paths.commitLock, "owner.json"));
|
|
818
|
+
if (!owner || owner.token !== token) return false; // 已被回收/接管:不要删别人的锁
|
|
819
|
+
try {
|
|
820
|
+
rmSync(join(paths.commitLock, "owner.json"), { force: true });
|
|
821
|
+
rmdirSync(paths.commitLock);
|
|
822
|
+
return true;
|
|
823
|
+
} catch {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* 在托管区提交锁内执行同步回调(供上层按"更新租约 → 提交锁"的固定顺序包住一组写操作)。
|
|
830
|
+
* - 回调必须同步;返回 Promise 会被拒绝(否则锁会在异步期间被误释放)。
|
|
831
|
+
* - 忙时返回 `{ ok:false, status:"busy", reason:"commit-lock-busy" }`,不排队等待。
|
|
832
|
+
* - 传 `lockToken` 表示重入(用于已持锁时调用 publish/lease 等接口)。
|
|
833
|
+
*/
|
|
834
|
+
export function withCarrierCommitLock(
|
|
835
|
+
{ updateDir, now = Date.now(), staleMs = DEFAULT_COMMIT_LOCK_STALE_MS, lockToken = "" } = {},
|
|
836
|
+
callback,
|
|
837
|
+
) {
|
|
838
|
+
if (typeof callback !== "function") return failed(CARRIER_STATUS.INVALID, "commit-lock-callback-missing");
|
|
839
|
+
try {
|
|
840
|
+
const paths = carrierPaths(updateDir);
|
|
841
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
842
|
+
const ensured = ensureCarrierStore({ updateDir, now, create: true });
|
|
843
|
+
if (!ensured.ok) return ensured;
|
|
844
|
+
const held = heldCommitLocks.get(paths.root);
|
|
845
|
+
if (lockToken) {
|
|
846
|
+
if (!held || held.token !== lockToken) return failed(CARRIER_STATUS.BUSY, "commit-lock-token-invalid");
|
|
847
|
+
held.depth += 1;
|
|
848
|
+
try {
|
|
849
|
+
return callback(lockToken);
|
|
850
|
+
} finally {
|
|
851
|
+
held.depth -= 1;
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
const acquired = acquireCommitLock(paths, { now, staleMs });
|
|
855
|
+
if (!acquired.ok) return acquired;
|
|
856
|
+
heldCommitLocks.set(paths.root, { token: acquired.token, depth: 1 });
|
|
857
|
+
let result;
|
|
858
|
+
try {
|
|
859
|
+
result = callback(acquired.token);
|
|
860
|
+
if (result && typeof result.then === "function") {
|
|
861
|
+
result.catch?.(() => {}); // 误用异步回调:不让它的拒绝变成未处理异常
|
|
862
|
+
throw new Error("commit-lock-callback-must-be-synchronous");
|
|
863
|
+
}
|
|
864
|
+
return result;
|
|
865
|
+
} finally {
|
|
866
|
+
heldCommitLocks.delete(paths.root);
|
|
867
|
+
releaseCommitLock(paths, acquired.token);
|
|
868
|
+
}
|
|
869
|
+
} catch (error) {
|
|
870
|
+
return failed(CARRIER_STATUS.UNUSABLE, "commit-lock-failed", { detail: String(error?.message ?? error) });
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/** 提交锁诊断:当前持有者或 null(不创建任何东西)。 */
|
|
875
|
+
export function readCommitLock({ updateDir } = {}) {
|
|
876
|
+
try {
|
|
877
|
+
const paths = carrierPaths(updateDir);
|
|
878
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
879
|
+
const inspected = inspectOwnership(paths);
|
|
880
|
+
if (!inspected.ok) return inspected;
|
|
881
|
+
const owner = readJson(join(paths.commitLock, "owner.json"));
|
|
882
|
+
return { ok: true, locked: !!owner, holder: owner ?? null };
|
|
883
|
+
} catch (error) {
|
|
884
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
/** 内部:在提交锁内运行(可重入)。回调接收已校验/已创建的布局 paths。 */
|
|
889
|
+
function runLocked({ updateDir, now = Date.now(), lockToken = "", staleMs = DEFAULT_COMMIT_LOCK_STALE_MS, create = true }, callback) {
|
|
890
|
+
const paths = carrierPaths(updateDir);
|
|
891
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
892
|
+
const ensured = create ? ensureCarrierStore({ updateDir, now, create: true }) : inspectOwnership(paths);
|
|
893
|
+
if (!ensured.ok) return ensured;
|
|
894
|
+
const held = heldCommitLocks.get(paths.root);
|
|
895
|
+
if (lockToken) {
|
|
896
|
+
if (!held || held.token !== lockToken) return failed(CARRIER_STATUS.BUSY, "commit-lock-token-invalid");
|
|
897
|
+
held.depth += 1;
|
|
898
|
+
try {
|
|
899
|
+
return callback(paths);
|
|
900
|
+
} finally {
|
|
901
|
+
held.depth -= 1;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
const acquired = acquireCommitLock(paths, { now, staleMs });
|
|
905
|
+
if (!acquired.ok) return acquired;
|
|
906
|
+
heldCommitLocks.set(paths.root, { token: acquired.token, depth: 1 });
|
|
907
|
+
try {
|
|
908
|
+
return callback(paths);
|
|
909
|
+
} finally {
|
|
910
|
+
heldCommitLocks.delete(paths.root);
|
|
911
|
+
releaseCommitLock(paths, acquired.token);
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// ── profile 指针 ─────────────────────────────────────────────────────────────
|
|
916
|
+
|
|
917
|
+
function validProfileKey(profileKey) {
|
|
918
|
+
if (typeof profileKey !== "string" || profileKey.trim() === "") {
|
|
919
|
+
return failed(CARRIER_STATUS.UNUSABLE, "profile-unknown");
|
|
920
|
+
}
|
|
921
|
+
if (!PROFILE_KEY.test(profileKey)) return failed(CARRIER_STATUS.UNUSABLE, "profile-key-invalid");
|
|
922
|
+
return { ok: true, profileKey };
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
function readPointer(paths, profileKey) {
|
|
926
|
+
const pointer = readJson(pointerPath(paths, profileKey));
|
|
927
|
+
if (!pointer || pointer.schema !== 1 || typeof pointer.current?.releaseId !== "string") return null;
|
|
928
|
+
return pointer;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
/** 读某个 profile 的当前/被替代指针(诊断与测试用)。 */
|
|
932
|
+
export function readProfilePointer({ updateDir, profileKey } = {}) {
|
|
933
|
+
try {
|
|
934
|
+
const paths = carrierPaths(updateDir);
|
|
935
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
936
|
+
const inspected = inspectOwnership(paths);
|
|
937
|
+
if (!inspected.ok) return inspected;
|
|
938
|
+
const key = validProfileKey(profileKey);
|
|
939
|
+
if (!key.ok) return key;
|
|
940
|
+
const pointer = readPointer(paths, key.profileKey);
|
|
941
|
+
if (!pointer) return failed(CARRIER_STATUS.NOT_READY, "no-current-release", { profileKey: key.profileKey });
|
|
942
|
+
return ready({ profileKey: key.profileKey, pointer });
|
|
943
|
+
} catch (error) {
|
|
944
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* 提交本 profile 的当前指针(锁内调用)。规则:
|
|
950
|
+
* - 与当前相同 → 幂等,不重写(重复调用不重置保留时钟);
|
|
951
|
+
* - 换掉旧 current → 旧 release 进入本 profile 的 superseded,窗口从**真实替代时刻**起算;
|
|
952
|
+
* - 重新选回某个 superseded → 移出 superseded(下次再被替代是新周期,重新起算)。
|
|
953
|
+
*/
|
|
954
|
+
function commitProfilePointer(paths, profileKey, { releaseId, identity, profileDir: profileDirValue, now, retentionMs }) {
|
|
955
|
+
const previous = readPointer(paths, profileKey);
|
|
956
|
+
if (previous && previous.current?.releaseId === releaseId) {
|
|
957
|
+
return { ok: true, changed: false, pointer: previous };
|
|
958
|
+
}
|
|
959
|
+
const superseded = Array.isArray(previous?.superseded)
|
|
960
|
+
? previous.superseded.filter((entry) => entry && entry.releaseId !== releaseId)
|
|
961
|
+
: [];
|
|
962
|
+
const inheritedProfileDir = typeof previous?.profileDir === "string" ? previous.profileDir : null;
|
|
963
|
+
if (previous?.current?.releaseId && previous.current.releaseId !== releaseId) {
|
|
964
|
+
superseded.push({
|
|
965
|
+
releaseId: previous.current.releaseId,
|
|
966
|
+
supersededAt: nowIso(now),
|
|
967
|
+
retainUntil: nowIso(now + retentionMs),
|
|
968
|
+
identity: previous.current.identity ?? null,
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
const pointer = {
|
|
972
|
+
schema: 1,
|
|
973
|
+
profileKey,
|
|
974
|
+
profileDir: typeof profileDirValue === "string" && profileDirValue !== "" ? profileDirValue : inheritedProfileDir,
|
|
975
|
+
current: { releaseId, selectedAt: nowIso(now), identity },
|
|
976
|
+
superseded,
|
|
977
|
+
updatedAt: nowIso(now),
|
|
978
|
+
};
|
|
979
|
+
atomicWriteJson(pointerPath(paths, profileKey), pointer);
|
|
980
|
+
return { ok: true, changed: true, pointer };
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// ── 对象与 release 记录发布 ───────────────────────────────────────────────────
|
|
984
|
+
|
|
985
|
+
/** 发布暂存文件为不可变对象:目标已存在则只允许"同哈希复用",绝不原地覆盖。 */
|
|
986
|
+
function publishObject({ paths, kind, expected, stagedFile }) {
|
|
987
|
+
const target = objectPath(paths, kind, expected.sha256);
|
|
988
|
+
if (existsSync(target)) {
|
|
989
|
+
const existing = verifyFile(target, expected);
|
|
990
|
+
if (!existing.ok) {
|
|
991
|
+
return failed(CARRIER_STATUS.MISMATCH, "object-integrity-mismatch", {
|
|
992
|
+
kind,
|
|
993
|
+
sha256: expected.sha256,
|
|
994
|
+
actualHash: existing.actual ?? null,
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return { ok: true, kind, path: target, reused: true, sha256: expected.sha256 };
|
|
998
|
+
}
|
|
999
|
+
try {
|
|
1000
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
1001
|
+
} catch (error) {
|
|
1002
|
+
return failed(CARRIER_STATUS.UNUSABLE, "object-publish-failed", { kind, detail: error?.code ?? String(error) });
|
|
1003
|
+
}
|
|
1004
|
+
try {
|
|
1005
|
+
// 同一托管区内的 rename 是原子的:要么没有对象,要么是完整对象。
|
|
1006
|
+
renameSync(stagedFile, target);
|
|
1007
|
+
fsyncDir(dirname(target));
|
|
1008
|
+
} catch (error) {
|
|
1009
|
+
return failed(CARRIER_STATUS.UNUSABLE, "object-publish-failed", { kind, detail: error?.code ?? String(error) });
|
|
1010
|
+
}
|
|
1011
|
+
const published = verifyFile(target, expected, { hash: false });
|
|
1012
|
+
if (!published.ok) {
|
|
1013
|
+
return failed(CARRIER_STATUS.MISMATCH, "object-integrity-mismatch", { kind, sha256: expected.sha256 });
|
|
1014
|
+
}
|
|
1015
|
+
return { ok: true, kind, path: target, reused: false, sha256: expected.sha256 };
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/** 原子发布 release 记录(整目录 rename);同一身份已存在时复用,不覆盖。 */
|
|
1019
|
+
function publishReleaseRecord({ paths, releaseId, identity, manifest, objects, now }) {
|
|
1020
|
+
const dir = releaseDir(paths, releaseId);
|
|
1021
|
+
const existing = readJson(join(dir, "release.json"));
|
|
1022
|
+
if (existing) {
|
|
1023
|
+
const sameIdentity = JSON.stringify(existing.identity ?? null) === JSON.stringify(identity);
|
|
1024
|
+
if (existing.releaseId !== releaseId || !sameIdentity) {
|
|
1025
|
+
return failed(CARRIER_STATUS.MISMATCH, "release-record-conflict", { releaseId });
|
|
1026
|
+
}
|
|
1027
|
+
return { ok: true, reused: true, record: existing, dir };
|
|
1028
|
+
}
|
|
1029
|
+
if (existsSync(dir)) {
|
|
1030
|
+
// 只可能是旧墓碑(或崩溃残片);含未知条目时绝不删除。
|
|
1031
|
+
const foreign = listDir(dir).map((entry) => entry.name).filter((name) => name !== "tombstone.json");
|
|
1032
|
+
if (foreign.length > 0) return failed(CARRIER_STATUS.MISMATCH, "release-dir-conflict", { releaseId, entries: foreign });
|
|
1033
|
+
try {
|
|
1034
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
return failed(CARRIER_STATUS.UNUSABLE, "release-record-publish-failed", { releaseId, detail: error?.code ?? String(error) });
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
const record = {
|
|
1040
|
+
schema: 1,
|
|
1041
|
+
releaseId,
|
|
1042
|
+
identity,
|
|
1043
|
+
objects,
|
|
1044
|
+
publishedAt: nowIso(now),
|
|
1045
|
+
manifest,
|
|
1046
|
+
};
|
|
1047
|
+
const tmp = join(paths.releases, `${RELEASE_TMP_PREFIX}${randomUUID()}`);
|
|
1048
|
+
try {
|
|
1049
|
+
mkdirSync(tmp, { recursive: true });
|
|
1050
|
+
atomicWriteJson(join(tmp, "release.json"), record);
|
|
1051
|
+
writeFileSync(join(tmp, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
|
|
1052
|
+
renameSync(tmp, dir);
|
|
1053
|
+
fsyncDir(paths.releases);
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
1056
|
+
return failed(CARRIER_STATUS.UNUSABLE, "release-record-publish-failed", { releaseId, detail: error?.code ?? String(error) });
|
|
1057
|
+
}
|
|
1058
|
+
return { ok: true, reused: false, record, dir };
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
// ── 事务租约 ─────────────────────────────────────────────────────────────────
|
|
1062
|
+
|
|
1063
|
+
const TERMINAL_PHASE_SET = new Set(TERMINAL_LEASE_PHASES);
|
|
1064
|
+
|
|
1065
|
+
function readLease(paths, leaseId) {
|
|
1066
|
+
const lease = readJson(leasePath(paths, leaseId));
|
|
1067
|
+
if (!lease || lease.schema !== 1 || lease.leaseId !== leaseId) return null;
|
|
1068
|
+
return lease;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
function validateKnownReleases(paths, releaseIds) {
|
|
1072
|
+
for (const releaseId of releaseIds) {
|
|
1073
|
+
if (!HEX64.test(releaseId ?? "")) return failed(CARRIER_STATUS.INVALID, "release-id-invalid", { releaseId });
|
|
1074
|
+
const record = readJson(join(releaseDir(paths, releaseId), "release.json"));
|
|
1075
|
+
if (record) continue;
|
|
1076
|
+
if (existsSync(join(releaseDir(paths, releaseId), "tombstone.json"))) {
|
|
1077
|
+
return failed(CARRIER_STATUS.EXPIRED, "release-expired", { releaseId });
|
|
1078
|
+
}
|
|
1079
|
+
return failed(CARRIER_STATUS.NOT_READY, "release-unknown", { releaseId });
|
|
1080
|
+
}
|
|
1081
|
+
return { ok: true };
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
function normalizeReleaseIdList(releaseIds) {
|
|
1085
|
+
if (!Array.isArray(releaseIds) || releaseIds.length === 0) return failed(CARRIER_STATUS.INVALID, "release-ids-invalid");
|
|
1086
|
+
const unique = [...new Set(releaseIds)];
|
|
1087
|
+
if (unique.length > 64) return failed(CARRIER_STATUS.INVALID, "release-ids-too-many");
|
|
1088
|
+
return { ok: true, releaseIds: unique };
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
function normalizeGeneration(generation) {
|
|
1092
|
+
if (typeof generation !== "string" || generation.trim() === "" || generation.length > 128) {
|
|
1093
|
+
return failed(CARRIER_STATUS.INVALID, "lease-generation-missing");
|
|
1094
|
+
}
|
|
1095
|
+
return { ok: true, generation: generation.trim() };
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/** 提交指针/引用前校验上层更新租约(固定顺序:更新租约 → 提交锁,本函数在锁内调用)。 */
|
|
1099
|
+
function validateUpdateLease(paths, lease) {
|
|
1100
|
+
if (!lease || typeof lease !== "object") return failed(CARRIER_STATUS.INVALID, "lease-invalid");
|
|
1101
|
+
const generation = normalizeGeneration(lease.generation);
|
|
1102
|
+
if (!generation.ok) return generation;
|
|
1103
|
+
if (lease.phase && TERMINAL_PHASE_SET.has(lease.phase)) {
|
|
1104
|
+
return failed(CARRIER_STATUS.UNUSABLE, "lease-terminal", { phase: lease.phase });
|
|
1105
|
+
}
|
|
1106
|
+
if (lease.leaseId !== undefined) {
|
|
1107
|
+
if (typeof lease.leaseId !== "string" || !LEASE_ID.test(lease.leaseId)) {
|
|
1108
|
+
return failed(CARRIER_STATUS.INVALID, "lease-id-invalid");
|
|
1109
|
+
}
|
|
1110
|
+
const stored = readLease(paths, lease.leaseId);
|
|
1111
|
+
if (!stored) return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-lost", { leaseId: lease.leaseId });
|
|
1112
|
+
if (stored.generation !== generation.generation) {
|
|
1113
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-lost", { leaseId: lease.leaseId });
|
|
1114
|
+
}
|
|
1115
|
+
if (TERMINAL_PHASE_SET.has(stored.phase)) {
|
|
1116
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-terminal", { leaseId: lease.leaseId, phase: stored.phase });
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
return { ok: true, lease: { generation: generation.generation, phase: lease.phase ?? null, leaseId: lease.leaseId ?? null } };
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* 声明一条与事务代际绑定的持久引用(受理更新前调用):引用期间 GC 不回收目标。
|
|
1124
|
+
* 返回的 lease 供上层记录进事务状态;终态时调用 `releaseReleaseLease` 释放引用。
|
|
1125
|
+
*/
|
|
1126
|
+
export function claimReleaseLease({
|
|
1127
|
+
updateDir,
|
|
1128
|
+
profileKey,
|
|
1129
|
+
releaseIds,
|
|
1130
|
+
generation,
|
|
1131
|
+
opId = "",
|
|
1132
|
+
phase = "claimed",
|
|
1133
|
+
leaseId = randomUUID(),
|
|
1134
|
+
now = Date.now(),
|
|
1135
|
+
ttlMs = DEFAULT_LEASE_TTL_MS,
|
|
1136
|
+
lockToken = "",
|
|
1137
|
+
} = {}) {
|
|
1138
|
+
try {
|
|
1139
|
+
const key = validProfileKey(profileKey);
|
|
1140
|
+
if (!key.ok) return key;
|
|
1141
|
+
const gen = normalizeGeneration(generation);
|
|
1142
|
+
if (!gen.ok) return gen;
|
|
1143
|
+
const ids = normalizeReleaseIdList(releaseIds);
|
|
1144
|
+
if (!ids.ok) return ids;
|
|
1145
|
+
if (typeof leaseId !== "string" || !LEASE_ID.test(leaseId)) return failed(CARRIER_STATUS.INVALID, "lease-id-invalid");
|
|
1146
|
+
if (typeof phase !== "string" || phase === "" || TERMINAL_PHASE_SET.has(phase)) {
|
|
1147
|
+
return failed(CARRIER_STATUS.INVALID, "lease-phase-invalid");
|
|
1148
|
+
}
|
|
1149
|
+
return runLocked({ updateDir, now, lockToken }, (paths) => {
|
|
1150
|
+
const known = validateKnownReleases(paths, ids.releaseIds);
|
|
1151
|
+
if (!known.ok) return known;
|
|
1152
|
+
const existing = readLease(paths, leaseId);
|
|
1153
|
+
if (existing && existing.generation !== gen.generation) {
|
|
1154
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-lost", { leaseId });
|
|
1155
|
+
}
|
|
1156
|
+
const lease = {
|
|
1157
|
+
schema: 1,
|
|
1158
|
+
leaseId,
|
|
1159
|
+
profileKey: key.profileKey,
|
|
1160
|
+
opId: typeof opId === "string" ? opId : "",
|
|
1161
|
+
generation: gen.generation,
|
|
1162
|
+
phase,
|
|
1163
|
+
releaseIds: ids.releaseIds,
|
|
1164
|
+
claimedAt: existing?.claimedAt ?? nowIso(now),
|
|
1165
|
+
heartbeatAt: nowIso(now),
|
|
1166
|
+
ttlMs: Number.isSafeInteger(ttlMs) && ttlMs > 0 ? ttlMs : DEFAULT_LEASE_TTL_MS,
|
|
1167
|
+
updatedAt: nowIso(now),
|
|
1168
|
+
};
|
|
1169
|
+
atomicWriteJson(leasePath(paths, leaseId), lease);
|
|
1170
|
+
return ready({ lease });
|
|
1171
|
+
});
|
|
1172
|
+
} catch (error) {
|
|
1173
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** 更新租约心跳/阶段/引用集合;代际不匹配或已终态时拒绝(陈旧写者不能提交引用)。 */
|
|
1178
|
+
export function updateReleaseLease({ updateDir, leaseId, generation, phase, releaseIds, now = Date.now(), lockToken = "" } = {}) {
|
|
1179
|
+
try {
|
|
1180
|
+
if (typeof leaseId !== "string" || !LEASE_ID.test(leaseId)) return failed(CARRIER_STATUS.INVALID, "lease-id-invalid");
|
|
1181
|
+
const gen = normalizeGeneration(generation);
|
|
1182
|
+
if (!gen.ok) return gen;
|
|
1183
|
+
if (typeof phase !== "string" || phase === "" || TERMINAL_PHASE_SET.has(phase)) {
|
|
1184
|
+
return failed(CARRIER_STATUS.INVALID, "lease-phase-invalid");
|
|
1185
|
+
}
|
|
1186
|
+
return runLocked({ updateDir, now, lockToken }, (paths) => {
|
|
1187
|
+
const stored = readLease(paths, leaseId);
|
|
1188
|
+
if (!stored) return failed(CARRIER_STATUS.NOT_READY, "lease-unknown", { leaseId });
|
|
1189
|
+
if (stored.generation !== gen.generation) return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-lost", { leaseId });
|
|
1190
|
+
if (TERMINAL_PHASE_SET.has(stored.phase)) {
|
|
1191
|
+
return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-terminal", { leaseId, phase: stored.phase });
|
|
1192
|
+
}
|
|
1193
|
+
let nextReleaseIds = stored.releaseIds;
|
|
1194
|
+
if (releaseIds !== undefined) {
|
|
1195
|
+
const ids = normalizeReleaseIdList(releaseIds);
|
|
1196
|
+
if (!ids.ok) return ids;
|
|
1197
|
+
const known = validateKnownReleases(paths, ids.releaseIds);
|
|
1198
|
+
if (!known.ok) return known;
|
|
1199
|
+
nextReleaseIds = ids.releaseIds;
|
|
1200
|
+
}
|
|
1201
|
+
const lease = { ...stored, phase, releaseIds: nextReleaseIds, heartbeatAt: nowIso(now), updatedAt: nowIso(now) };
|
|
1202
|
+
atomicWriteJson(leasePath(paths, leaseId), lease);
|
|
1203
|
+
return ready({ lease });
|
|
1204
|
+
});
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* 终态释放事务引用(终态阶段必须显式给出)。释放引用会为"从未成为当前发布"的候选
|
|
1212
|
+
* 记录一个从此刻起算的保留窗口;**重复调用不重置/不缩短**已承诺的窗口。
|
|
1213
|
+
*/
|
|
1214
|
+
export function releaseReleaseLease({
|
|
1215
|
+
updateDir,
|
|
1216
|
+
leaseId,
|
|
1217
|
+
generation,
|
|
1218
|
+
phase = "settled",
|
|
1219
|
+
now = Date.now(),
|
|
1220
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
1221
|
+
lockToken = "",
|
|
1222
|
+
} = {}) {
|
|
1223
|
+
try {
|
|
1224
|
+
if (typeof leaseId !== "string" || !LEASE_ID.test(leaseId)) return failed(CARRIER_STATUS.INVALID, "lease-id-invalid");
|
|
1225
|
+
const gen = normalizeGeneration(generation);
|
|
1226
|
+
if (!gen.ok) return gen;
|
|
1227
|
+
if (typeof phase !== "string" || !TERMINAL_PHASE_SET.has(phase)) {
|
|
1228
|
+
return failed(CARRIER_STATUS.INVALID, "lease-phase-invalid");
|
|
1229
|
+
}
|
|
1230
|
+
return runLocked({ updateDir, now, lockToken }, (paths) => {
|
|
1231
|
+
const stored = readLease(paths, leaseId);
|
|
1232
|
+
if (!stored) return failed(CARRIER_STATUS.NOT_READY, "lease-unknown", { leaseId });
|
|
1233
|
+
if (stored.generation !== gen.generation) return failed(CARRIER_STATUS.UNUSABLE, "carrier-lease-lost", { leaseId });
|
|
1234
|
+
if (TERMINAL_PHASE_SET.has(stored.phase)) {
|
|
1235
|
+
// 重复调用:不延长、不缩短已经承诺的窗口。
|
|
1236
|
+
return ready({ lease: stored, changed: false, retention: [] });
|
|
1237
|
+
}
|
|
1238
|
+
const lease = { ...stored, phase, terminalAt: nowIso(now), updatedAt: nowIso(now) };
|
|
1239
|
+
const ledger = readRetention(paths);
|
|
1240
|
+
const promised = promisedWindowIds(paths);
|
|
1241
|
+
const retention = [];
|
|
1242
|
+
for (const releaseId of stored.releaseIds ?? []) {
|
|
1243
|
+
if (promised.has(releaseId)) continue; // 已有承诺窗口(当前/被替代):不重算、不缩短
|
|
1244
|
+
if (extendCandidateRetention(ledger, releaseId, now + retentionMs, { now, reason: `lease-${phase}`, profileKey: stored.profileKey })) {
|
|
1245
|
+
retention.push({ releaseId, retainUntil: nowIso(now + retentionMs) });
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
// 先落保留窗口、再标记终态:中途崩溃也不会让候选失去承诺的窗口。
|
|
1249
|
+
writeRetention(paths, ledger);
|
|
1250
|
+
atomicWriteJson(leasePath(paths, leaseId), lease);
|
|
1251
|
+
return ready({ lease, changed: true, retention });
|
|
1252
|
+
});
|
|
1253
|
+
} catch (error) {
|
|
1254
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** 读租约(诊断/测试用)。 */
|
|
1259
|
+
export function readReleaseLease({ updateDir, leaseId } = {}) {
|
|
1260
|
+
try {
|
|
1261
|
+
const paths = carrierPaths(updateDir);
|
|
1262
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
1263
|
+
const inspected = inspectOwnership(paths);
|
|
1264
|
+
if (!inspected.ok) return inspected;
|
|
1265
|
+
if (typeof leaseId !== "string" || !LEASE_ID.test(leaseId)) return failed(CARRIER_STATUS.INVALID, "lease-id-invalid");
|
|
1266
|
+
const lease = readLease(paths, leaseId);
|
|
1267
|
+
if (!lease) return failed(CARRIER_STATUS.NOT_READY, "lease-unknown", { leaseId });
|
|
1268
|
+
return ready({ lease });
|
|
1269
|
+
} catch (error) {
|
|
1270
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// ── 发布(提交协议) ─────────────────────────────────────────────────────────
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* 把已校验的暂存提交为一次发布:发布不可变对象 → 发布 release 记录 → 最后原子更新
|
|
1278
|
+
* 本 profile 的 current 指针。全程在一把提交锁内;任一步失败都不产生可服务半成品。
|
|
1279
|
+
*
|
|
1280
|
+
* @param {object} input
|
|
1281
|
+
* @param {string} input.updateDir
|
|
1282
|
+
* @param {string} input.stagingId 由 `stageRelease` 返回
|
|
1283
|
+
* @param {string} input.profileKey 已验证的当前 profile 身份(缺失/非法 → 不猜,返回不可用)
|
|
1284
|
+
* @param {string} [input.profileDir] profile 目录(GC 存活性核对用;建议传绝对路径)
|
|
1285
|
+
* @param {object} [input.lease] 上层更新租约 { generation, phase, leaseId? };
|
|
1286
|
+
* 终态/代际不符的写者不能提交指针(固定顺序"更新租约 → 提交锁")
|
|
1287
|
+
* @param {string} [input.lockToken] 已持锁时传入(重入)
|
|
1288
|
+
* @param {boolean} [input.requireLease] 为 true 时必须有 lease
|
|
1289
|
+
* @param {boolean} [input.select=true] 为 false 时只发布 release 记录(更新目标候选),
|
|
1290
|
+
* 不切换 current;候选由租约保护,健康确认后再用 `selectRelease` 切换。
|
|
1291
|
+
* @param {number} [input.retentionMs] 被替代后的保留窗口(默认 24h)
|
|
1292
|
+
*/
|
|
1293
|
+
export function publishStagedRelease({
|
|
1294
|
+
updateDir,
|
|
1295
|
+
stagingId,
|
|
1296
|
+
profileKey,
|
|
1297
|
+
profileDir: profileDirValue = "",
|
|
1298
|
+
lease,
|
|
1299
|
+
lockToken = "",
|
|
1300
|
+
requireLease = false,
|
|
1301
|
+
select = true,
|
|
1302
|
+
now = Date.now(),
|
|
1303
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
1304
|
+
} = {}) {
|
|
1305
|
+
try {
|
|
1306
|
+
const paths = carrierPaths(updateDir);
|
|
1307
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
1308
|
+
const key = validProfileKey(profileKey);
|
|
1309
|
+
if (!key.ok) return key;
|
|
1310
|
+
if (typeof stagingId !== "string" || !STAGING_ID.test(stagingId)) return failed(CARRIER_STATUS.INVALID, "staging-id-invalid");
|
|
1311
|
+
if (profileDirValue !== "" && (typeof profileDirValue !== "string" || !isAbsolute(profileDirValue))) {
|
|
1312
|
+
return failed(CARRIER_STATUS.INVALID, "profile-dir-invalid");
|
|
1313
|
+
}
|
|
1314
|
+
if (lease === undefined || lease === null) {
|
|
1315
|
+
if (requireLease) return failed(CARRIER_STATUS.UNUSABLE, "lease-required");
|
|
1316
|
+
}
|
|
1317
|
+
return runLocked({ updateDir, now, lockToken }, (locked) => {
|
|
1318
|
+
if (lease !== undefined && lease !== null) {
|
|
1319
|
+
const leaseCheck = validateUpdateLease(locked, lease);
|
|
1320
|
+
if (!leaseCheck.ok) return leaseCheck;
|
|
1321
|
+
}
|
|
1322
|
+
const dir = stagingDir(locked, stagingId);
|
|
1323
|
+
const staged = readJson(join(dir, "staged.json"));
|
|
1324
|
+
if (!staged || staged.stagingId !== stagingId || !HEX64.test(staged.releaseId ?? "")) {
|
|
1325
|
+
return failed(CARRIER_STATUS.NOT_READY, "staging-unknown", { stagingId });
|
|
1326
|
+
}
|
|
1327
|
+
const identity = staged.identity;
|
|
1328
|
+
// 提交前再核暂存完整性(暂存可能被外部破坏;不合格就绝不进入发布)。
|
|
1329
|
+
const appCheck = verifyFile(join(dir, OBJECT_NAMES.apk), identity.app);
|
|
1330
|
+
if (!appCheck.ok) {
|
|
1331
|
+
return failed(CARRIER_STATUS.MISMATCH, "staging-integrity-mismatch", { stage: "app", detail: appCheck.reason });
|
|
1332
|
+
}
|
|
1333
|
+
let pluginCheck = null;
|
|
1334
|
+
if (staged.plugin) {
|
|
1335
|
+
pluginCheck = verifyFile(join(dir, OBJECT_NAMES.plugin), identity.plugin);
|
|
1336
|
+
if (!pluginCheck.ok) {
|
|
1337
|
+
return failed(CARRIER_STATUS.MISMATCH, "staging-integrity-mismatch", { stage: "plugin", detail: pluginCheck.reason });
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
const appObject = publishObject({ paths: locked, kind: "apk", expected: identity.app, stagedFile: join(dir, OBJECT_NAMES.apk) });
|
|
1341
|
+
if (!appObject.ok) return appObject;
|
|
1342
|
+
let pluginObject = null;
|
|
1343
|
+
if (staged.plugin) {
|
|
1344
|
+
pluginObject = publishObject({ paths: locked, kind: "plugin", expected: identity.plugin, stagedFile: join(dir, OBJECT_NAMES.plugin) });
|
|
1345
|
+
if (!pluginObject.ok) return pluginObject;
|
|
1346
|
+
}
|
|
1347
|
+
const objects = {
|
|
1348
|
+
apk: { sha256: identity.app.sha256, size: identity.app.size, file: OBJECT_NAMES.apk, present: true },
|
|
1349
|
+
plugin: pluginObject
|
|
1350
|
+
? { sha256: identity.plugin.sha256, size: identity.plugin.size, file: OBJECT_NAMES.plugin, present: true }
|
|
1351
|
+
: { sha256: identity.plugin.sha256, size: identity.plugin.size, file: OBJECT_NAMES.plugin, present: false },
|
|
1352
|
+
};
|
|
1353
|
+
const record = publishReleaseRecord({
|
|
1354
|
+
paths: locked,
|
|
1355
|
+
releaseId: staged.releaseId,
|
|
1356
|
+
identity,
|
|
1357
|
+
manifest: staged.manifest,
|
|
1358
|
+
objects,
|
|
1359
|
+
now,
|
|
1360
|
+
});
|
|
1361
|
+
if (!record.ok) return record;
|
|
1362
|
+
let pointer = { ok: true, changed: false, pointer: readPointer(locked, key.profileKey) };
|
|
1363
|
+
if (select) {
|
|
1364
|
+
pointer = commitProfilePointer(locked, key.profileKey, {
|
|
1365
|
+
releaseId: staged.releaseId,
|
|
1366
|
+
identity,
|
|
1367
|
+
profileDir: profileDirValue,
|
|
1368
|
+
now,
|
|
1369
|
+
retentionMs,
|
|
1370
|
+
});
|
|
1371
|
+
if (!pointer.ok) return pointer;
|
|
1372
|
+
}
|
|
1373
|
+
cleanupStagingDir(dir);
|
|
1374
|
+
return ready({
|
|
1375
|
+
releaseId: staged.releaseId,
|
|
1376
|
+
identity,
|
|
1377
|
+
profileKey: key.profileKey,
|
|
1378
|
+
selected: select,
|
|
1379
|
+
changed: pointer.changed,
|
|
1380
|
+
pointer: pointer.pointer,
|
|
1381
|
+
objects,
|
|
1382
|
+
reuse: { releaseRecord: record.reused, appObject: appObject.reused, pluginObject: pluginObject?.reused ?? null },
|
|
1383
|
+
});
|
|
1384
|
+
});
|
|
1385
|
+
} catch (error) {
|
|
1386
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** 一步完成"暂存 → 校验 → 提交"(失败时清理暂存,不留可服务半成品)。 */
|
|
1391
|
+
export function publishRelease(input = {}) {
|
|
1392
|
+
const staged = stageRelease(input);
|
|
1393
|
+
if (!staged.ok) return staged;
|
|
1394
|
+
const published = publishStagedRelease({ ...input, stagingId: staged.stagingId });
|
|
1395
|
+
if (!published.ok) {
|
|
1396
|
+
discardStagedRelease({ updateDir: input.updateDir, stagingId: staged.stagingId, now: input.now });
|
|
1397
|
+
return published;
|
|
1398
|
+
}
|
|
1399
|
+
return { ...published, staging: { stagingId: staged.stagingId, source: staged.source, app: staged.app, plugin: staged.plugin } };
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/**
|
|
1403
|
+
* 把一个已发布的 release 选为某 profile 的当前发布(健康确认后 / 回滚重选时用)。
|
|
1404
|
+
* 必须验证 release 记录与 APK 对象都在(当前入口不能指向缺失对象),指针最后原子更新。
|
|
1405
|
+
*/
|
|
1406
|
+
export function selectRelease({
|
|
1407
|
+
updateDir,
|
|
1408
|
+
profileKey,
|
|
1409
|
+
releaseId,
|
|
1410
|
+
profileDir: profileDirValue = "",
|
|
1411
|
+
lease,
|
|
1412
|
+
lockToken = "",
|
|
1413
|
+
requireLease = false,
|
|
1414
|
+
now = Date.now(),
|
|
1415
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
1416
|
+
} = {}) {
|
|
1417
|
+
try {
|
|
1418
|
+
const paths = carrierPaths(updateDir);
|
|
1419
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
1420
|
+
const key = validProfileKey(profileKey);
|
|
1421
|
+
if (!key.ok) return key;
|
|
1422
|
+
if (typeof releaseId !== "string" || !HEX64.test(releaseId)) return failed(CARRIER_STATUS.INVALID, "release-id-invalid");
|
|
1423
|
+
if (profileDirValue !== "" && (typeof profileDirValue !== "string" || !isAbsolute(profileDirValue))) {
|
|
1424
|
+
return failed(CARRIER_STATUS.INVALID, "profile-dir-invalid");
|
|
1425
|
+
}
|
|
1426
|
+
if ((lease === undefined || lease === null) && requireLease) return failed(CARRIER_STATUS.UNUSABLE, "lease-required");
|
|
1427
|
+
return runLocked({ updateDir, now, lockToken }, (locked) => {
|
|
1428
|
+
if (lease !== undefined && lease !== null) {
|
|
1429
|
+
const leaseCheck = validateUpdateLease(locked, lease);
|
|
1430
|
+
if (!leaseCheck.ok) return leaseCheck;
|
|
1431
|
+
}
|
|
1432
|
+
const record = readJson(join(releaseDir(locked, releaseId), "release.json"));
|
|
1433
|
+
if (!record) {
|
|
1434
|
+
if (existsSync(join(releaseDir(locked, releaseId), "tombstone.json"))) {
|
|
1435
|
+
return failed(CARRIER_STATUS.EXPIRED, "release-expired", { releaseId });
|
|
1436
|
+
}
|
|
1437
|
+
return failed(CARRIER_STATUS.NOT_READY, "release-unknown", { releaseId });
|
|
1438
|
+
}
|
|
1439
|
+
const objectCheck = verifyFile(objectPath(locked, "apk", record.identity.app.sha256), record.identity.app, { hash: false });
|
|
1440
|
+
if (!objectCheck.ok) {
|
|
1441
|
+
return failed(CARRIER_STATUS.NOT_READY, "app-object-missing", { releaseId, detail: objectCheck.reason });
|
|
1442
|
+
}
|
|
1443
|
+
const pointer = commitProfilePointer(locked, key.profileKey, {
|
|
1444
|
+
releaseId,
|
|
1445
|
+
identity: record.identity,
|
|
1446
|
+
profileDir: profileDirValue,
|
|
1447
|
+
now,
|
|
1448
|
+
retentionMs,
|
|
1449
|
+
});
|
|
1450
|
+
if (!pointer.ok) return pointer;
|
|
1451
|
+
return ready({
|
|
1452
|
+
releaseId,
|
|
1453
|
+
identity: record.identity,
|
|
1454
|
+
profileKey: key.profileKey,
|
|
1455
|
+
changed: pointer.changed,
|
|
1456
|
+
pointer: pointer.pointer,
|
|
1457
|
+
});
|
|
1458
|
+
});
|
|
1459
|
+
} catch (error) {
|
|
1460
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
// ── 读取与打开(服务面) ─────────────────────────────────────────────────────
|
|
1465
|
+
|
|
1466
|
+
/** 解析一个已知 release 记录(不校验对象字节)。 */
|
|
1467
|
+
export function resolveRelease({ updateDir, releaseId } = {}) {
|
|
1468
|
+
try {
|
|
1469
|
+
const paths = carrierPaths(updateDir);
|
|
1470
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
1471
|
+
const inspected = inspectOwnership(paths);
|
|
1472
|
+
if (!inspected.ok) return inspected;
|
|
1473
|
+
if (typeof releaseId !== "string" || !HEX64.test(releaseId)) return failed(CARRIER_STATUS.INVALID, "release-id-invalid");
|
|
1474
|
+
const record = readJson(join(releaseDir(paths, releaseId), "release.json"));
|
|
1475
|
+
if (record) {
|
|
1476
|
+
return ready({ releaseId, identity: record.identity, manifest: record.manifest, objects: record.objects, record });
|
|
1477
|
+
}
|
|
1478
|
+
if (existsSync(join(releaseDir(paths, releaseId), "tombstone.json"))) {
|
|
1479
|
+
return failed(CARRIER_STATUS.EXPIRED, "release-expired", { releaseId });
|
|
1480
|
+
}
|
|
1481
|
+
return failed(CARRIER_STATUS.NOT_READY, "release-unknown", { releaseId });
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/**
|
|
1488
|
+
* 解析 release 内的不可变对象:
|
|
1489
|
+
* - 记录不存在 → not-ready(未知)或 expired(墓碑,对应 410);
|
|
1490
|
+
* - 记录存在但对象缺失 → not-ready(对应 503,不是 404);
|
|
1491
|
+
* - 请求的对象哈希与记录不符 → mismatch(固定目标只代表原字节);
|
|
1492
|
+
* - `verify:true` 时复算字节哈希(受控复核,不在每个请求上做)。
|
|
1493
|
+
*/
|
|
1494
|
+
export function resolveReleaseObject({ updateDir, releaseId, sha256, kind = "apk", verify = false } = {}) {
|
|
1495
|
+
try {
|
|
1496
|
+
if (!OBJECT_KINDS.includes(kind)) return failed(CARRIER_STATUS.INVALID, "object-kind-invalid");
|
|
1497
|
+
const resolved = resolveRelease({ updateDir, releaseId });
|
|
1498
|
+
if (!resolved.ok) return resolved;
|
|
1499
|
+
const expected = resolved.identity?.[kind === "apk" ? "app" : "plugin"];
|
|
1500
|
+
if (!expected || !HEX64.test(expected.sha256 ?? "")) {
|
|
1501
|
+
return failed(CARRIER_STATUS.MISMATCH, "release-identity-invalid", { releaseId });
|
|
1502
|
+
}
|
|
1503
|
+
if (sha256 !== undefined && sha256 !== null) {
|
|
1504
|
+
if (typeof sha256 !== "string" || !HEX64.test(sha256.trim().toLowerCase())) {
|
|
1505
|
+
return failed(CARRIER_STATUS.INVALID, "object-sha256-invalid");
|
|
1506
|
+
}
|
|
1507
|
+
if (sha256.trim().toLowerCase() !== expected.sha256) {
|
|
1508
|
+
return failed(CARRIER_STATUS.MISMATCH, "object-identity-mismatch", { releaseId, kind });
|
|
1509
|
+
}
|
|
1510
|
+
}
|
|
1511
|
+
const file = objectPath(carrierPaths(updateDir), kind, expected.sha256);
|
|
1512
|
+
if (!existsSync(file)) {
|
|
1513
|
+
return failed(CARRIER_STATUS.NOT_READY, kind === "plugin" ? "plugin-object-not-imported" : "object-missing", {
|
|
1514
|
+
releaseId,
|
|
1515
|
+
kind,
|
|
1516
|
+
sha256: expected.sha256,
|
|
1517
|
+
});
|
|
1518
|
+
}
|
|
1519
|
+
const checked = verifyFile(file, expected, { hash: verify });
|
|
1520
|
+
if (!checked.ok) {
|
|
1521
|
+
if (checked.reason === "size-mismatch") {
|
|
1522
|
+
return failed(CARRIER_STATUS.MISMATCH, "object-size-mismatch", { releaseId, kind, expectedSize: expected.size, actualSize: checked.actualSize });
|
|
1523
|
+
}
|
|
1524
|
+
if (checked.reason === "hash-mismatch") {
|
|
1525
|
+
return failed(CARRIER_STATUS.MISMATCH, "object-hash-mismatch", { releaseId, kind, expected: expected.sha256, actual: checked.actual });
|
|
1526
|
+
}
|
|
1527
|
+
return { ...checked, releaseId, kind };
|
|
1528
|
+
}
|
|
1529
|
+
return ready({
|
|
1530
|
+
releaseId,
|
|
1531
|
+
kind,
|
|
1532
|
+
path: file,
|
|
1533
|
+
size: expected.size,
|
|
1534
|
+
sha256: expected.sha256,
|
|
1535
|
+
identity: resolved.identity,
|
|
1536
|
+
manifest: resolved.manifest,
|
|
1537
|
+
manifestPath: join(releaseDir(carrierPaths(updateDir), releaseId), "manifest.json"),
|
|
1538
|
+
record: resolved.record,
|
|
1539
|
+
});
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
/**
|
|
1546
|
+
* 打开一个固定目标对象。返回的句柄在 `close()` 前保护该对象不被 GC 删除;
|
|
1547
|
+
* 句柄本身可用于流式读取(调用方用 `path`/`size` 建流,不在这里同步读整文件)。
|
|
1548
|
+
*/
|
|
1549
|
+
export function openReleaseObject({ updateDir, releaseId, sha256, kind = "apk", verify = false } = {}) {
|
|
1550
|
+
try {
|
|
1551
|
+
const resolved = resolveReleaseObject({ updateDir, releaseId, sha256, kind, verify });
|
|
1552
|
+
if (!resolved.ok) return resolved;
|
|
1553
|
+
registerPin(kind, resolved.sha256);
|
|
1554
|
+
let closed = false;
|
|
1555
|
+
const handle = {
|
|
1556
|
+
releaseId: resolved.releaseId,
|
|
1557
|
+
kind: resolved.kind,
|
|
1558
|
+
path: resolved.path,
|
|
1559
|
+
size: resolved.size,
|
|
1560
|
+
sha256: resolved.sha256,
|
|
1561
|
+
identity: resolved.identity,
|
|
1562
|
+
manifest: resolved.manifest,
|
|
1563
|
+
manifestPath: resolved.manifestPath,
|
|
1564
|
+
close() {
|
|
1565
|
+
if (closed) return;
|
|
1566
|
+
closed = true;
|
|
1567
|
+
releasePin(kind, resolved.sha256);
|
|
1568
|
+
},
|
|
1569
|
+
};
|
|
1570
|
+
return ready({ handle });
|
|
1571
|
+
} catch (error) {
|
|
1572
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** 本 profile 的当前发布(用于无参数首装入口):指针 → release 记录 → APK 对象。 */
|
|
1577
|
+
export function readCurrentRelease({ updateDir, profileKey, verify = false } = {}) {
|
|
1578
|
+
try {
|
|
1579
|
+
const paths = carrierPaths(updateDir);
|
|
1580
|
+
if (!paths) return failed(CARRIER_STATUS.UNUSABLE, "update-dir-missing");
|
|
1581
|
+
const inspected = inspectOwnership(paths);
|
|
1582
|
+
if (!inspected.ok) return inspected;
|
|
1583
|
+
const key = validProfileKey(profileKey);
|
|
1584
|
+
if (!key.ok) return key;
|
|
1585
|
+
const pointer = readPointer(paths, key.profileKey);
|
|
1586
|
+
if (!pointer) return failed(CARRIER_STATUS.NOT_READY, "no-current-release", { profileKey: key.profileKey });
|
|
1587
|
+
const releaseId = pointer.current.releaseId;
|
|
1588
|
+
const resolved = resolveRelease({ updateDir, releaseId });
|
|
1589
|
+
if (!resolved.ok) {
|
|
1590
|
+
if (resolved.status === CARRIER_STATUS.NOT_READY) {
|
|
1591
|
+
return failed(CARRIER_STATUS.NOT_READY, "current-release-missing", { profileKey: key.profileKey, releaseId });
|
|
1592
|
+
}
|
|
1593
|
+
if (resolved.status === CARRIER_STATUS.EXPIRED) {
|
|
1594
|
+
return failed(CARRIER_STATUS.EXPIRED, "current-release-expired", { profileKey: key.profileKey, releaseId });
|
|
1595
|
+
}
|
|
1596
|
+
return resolved;
|
|
1597
|
+
}
|
|
1598
|
+
const app = resolveReleaseObject({ updateDir, releaseId, sha256: resolved.identity.app.sha256, kind: "apk", verify });
|
|
1599
|
+
if (!app.ok) {
|
|
1600
|
+
if (app.status === CARRIER_STATUS.NOT_READY) {
|
|
1601
|
+
return failed(CARRIER_STATUS.NOT_READY, "app-object-missing", { profileKey: key.profileKey, releaseId });
|
|
1602
|
+
}
|
|
1603
|
+
return app;
|
|
1604
|
+
}
|
|
1605
|
+
const plugin = resolveReleaseObject({ updateDir, releaseId, sha256: resolved.identity.plugin.sha256, kind: "plugin", verify: false });
|
|
1606
|
+
return ready({
|
|
1607
|
+
profileKey: key.profileKey,
|
|
1608
|
+
releaseId,
|
|
1609
|
+
identity: resolved.identity,
|
|
1610
|
+
manifest: resolved.manifest,
|
|
1611
|
+
app: { path: app.path, size: app.size, sha256: app.sha256 },
|
|
1612
|
+
plugin: plugin.ok
|
|
1613
|
+
? { path: plugin.path, size: plugin.size, sha256: plugin.sha256 }
|
|
1614
|
+
: { available: false, reason: plugin.reason ?? "plugin-object-not-imported" },
|
|
1615
|
+
pointer,
|
|
1616
|
+
});
|
|
1617
|
+
} catch (error) {
|
|
1618
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
/** 打开本 profile 当前发布的 APK(首装入口;句柄保护对象不被 GC 删除)。 */
|
|
1623
|
+
export function openCurrentApp({ updateDir, profileKey, verify = false } = {}) {
|
|
1624
|
+
const current = readCurrentRelease({ updateDir, profileKey, verify });
|
|
1625
|
+
if (!current.ok) return current;
|
|
1626
|
+
const opened = openReleaseObject({ updateDir, releaseId: current.releaseId, sha256: current.identity.app.sha256, kind: "apk" });
|
|
1627
|
+
if (!opened.ok) return opened;
|
|
1628
|
+
return { ...opened, profileKey: current.profileKey, pointer: current.pointer, manifest: current.manifest };
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
// ── 保留账本 ─────────────────────────────────────────────────────────────────
|
|
1632
|
+
|
|
1633
|
+
function readRetention(paths) {
|
|
1634
|
+
const doc = readJson(paths.retentionFile);
|
|
1635
|
+
if (!doc || doc.schema !== 1 || typeof doc.entries !== "object" || !doc.entries) {
|
|
1636
|
+
return { schema: 1, entries: {} };
|
|
1637
|
+
}
|
|
1638
|
+
return { schema: 1, entries: { ...doc.entries } };
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
function writeRetention(paths, ledger) {
|
|
1642
|
+
const entries = ledger.entries ?? {};
|
|
1643
|
+
if (Object.keys(entries).length === 0) {
|
|
1644
|
+
rmSync(paths.retentionFile, { force: true });
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
atomicWriteJson(paths.retentionFile, { schema: 1, entries });
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
/** 给"从未成为当前发布"的候选追加保留窗口;已有窗口时不缩短、不重置。 */
|
|
1651
|
+
function extendCandidateRetention(ledger, releaseId, until, { now, reason, profileKey }) {
|
|
1652
|
+
const existing = ledger.entries[releaseId];
|
|
1653
|
+
const existingUntil = existing ? Date.parse(existing.retainUntil ?? "") : Number.NaN;
|
|
1654
|
+
if (Number.isFinite(existingUntil) && existingUntil >= until) return false;
|
|
1655
|
+
ledger.entries[releaseId] = {
|
|
1656
|
+
releaseId,
|
|
1657
|
+
retainUntil: nowIso(until),
|
|
1658
|
+
updatedAt: nowIso(now),
|
|
1659
|
+
reason,
|
|
1660
|
+
profileKey: profileKey ?? null,
|
|
1661
|
+
};
|
|
1662
|
+
return true;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/** 当前指针/被替代窗口覆盖的 release 集合(用于判定"已有承诺窗口")。 */
|
|
1666
|
+
function promisedWindowIds(paths) {
|
|
1667
|
+
const ids = new Set();
|
|
1668
|
+
for (const key of listSubdirs(paths.profiles)) {
|
|
1669
|
+
if (!PROFILE_KEY.test(key)) continue;
|
|
1670
|
+
const pointer = readPointer(paths, key);
|
|
1671
|
+
if (!pointer) continue;
|
|
1672
|
+
// 已确认遗弃(profile 目录/package.json 不在)的指针不算承诺:GC 会先写墓碑再释放引用。
|
|
1673
|
+
if (typeof pointer.profileDir === "string" && pointer.profileDir !== "" && !profileDirAlive(pointer.profileDir)) continue;
|
|
1674
|
+
if (pointer.current?.releaseId) ids.add(pointer.current.releaseId);
|
|
1675
|
+
for (const entry of Array.isArray(pointer.superseded) ? pointer.superseded : []) {
|
|
1676
|
+
const until = Date.parse(entry?.retainUntil ?? "");
|
|
1677
|
+
if (entry?.releaseId && Number.isFinite(until)) ids.add(entry.releaseId);
|
|
1678
|
+
}
|
|
1679
|
+
}
|
|
1680
|
+
return ids;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// ── GC ───────────────────────────────────────────────────────────────────────
|
|
1684
|
+
|
|
1685
|
+
function profileDirAlive(dir) {
|
|
1686
|
+
if (typeof dir !== "string" || dir === "") return false;
|
|
1687
|
+
try {
|
|
1688
|
+
return statSync(join(dir, "package.json")).isFile();
|
|
1689
|
+
} catch {
|
|
1690
|
+
return false;
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function profileAliveFor(pointer, key, resolver) {
|
|
1695
|
+
if (typeof resolver === "function") return resolver(key, pointer) !== false;
|
|
1696
|
+
if (typeof pointer?.profileDir === "string" && pointer.profileDir !== "") return profileDirAlive(pointer.profileDir);
|
|
1697
|
+
// 没有记录 profile 目录时无法判定"遗弃":保守视为存活,不接管、不回收引用。
|
|
1698
|
+
return true;
|
|
1699
|
+
}
|
|
1700
|
+
|
|
1701
|
+
function releaseRecordValid(record, releaseId) {
|
|
1702
|
+
if (!record || typeof record !== "object") return false;
|
|
1703
|
+
if (record.releaseId !== releaseId) return false;
|
|
1704
|
+
try {
|
|
1705
|
+
const identity = normalizeReleaseIdentity(record.identity);
|
|
1706
|
+
return JSON.stringify(identity) === JSON.stringify(record.identity);
|
|
1707
|
+
} catch {
|
|
1708
|
+
return false;
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
/**
|
|
1713
|
+
* 回收托管区:只删本 module 的托管对象,人工区/未知文件永不触碰。
|
|
1714
|
+
*
|
|
1715
|
+
* 顺序(与设计稿 §5.8 一致):
|
|
1716
|
+
* 1. 核对每个 profile 存活性;遗弃指针**先写墓碑**再释放引用;
|
|
1717
|
+
* 2. 汇总所有存活 profile 的当前引用 + 真实保留截止(取并集);
|
|
1718
|
+
* 3. 收敛死/超时租约(先确认不再是有效引用),为候选记录保留窗口;
|
|
1719
|
+
* 4. 有效租约 + 保留账本 + 已打开句柄取保护并集;
|
|
1720
|
+
* 5. 过期 release:先写墓碑再删记录/对象;共享对象仍被任何存活引用使用时保留;
|
|
1721
|
+
* 6. 未提交暂存按年龄回收(只回收完全属于本 module 的暂存目录)。
|
|
1722
|
+
*
|
|
1723
|
+
* @param {object} input
|
|
1724
|
+
* @param {string} input.updateDir
|
|
1725
|
+
* @param {number} [input.now]
|
|
1726
|
+
* @param {number} [input.retentionMs] 保留窗口(默认 24h)
|
|
1727
|
+
* @param {number} [input.stagingTtlMs] 暂存残留回收年龄(默认 1h)
|
|
1728
|
+
* @param {number} [input.leaseTtlMs] 租约心跳有效期(默认 10min)
|
|
1729
|
+
* @param {Function} [input.isProfileAlive] (profileKey, pointer) => boolean;默认按 pointer.profileDir 核对
|
|
1730
|
+
* @param {string} [input.lockToken] 已持锁时传入
|
|
1731
|
+
*/
|
|
1732
|
+
export function collectGarbage({
|
|
1733
|
+
updateDir,
|
|
1734
|
+
now = Date.now(),
|
|
1735
|
+
retentionMs = DEFAULT_RETENTION_MS,
|
|
1736
|
+
stagingTtlMs = DEFAULT_STAGING_TTL_MS,
|
|
1737
|
+
leaseTtlMs = DEFAULT_LEASE_TTL_MS,
|
|
1738
|
+
isProfileAlive,
|
|
1739
|
+
lockToken = "",
|
|
1740
|
+
} = {}) {
|
|
1741
|
+
try {
|
|
1742
|
+
return runLocked({ updateDir, now, lockToken, create: false }, (paths) => {
|
|
1743
|
+
const report = {
|
|
1744
|
+
now: nowIso(now),
|
|
1745
|
+
profiles: { live: [], tombstoned: [], livenessUnknown: [] },
|
|
1746
|
+
releases: { protected: [], expired: [], droppedSuperseded: [], skipped: [] },
|
|
1747
|
+
leases: { protected: [], converged: [], pruned: [] },
|
|
1748
|
+
objects: { removed: [], protected: [] },
|
|
1749
|
+
staging: { removed: [], skipped: [] },
|
|
1750
|
+
retention: { protected: [], dropped: [] },
|
|
1751
|
+
};
|
|
1752
|
+
const currentIds = new Set();
|
|
1753
|
+
const promisedIds = new Set();
|
|
1754
|
+
// 1) profile 存活性 + 指针引用
|
|
1755
|
+
for (const key of listSubdirs(paths.profiles)) {
|
|
1756
|
+
if (!PROFILE_KEY.test(key)) continue;
|
|
1757
|
+
const pointer = readPointer(paths, key);
|
|
1758
|
+
if (!pointer) continue;
|
|
1759
|
+
const alive = profileAliveFor(pointer, key, isProfileAlive);
|
|
1760
|
+
if (alive instanceof Promise) {
|
|
1761
|
+
// 回调必须是同步判定:GC 持锁期间不做异步/网络等待。
|
|
1762
|
+
return failed(CARRIER_STATUS.UNUSABLE, "profile-liveness-unsupported", { profileKey: key });
|
|
1763
|
+
}
|
|
1764
|
+
if (!alive) {
|
|
1765
|
+
// 先写墓碑,再释放引用(删除 current.json)。
|
|
1766
|
+
const tombstoneFile = join(profileDir(paths, key), "tombstone.json");
|
|
1767
|
+
if (!existsSync(tombstoneFile)) {
|
|
1768
|
+
atomicWriteJson(tombstoneFile, {
|
|
1769
|
+
schema: 1,
|
|
1770
|
+
profileKey: key,
|
|
1771
|
+
profileDir: pointer.profileDir ?? null,
|
|
1772
|
+
tombstonedAt: nowIso(now),
|
|
1773
|
+
reason: "profile-gone",
|
|
1774
|
+
current: pointer.current ?? null,
|
|
1775
|
+
superseded: Array.isArray(pointer.superseded) ? pointer.superseded : [],
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
rmSync(pointerPath(paths, key), { force: true });
|
|
1779
|
+
report.profiles.tombstoned.push(key);
|
|
1780
|
+
continue;
|
|
1781
|
+
}
|
|
1782
|
+
report.profiles.live.push(key);
|
|
1783
|
+
if (pointer.current?.releaseId) currentIds.add(pointer.current.releaseId);
|
|
1784
|
+
const keptSuperseded = [];
|
|
1785
|
+
for (const entry of Array.isArray(pointer.superseded) ? pointer.superseded : []) {
|
|
1786
|
+
const until = Date.parse(entry?.retainUntil ?? "");
|
|
1787
|
+
if (entry?.releaseId && Number.isFinite(until) && until > now) {
|
|
1788
|
+
keptSuperseded.push(entry);
|
|
1789
|
+
promisedIds.add(entry.releaseId);
|
|
1790
|
+
} else if (entry?.releaseId) {
|
|
1791
|
+
report.releases.droppedSuperseded.push({ profileKey: key, releaseId: entry.releaseId });
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
if (keptSuperseded.length !== (Array.isArray(pointer.superseded) ? pointer.superseded.length : 0)) {
|
|
1795
|
+
atomicWriteJson(pointerPath(paths, key), {
|
|
1796
|
+
...pointer,
|
|
1797
|
+
superseded: keptSuperseded,
|
|
1798
|
+
updatedAt: nowIso(now),
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
if (typeof pointer.profileDir !== "string" || pointer.profileDir === "") {
|
|
1802
|
+
report.profiles.livenessUnknown.push(key);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
// 2) 保留账本
|
|
1806
|
+
const ledger = readRetention(paths);
|
|
1807
|
+
let ledgerChanged = false;
|
|
1808
|
+
for (const [releaseId, entry] of Object.entries(ledger.entries)) {
|
|
1809
|
+
const until = Date.parse(entry?.retainUntil ?? "");
|
|
1810
|
+
if (Number.isFinite(until) && until > now) {
|
|
1811
|
+
promisedIds.add(releaseId);
|
|
1812
|
+
report.retention.protected.push(releaseId);
|
|
1813
|
+
} else {
|
|
1814
|
+
delete ledger.entries[releaseId];
|
|
1815
|
+
ledgerChanged = true;
|
|
1816
|
+
report.retention.dropped.push(releaseId);
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
// 3) 租约:有效引用保护;死持有者/超时先收敛再计保留
|
|
1820
|
+
const leaseIds = new Set();
|
|
1821
|
+
const pendingConverged = [];
|
|
1822
|
+
for (const file of listFiles(paths.leases)) {
|
|
1823
|
+
if (!file.endsWith(".json")) continue;
|
|
1824
|
+
const leaseId = file.slice(0, -".json".length);
|
|
1825
|
+
if (!LEASE_ID.test(leaseId)) continue;
|
|
1826
|
+
const lease = readLease(paths, leaseId);
|
|
1827
|
+
if (!lease) continue;
|
|
1828
|
+
if (TERMINAL_PHASE_SET.has(lease.phase)) {
|
|
1829
|
+
const terminalAt = Date.parse(lease.terminalAt ?? lease.updatedAt ?? "");
|
|
1830
|
+
if (!Number.isFinite(terminalAt) || now - terminalAt > retentionMs) {
|
|
1831
|
+
rmSync(leasePath(paths, leaseId), { force: true });
|
|
1832
|
+
report.leases.pruned.push(leaseId);
|
|
1833
|
+
}
|
|
1834
|
+
continue;
|
|
1835
|
+
}
|
|
1836
|
+
const heartbeat = Date.parse(lease.heartbeatAt ?? lease.claimedAt ?? "");
|
|
1837
|
+
const ttl = Number.isSafeInteger(lease.ttlMs) && lease.ttlMs > 0 ? lease.ttlMs : leaseTtlMs;
|
|
1838
|
+
const active = Number.isFinite(heartbeat) && now - heartbeat <= ttl;
|
|
1839
|
+
if (active) {
|
|
1840
|
+
for (const releaseId of lease.releaseIds ?? []) {
|
|
1841
|
+
if (HEX64.test(releaseId ?? "")) leaseIds.add(releaseId);
|
|
1842
|
+
}
|
|
1843
|
+
report.leases.protected.push(leaseId);
|
|
1844
|
+
continue;
|
|
1845
|
+
}
|
|
1846
|
+
// 死持有者/超时:确认不再有效后收敛,并为候选记录保留窗口(基于收敛时刻,不重置已有承诺)。
|
|
1847
|
+
const converged = {
|
|
1848
|
+
...lease,
|
|
1849
|
+
phase: "expired",
|
|
1850
|
+
terminalAt: nowIso(now),
|
|
1851
|
+
updatedAt: nowIso(now),
|
|
1852
|
+
};
|
|
1853
|
+
pendingConverged.push({ leaseId, lease: converged });
|
|
1854
|
+
report.leases.converged.push(leaseId);
|
|
1855
|
+
for (const releaseId of lease.releaseIds ?? []) {
|
|
1856
|
+
if (!HEX64.test(releaseId ?? "")) continue;
|
|
1857
|
+
if (currentIds.has(releaseId) || promisedIds.has(releaseId)) continue;
|
|
1858
|
+
if (extendCandidateRetention(ledger, releaseId, now + retentionMs, { now, reason: "lease-expired", profileKey: lease.profileKey })) {
|
|
1859
|
+
ledgerChanged = true;
|
|
1860
|
+
promisedIds.add(releaseId);
|
|
1861
|
+
report.retention.protected.push(releaseId);
|
|
1862
|
+
}
|
|
1863
|
+
}
|
|
1864
|
+
}
|
|
1865
|
+
// 先落保留账本、再把租约标成终态:崩溃时宁可多保护一轮,也不提前释放候选。
|
|
1866
|
+
if (ledgerChanged) writeRetention(paths, ledger);
|
|
1867
|
+
for (const { leaseId, lease: converged } of pendingConverged) {
|
|
1868
|
+
atomicWriteJson(leasePath(paths, leaseId), converged);
|
|
1869
|
+
}
|
|
1870
|
+
const protectedIds = new Set([...currentIds, ...promisedIds, ...leaseIds]);
|
|
1871
|
+
// 4) release 记录:过期先写墓碑,再删记录;共享对象仍被存活记录使用时保留。
|
|
1872
|
+
const survivingRecords = new Map();
|
|
1873
|
+
for (const name of listSubdirs(paths.releases)) {
|
|
1874
|
+
if (name.startsWith(RELEASE_TMP_PREFIX)) {
|
|
1875
|
+
const dir = join(paths.releases, name);
|
|
1876
|
+
let age = Infinity;
|
|
1877
|
+
try {
|
|
1878
|
+
age = now - lstatSync(dir).mtimeMs;
|
|
1879
|
+
} catch {
|
|
1880
|
+
age = Infinity;
|
|
1881
|
+
}
|
|
1882
|
+
const entries = listDir(dir).map((entry) => entry.name);
|
|
1883
|
+
const ours = entries.every((entry) => entry === "release.json" || entry === "manifest.json" || /\.tmp$/.test(entry));
|
|
1884
|
+
if (age >= stagingTtlMs && ours) {
|
|
1885
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1886
|
+
report.staging.removed.push(`releases/${name}`);
|
|
1887
|
+
} else if (!ours) {
|
|
1888
|
+
report.staging.skipped.push(`releases/${name}`);
|
|
1889
|
+
}
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
if (!HEX64.test(name)) continue; // 未知目录不碰
|
|
1893
|
+
const dir = join(paths.releases, name);
|
|
1894
|
+
const record = readJson(join(dir, "release.json"));
|
|
1895
|
+
if (!record) continue; // 墓碑/未知残片:不删未知文件
|
|
1896
|
+
if (!releaseRecordValid(record, name)) {
|
|
1897
|
+
report.releases.skipped.push(name);
|
|
1898
|
+
continue;
|
|
1899
|
+
}
|
|
1900
|
+
if (protectedIds.has(name)) {
|
|
1901
|
+
survivingRecords.set(name, record);
|
|
1902
|
+
continue;
|
|
1903
|
+
}
|
|
1904
|
+
const tombstoneFile = join(dir, "tombstone.json");
|
|
1905
|
+
if (!existsSync(tombstoneFile)) {
|
|
1906
|
+
atomicWriteJson(tombstoneFile, {
|
|
1907
|
+
schema: 1,
|
|
1908
|
+
releaseId: name,
|
|
1909
|
+
identity: record.identity,
|
|
1910
|
+
expiredAt: nowIso(now),
|
|
1911
|
+
reason: promisedIds.has(name) ? "retention-tombstone" : "retention-expired",
|
|
1912
|
+
});
|
|
1913
|
+
}
|
|
1914
|
+
rmSync(join(dir, "release.json"), { force: true });
|
|
1915
|
+
rmSync(join(dir, "manifest.json"), { force: true });
|
|
1916
|
+
report.releases.expired.push(name);
|
|
1917
|
+
}
|
|
1918
|
+
// 5) 对象:只删"不再被任何存活 release 记录引用、也未被打开句柄保护"的托管对象
|
|
1919
|
+
const protectedObjects = new Set(openObjectPins.keys());
|
|
1920
|
+
for (const record of survivingRecords.values()) {
|
|
1921
|
+
protectedObjects.add(pinKey("apk", record.identity.app.sha256));
|
|
1922
|
+
protectedObjects.add(pinKey("plugin", record.identity.plugin.sha256));
|
|
1923
|
+
}
|
|
1924
|
+
for (const kind of OBJECT_KINDS) {
|
|
1925
|
+
const base = kind === "apk" ? paths.apkObjects : paths.pluginObjects;
|
|
1926
|
+
for (const sha256 of listSubdirs(base)) {
|
|
1927
|
+
if (!HEX64.test(sha256)) continue; // 未知目录不碰
|
|
1928
|
+
const dir = join(base, sha256);
|
|
1929
|
+
const file = join(dir, OBJECT_NAMES[kind]);
|
|
1930
|
+
if (!existsSync(file)) continue;
|
|
1931
|
+
const key = pinKey(kind, sha256);
|
|
1932
|
+
if (protectedObjects.has(key)) {
|
|
1933
|
+
report.objects.protected.push(key);
|
|
1934
|
+
continue;
|
|
1935
|
+
}
|
|
1936
|
+
rmSync(file, { force: true });
|
|
1937
|
+
report.objects.removed.push(key);
|
|
1938
|
+
try {
|
|
1939
|
+
rmdirSync(dir); // 只删空目录;含未知文件则保留
|
|
1940
|
+
} catch {
|
|
1941
|
+
// 非空:未知文件不碰
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
// 6) 未提交暂存:按年龄回收(只回收完全属于本 module 的目录)
|
|
1946
|
+
for (const name of listSubdirs(paths.staging)) {
|
|
1947
|
+
if (!STAGING_ID.test(name)) continue; // 未知目录不碰
|
|
1948
|
+
const dir = join(paths.staging, name);
|
|
1949
|
+
const staged = readJson(join(dir, "staged.json"));
|
|
1950
|
+
let createdAt = staged && Number.isFinite(staged.createdAtMs) ? staged.createdAtMs : Number.NaN;
|
|
1951
|
+
if (!Number.isFinite(createdAt)) {
|
|
1952
|
+
try {
|
|
1953
|
+
createdAt = lstatSync(dir).mtimeMs;
|
|
1954
|
+
} catch {
|
|
1955
|
+
continue;
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
if (now - createdAt < stagingTtlMs) continue;
|
|
1959
|
+
if (!stagingDirIsOurs(dir)) {
|
|
1960
|
+
report.staging.skipped.push(name);
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
rmSync(dir, { recursive: true, force: true });
|
|
1964
|
+
report.staging.removed.push(name);
|
|
1965
|
+
}
|
|
1966
|
+
report.releases.protected = [...survivingRecords.keys()];
|
|
1967
|
+
return ready(report);
|
|
1968
|
+
});
|
|
1969
|
+
} catch (error) {
|
|
1970
|
+
return failed(CARRIER_STATUS.UNUSABLE, "internal-error", { detail: String(error?.message ?? error) });
|
|
1971
|
+
}
|
|
1972
|
+
}
|