@whyshu/argent-engine 0.0.0-stage → 0.6.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/MANIFEST.json +341 -0
- package/engine/VERSION +1 -0
- package/engine/agents/catalog.json +454 -0
- package/engine/configs/_aliases.proposed.json +651 -0
- package/engine/configs/_migration-plan.proposed.json +899 -0
- package/engine/configs/account.json +272 -0
- package/engine/configs/advanced.json +205 -0
- package/engine/configs/host.json +149 -0
- package/engine/configs/hr.json +27 -0
- package/engine/configs/notify.json +264 -0
- package/engine/core/cli/argent.mjs +277 -0
- package/engine/core/contracts/argent.contracts.json +1065 -0
- package/engine/core/contracts/verbs.schema.json +2539 -0
- package/engine/core/lib/argent-datadir.mjs +223 -0
- package/engine/core/lib/audit.mjs +187 -0
- package/engine/core/lib/claim.mjs +258 -0
- package/engine/core/lib/config-catalog.mjs +1172 -0
- package/engine/core/lib/doctor-customer.mjs +577 -0
- package/engine/core/lib/flow-list.mjs +138 -0
- package/engine/core/lib/flow-registry.mjs +48 -0
- package/engine/core/lib/init.mjs +247 -0
- package/engine/core/lib/ledger.mjs +124 -0
- package/engine/core/lib/license-activate.mjs +813 -0
- package/engine/core/lib/license-device.mjs +314 -0
- package/engine/core/lib/license-file.mjs +349 -0
- package/engine/core/lib/license-guard.mjs +123 -0
- package/engine/core/lib/license-ops.mjs +1645 -0
- package/engine/core/lib/license-verdict.mjs +133 -0
- package/engine/core/lib/license.mjs +577 -0
- package/engine/core/lib/notify/dedup.mjs +101 -0
- package/engine/core/lib/notify/feishu.mjs +38 -0
- package/engine/core/lib/notify/index.mjs +95 -0
- package/engine/core/lib/notify/verb.mjs +327 -0
- package/engine/core/lib/notify/webhook.mjs +135 -0
- package/engine/core/lib/notify/wecom.mjs +36 -0
- package/engine/core/lib/python.mjs +145 -0
- package/engine/core/lib/sop.mjs +116 -0
- package/engine/core/lib/versions.mjs +205 -0
- package/engine/core/mcp/client.mjs +67 -0
- package/engine/core/mcp/http.mjs +138 -0
- package/engine/core/mcp/server.mjs +702 -0
- package/engine/scripts/_engine_rules_probe.py +15 -0
- package/engine/scripts/analyze-full-day.py +568 -0
- package/engine/scripts/archive/feishu-bridge.js +246 -0
- package/engine/scripts/argent-agents.mjs +527 -0
- package/engine/scripts/argent-capabilities.mjs +776 -0
- package/engine/scripts/argent-catalog.mjs +186 -0
- package/engine/scripts/argent-datadir.py +62 -0
- package/engine/scripts/argent-doctor.mjs +624 -0
- package/engine/scripts/argent-env-write.mjs +302 -0
- package/engine/scripts/argent-schedule.mjs +620 -0
- package/engine/scripts/argent-setup-check.mjs +369 -0
- package/engine/scripts/argent-upgrade.mjs +1046 -0
- package/engine/scripts/base-bootstrap.sh +576 -0
- package/engine/scripts/billing-mode-daily.sh +13 -0
- package/engine/scripts/build-engine-package.mjs +126 -0
- package/engine/scripts/capability-packs.lock.json +484 -0
- package/engine/scripts/capability_pack.py +1242 -0
- package/engine/scripts/check-1.0-criteria.mjs +355 -0
- package/engine/scripts/check-agent-catalog.mjs +141 -0
- package/engine/scripts/check-billing-mode.sh +378 -0
- package/engine/scripts/check-child-sessions.mjs +83 -0
- package/engine/scripts/check-config-catalog.mjs +677 -0
- package/engine/scripts/check-docs-drift.mjs +522 -0
- package/engine/scripts/check-dsh-web-expose.sh +98 -0
- package/engine/scripts/check-judgment-drift.mjs +403 -0
- package/engine/scripts/check-license.mjs +199 -0
- package/engine/scripts/check-model-auth.py +443 -0
- package/engine/scripts/check-pack-closure.mjs +661 -0
- package/engine/scripts/check-portable-symbols.mjs +116 -0
- package/engine/scripts/check-preset-landing.mjs +124 -0
- package/engine/scripts/check-production-home.mjs +602 -0
- package/engine/scripts/check-role-mapping.mjs +461 -0
- package/engine/scripts/check-sample-redaction.mjs +320 -0
- package/engine/scripts/check-skill-classification.mjs +188 -0
- package/engine/scripts/check-sop-consistency.mjs +431 -0
- package/engine/scripts/check-test-env-safety.mjs +627 -0
- package/engine/scripts/check-version-consistency.mjs +452 -0
- package/engine/scripts/ci-run.sh +558 -0
- package/engine/scripts/collect-evidence.mjs +42 -0
- package/engine/scripts/config-explain.mjs +278 -0
- package/engine/scripts/configure-provider.rb +29 -0
- package/engine/scripts/customer-config-migrate.mjs +179 -0
- package/engine/scripts/customer-preflight.py +892 -0
- package/engine/scripts/customer-readiness.sh +135 -0
- package/engine/scripts/daily-report-inbox.py +242 -0
- package/engine/scripts/daily-report-reconcile.mjs +312 -0
- package/engine/scripts/daily-report.mjs +1310 -0
- package/engine/scripts/daily-report.sh +47 -0
- package/engine/scripts/data/lip-product-dict.json +306 -0
- package/engine/scripts/deliver-customer.mjs +932 -0
- package/engine/scripts/delivery-faces.txt +31 -0
- package/engine/scripts/desktop-trial.sh +146 -0
- package/engine/scripts/dev-window.sh +261 -0
- package/engine/scripts/drill-clean-machine.sh +315 -0
- package/engine/scripts/dsh-api-client.mjs +215 -0
- package/engine/scripts/dsh-backup-scope.sh +83 -0
- package/engine/scripts/dsh-offline-pack.mjs +77 -0
- package/engine/scripts/dsh-overlay-pin.sh +87 -0
- package/engine/scripts/dsh-plane.mjs +153 -0
- package/engine/scripts/dsh-portforward.py +223 -0
- package/engine/scripts/dsh-preflight.mjs +179 -0
- package/engine/scripts/dsh-release-watch-daily.sh +19 -0
- package/engine/scripts/dsh-release-watch.mjs +458 -0
- package/engine/scripts/dsh-remote-check.sh +99 -0
- package/engine/scripts/dsh-restart.sh +179 -0
- package/engine/scripts/dsh-restore.sh +111 -0
- package/engine/scripts/dsh-scope-rewire.mjs +803 -0
- package/engine/scripts/dsh-snapshot.mjs +116 -0
- package/engine/scripts/dsh-upgrade-readiness.mjs +165 -0
- package/engine/scripts/dsh-upgrade.mjs +612 -0
- package/engine/scripts/dsh-url.sh +86 -0
- package/engine/scripts/dsh-web-launch.mjs +155 -0
- package/engine/scripts/ensure-store-windows.py +509 -0
- package/engine/scripts/entitlements.py +458 -0
- package/engine/scripts/env_probe.py +411 -0
- package/engine/scripts/feishu-card.mjs +252 -0
- package/engine/scripts/final-check.mjs +43 -0
- package/engine/scripts/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +735 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +87 -0
- package/engine/scripts/flow-timers.sh +184 -0
- package/engine/scripts/flow-trigger-wake.mjs +284 -0
- package/engine/scripts/flow-trigger.sh +571 -0
- package/engine/scripts/flow_billing.py +354 -0
- package/engine/scripts/flow_capability_stats.py +314 -0
- package/engine/scripts/flow_case.py +194 -0
- package/engine/scripts/flow_claim.py +497 -0
- package/engine/scripts/flow_config.py +1025 -0
- package/engine/scripts/flow_failure.py +588 -0
- package/engine/scripts/flow_learn.py +105 -0
- package/engine/scripts/flow_ledger.py +235 -0
- package/engine/scripts/flow_policy.py +238 -0
- package/engine/scripts/flow_preflight.py +339 -0
- package/engine/scripts/flow_registry.py +341 -0
- package/engine/scripts/flow_retry.py +304 -0
- package/engine/scripts/flow_return_to_ops.py +191 -0
- package/engine/scripts/flow_source.py +315 -0
- package/engine/scripts/flow_sources/__init__.py +53 -0
- package/engine/scripts/flow_sources/cases_json.py +106 -0
- package/engine/scripts/flow_sources/common.py +428 -0
- package/engine/scripts/flow_sources/feishu_base.py +273 -0
- package/engine/scripts/flow_stale.py +185 -0
- package/engine/scripts/flow_status_reconcile.py +344 -0
- package/engine/scripts/flow_triage.py +849 -0
- package/engine/scripts/flow_write_verify.py +496 -0
- package/engine/scripts/fulfillment.js +586 -0
- package/engine/scripts/gen-agent-bundles.mjs +276 -0
- package/engine/scripts/gen-preset-rows.mjs +259 -0
- package/engine/scripts/init-ml.sh +2278 -0
- package/engine/scripts/install-internal-role.sh +190 -0
- package/engine/scripts/install-license.mjs +641 -0
- package/engine/scripts/install-one-shot.mjs +665 -0
- package/engine/scripts/integration_contract.py +220 -0
- package/engine/scripts/integrations/wenyao/adapter.py +98 -0
- package/engine/scripts/integrations/xzh/adapter.py +75 -0
- package/engine/scripts/invoice-mapping.example.json +40 -0
- package/engine/scripts/invoice-table.sh +195 -0
- package/engine/scripts/invoice_carrier_notice.py +279 -0
- package/engine/scripts/invoice_fail_closeout.py +297 -0
- package/engine/scripts/invoice_lip_runner.py +1508 -0
- package/engine/scripts/invoice_lip_runner.py.bak-20260922-1710-freready +1090 -0
- package/engine/scripts/invoice_ready_audit.py +191 -0
- package/engine/scripts/invoice_runner.py +675 -0
- package/engine/scripts/invoice_stageb.py +516 -0
- package/engine/scripts/launchd/com.whyshu.argent-bridge.plist.example +42 -0
- package/engine/scripts/launchd/com.whyshu.fulfillment-poll.plist.example +29 -0
- package/engine/scripts/lease.py +377 -0
- package/engine/scripts/lib/agent-install.mjs +649 -0
- package/engine/scripts/lib/argent-datadir.mjs +16 -0
- package/engine/scripts/lib/argent-datadir.sh +18 -0
- package/engine/scripts/lib/argent_datadir.py +285 -0
- package/engine/scripts/lib/argent_filelock.py +507 -0
- package/engine/scripts/lib/bootcheck-wiring.mjs +510 -0
- package/engine/scripts/lib/cdp_ports.py +197 -0
- package/engine/scripts/lib/customer-env.mjs +102 -0
- package/engine/scripts/lib/daily-report-core.mjs +517 -0
- package/engine/scripts/lib/dep-declaration.mjs +81 -0
- package/engine/scripts/lib/deps-migrate.mjs +310 -0
- package/engine/scripts/lib/dsh-host-resolve.mjs +109 -0
- package/engine/scripts/lib/dsh-scope.mjs +500 -0
- package/engine/scripts/lib/dsh-snapshot-path.mjs +35 -0
- package/engine/scripts/lib/dsh-versions.mjs +96 -0
- package/engine/scripts/lib/dsh-web-probe.mjs +214 -0
- package/engine/scripts/lib/is-direct-run.mjs +61 -0
- package/engine/scripts/lib/patch-compose.mjs +241 -0
- package/engine/scripts/lib/patch-target.mjs +35 -0
- package/engine/scripts/lib/plugin-lifecycle.mjs +128 -0
- package/engine/scripts/lib/plugin-packages.mjs +332 -0
- package/engine/scripts/lib/preset-compat.mjs +127 -0
- package/engine/scripts/lib/preset-install.mjs +575 -0
- package/engine/scripts/lib/preset-mechanism.mjs +56 -0
- package/engine/scripts/lib/preset-sync.mjs +59 -0
- package/engine/scripts/lib/profile-retire.mjs +97 -0
- package/engine/scripts/lib/python-runtime.mjs +190 -0
- package/engine/scripts/lib/registry.mjs +72 -0
- package/engine/scripts/lib/repo-root.sh +56 -0
- package/engine/scripts/lib/schedule-mechanism.mjs +659 -0
- package/engine/scripts/lib/schedule-probe.mjs +835 -0
- package/engine/scripts/lib/scope-pointer.mjs +164 -0
- package/engine/scripts/lib/settings-panels.mjs +816 -0
- package/engine/scripts/lib/stdio_utf8.py +86 -0
- package/engine/scripts/lib/wake-auth.mjs +392 -0
- package/engine/scripts/lib/webdriver_ipc.py +264 -0
- package/engine/scripts/lib/zstd-frames.mjs +104 -0
- package/engine/scripts/license-e2e-drill.mjs +426 -0
- package/engine/scripts/license-gate.mjs +245 -0
- package/engine/scripts/lip_backlog.py +171 -0
- package/engine/scripts/lip_table.py +185 -0
- package/engine/scripts/local-usage.mjs +214 -0
- package/engine/scripts/migrate-providers.mjs +127 -0
- package/engine/scripts/migrate-skills.sh +65 -0
- package/engine/scripts/ml-oauth-setup.mjs +550 -0
- package/engine/scripts/ml-store-ids.json +26 -0
- package/engine/scripts/ml_artifact_attribution.py +333 -0
- package/engine/scripts/ml_capacity_probe.py +1158 -0
- package/engine/scripts/ml_hub_status.py +1556 -0
- package/engine/scripts/ml_schema_check.py +115 -0
- package/engine/scripts/ml_store_ids.py +139 -0
- package/engine/scripts/npm-publish-argent.mjs +499 -0
- package/engine/scripts/npm-publish-argent.sh +20 -0
- package/engine/scripts/orchestrator.py +13811 -0
- package/engine/scripts/pack-plugins.mjs +140 -0
- package/engine/scripts/patches/dsh-llm-deepseek--transport-diagnostics.patch +297 -0
- package/engine/scripts/patches/dsh-session--append-message-shape.patch +15 -0
- package/engine/scripts/patches/dsh-session--tool-result-block-017.patch +28 -0
- package/engine/scripts/patches/dsh-session-format-v0-to-v1--permission-preset-origin.patch +11 -0
- package/engine/scripts/patches/dsh-session-format-v3-to-v4--turn-outside-replace.patch +20 -0
- package/engine/scripts/patches/dsh-session-persistence-jsonl--worker-bundled-migrators.patch +39 -0
- package/engine/scripts/patches/dsh-tools--render-return-shape.patch +50 -0
- package/engine/scripts/patches/verify-bundled-migrators.mjs +79 -0
- package/engine/scripts/patches/verify-hardening.mjs +167 -0
- package/engine/scripts/patches/verify-session-migration.mjs +149 -0
- package/engine/scripts/patches/verify-transport-diagnostics.mjs +227 -0
- package/engine/scripts/plan-role-rename.mjs +125 -0
- package/engine/scripts/plugin-bootcheck.mjs +451 -0
- package/engine/scripts/plugin-composition.json +15 -0
- package/engine/scripts/poc-mcp-host.mjs +455 -0
- package/engine/scripts/poll-fulfillment.sh +295 -0
- package/engine/scripts/poll-invoice.sh +76 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +71 -0
- package/engine/scripts/provider-switch.sh +146 -0
- package/engine/scripts/provider-sync-target.check.mjs +64 -0
- package/engine/scripts/python-runtime.sh +78 -0
- package/engine/scripts/query_runner.py +310 -0
- package/engine/scripts/release-argent.sh +574 -0
- package/engine/scripts/repair-unresolved-tools.mjs +178 -0
- package/engine/scripts/retire-inventory.mjs +40 -0
- package/engine/scripts/run-tests.sh +257 -0
- package/engine/scripts/s1-audit.mjs +85 -0
- package/engine/scripts/self-sign-license.mjs +179 -0
- package/engine/scripts/session-health-scan.mjs +63 -0
- package/engine/scripts/session-repair-tool-result-content.mjs +181 -0
- package/engine/scripts/setup.sh +22 -0
- package/engine/scripts/skill-install.mjs +629 -0
- package/engine/scripts/sku_translate.py +148 -0
- package/engine/scripts/sync-plugins.sh +90 -0
- package/engine/scripts/sync-presets.sh +27 -0
- package/engine/scripts/sync-scripts.sh +445 -0
- package/engine/scripts/telemetry.py +873 -0
- package/engine/scripts/ui-copy-count.mjs +220 -0
- package/engine/scripts/usage-anchor.sh +137 -0
- package/engine/scripts/usage_report.py +531 -0
- package/engine/scripts/validate-preset-schema.mjs +208 -0
- package/engine/scripts/vertical-init.mjs +323 -0
- package/engine/scripts/vertical-lint.mjs +482 -0
- package/engine/scripts/wenyao-browser.sh +74 -0
- package/engine/scripts/wenyao-fixture-export.py +116 -0
- package/engine/scripts/wenyao-invoice-writeback.mjs +540 -0
- package/engine/scripts/wenyao-login.sh +35 -0
- package/engine/scripts/wenyao-template-map.json +292 -0
- package/engine/scripts/wenyao_attach.py +131 -0
- package/engine/scripts/wenyao_download_template.py +144 -0
- package/engine/scripts/wenyao_fill_template.py +568 -0
- package/engine/scripts/wenyao_login.py +145 -0
- package/engine/scripts/wenyao_portal.py +633 -0
- package/engine/scripts/wenyao_recon.py +230 -0
- package/engine/scripts/wenyao_run.py +503 -0
- package/engine/scripts/wenyao_shadow_diff.py +160 -0
- package/engine/scripts/wenyao_skill_contract.py +156 -0
- package/engine/scripts/wenyao_template_fetch.py +175 -0
- package/engine/scripts/win/dsh-web-autostart.ps1 +372 -0
- package/engine/scripts/ziniao-auth-probe.sh +268 -0
- package/engine/scripts/ziniao-keepalive.sh +217 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +121 -0
- package/engine/scripts/ziniao-windows.sh +154 -0
- package/engine/skills/README.md +86 -0
- package/engine/skills/argent-dev/SKILL.md +41 -0
- package/engine/skills/internal.json +37 -0
- package/engine/skills/lktop-pm/SKILL.md +35 -0
- package/engine/skills/lktopapp-dev/SKILL.md +34 -0
- package/engine/skills/ml-config-guide/SKILL.md +50 -0
- package/engine/skills/ml-fulfillment-sop/SKILL.md +178 -0
- package/engine/skills/ml-fulfillment-sop/references/andes-components.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/cdp-port-mapping.md +35 -0
- package/engine/skills/ml-fulfillment-sop/references/feishu-base-fulfillment-schema.md +136 -0
- package/engine/skills/ml-fulfillment-sop/references/multi-window-concurrency.md +69 -0
- package/engine/skills/ml-fulfillment-sop/references/pitfalls.md +77 -0
- package/engine/skills/ml-fulfillment-sop/references/selectors.md +243 -0
- package/engine/skills/ml-fulfillment-sop/scripts/fulfillment.js +586 -0
- package/engine/skills/ml-fulfillment-sop/scripts/poll-and-execute.sh +271 -0
- package/engine/skills/ml-fulfillment-sop/templates/report.md +55 -0
- package/engine/skills/ml-invoice/SKILL.md +506 -0
- package/engine/skills/ml-invoice/references/pitfalls.md +268 -0
- package/engine/skills/ml-invoice/references/portal-flow.md +360 -0
- package/engine/skills/ml-invoice/references/template-fill-contract.md +133 -0
- package/engine/skills/ml-invoice-xzh/SKILL.md +133 -0
- package/engine/skills/ml-invoice-xzh/references/pitfalls.md +230 -0
- package/engine/skills/ml-store-daily-report/SKILL.md +224 -0
- package/engine/skills/ml-store-daily-report/scripts/README.md +25 -0
- package/engine/skills/ml-store-daily-report/scripts/extract.js +691 -0
- package/engine/skills/ml-store-daily-report/scripts/generate.js +299 -0
- package/engine/skills/pet-dev/SKILL.md +42 -0
- package/engine/skills/whyshu-pm/SKILL.md +35 -0
- package/engine/skills/whyshu-web-dev/SKILL.md +50 -0
- package/engine/version.mjs +1 -0
- package/engine/verticals/README.md +103 -0
- package/engine/verticals/_engine-words.baseline.txt +237 -0
- package/engine/verticals/_engine-words.json +19 -0
- package/engine/verticals/mercadolibre/README.md +43 -0
- package/engine/verticals/mercadolibre/config.json +709 -0
- package/engine/verticals/mercadolibre/flows.json +738 -0
- package/engine/verticals/mercadolibre/manifest.json +538 -0
- package/engine/verticals/mercadolibre/migrate-machine-state.mjs +138 -0
- package/engine/verticals/mercadolibre/preflight.py +143 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.failure.md +69 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.md +47 -0
- package/engine/verticals/mercadolibre/prompts/daily-report.scheduled.md +79 -0
- package/engine/verticals/mercadolibre/prompts/full.failure.md +125 -0
- package/engine/verticals/mercadolibre/prompts/full.md +91 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.failure.md +88 -0
- package/engine/verticals/mercadolibre/prompts/wenyao-invoice.md +64 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.failure.md +91 -0
- package/engine/verticals/mercadolibre/prompts/xzh-invoice.md +46 -0
- package/package.json +22 -4
- package/requirements.txt +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,1242 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""能力包版本化(迭代 3-⑨):把一条 capability 打成**可版本化、可装配、装配后能自检**的包。
|
|
3
|
+
|
|
4
|
+
## 问题
|
|
5
|
+
现状是"整仓 `git pull`":想只领"发票能力"做不到;能力与仓库版本绑死;装完也没法在客户机上
|
|
6
|
+
证明"这一包是自洽的"(缺脚本/缺 preset/缺适配器都要等真跑那天才发现)。
|
|
7
|
+
|
|
8
|
+
## 口径
|
|
9
|
+
- **包 = capability**(不是 flow,也不是整仓):`capabilities.<c>` 下的共同声明 + 该能力的
|
|
10
|
+
flow/preset/提示词/技能/集成适配器/脚本。
|
|
11
|
+
- **版本**在注册表里声明:`capabilities.<c>.pack.version`(语义化版本);装配产物里写 `PACK.json`
|
|
12
|
+
(含每个文件的 sha256 + 源提交),因此"客户机上跑的是哪一版"可核对。
|
|
13
|
+
- **装配后必须能自检**:`--build` 结束前,在**产物目录里**真跑
|
|
14
|
+
`flow_config.py --flow <id> --registry <产物>/scripts/flow-requirements.json --lax`
|
|
15
|
+
与 `integration_contract.py --validate`;任一失败 ⇒ 非零退出(把"装完才知道缺东西"提前到装配时)。
|
|
16
|
+
|
|
17
|
+
## 派生 vs 声明(避免第二份真相)
|
|
18
|
+
- **派生**:flow/prompt/preset/适配器/runners/模板/技能引用(扫描提示词里的 `skills/<名>`)。
|
|
19
|
+
- **声明**:`capabilities.<c>.pack.{version,scripts,skills}`(该能力**额外**要带的脚本、要装的技能)
|
|
20
|
+
与顶层 `pack.engine`(共用引擎文件 —— 它是**分发清单**,不是运行时真相)。
|
|
21
|
+
- 派生与声明**都要存在性校验**:声明了却不存在 ⇒ 报错,不许静默少装。
|
|
22
|
+
|
|
23
|
+
用法:
|
|
24
|
+
python3 scripts/capability_pack.py --list [--json]
|
|
25
|
+
python3 scripts/capability_pack.py --describe invoice [--json]
|
|
26
|
+
python3 scripts/capability_pack.py --validate [--json]
|
|
27
|
+
python3 scripts/capability_pack.py --stamp [--allow-downgrade]
|
|
28
|
+
python3 scripts/capability_pack.py --check-stamp [--json] # 只读:锁内摘要 == 当前内容重算摘要
|
|
29
|
+
python3 scripts/capability_pack.py --build invoice --out-dir /tmp/pack-invoice [--force]
|
|
30
|
+
python3 scripts/capability_pack.py --emit-server-manifest --out <whyshu-svr>/src/config/capability-packs.json
|
|
31
|
+
· 默认 **merge**:只刷新注册表认识的能力,清单里它不认识的条目**原样保留**(键序也不动);
|
|
32
|
+
· 要删除不认识的条目须显式 `--prune`(会把将删项打印出来);
|
|
33
|
+
· `--dry-run` 只打印 diff、不写文件(发布前复核用;发布工具已保证不误删,这一步是双保险)。
|
|
34
|
+
|
|
35
|
+
⚠️ 2026-10-06 修(脚枪):`--emit-server-manifest` 旧实现是**整表替换** —— 跑一次就会把
|
|
36
|
+
"清单里有、注册表里没有"的条目(如 `daily-report-lite`)抹掉,还会顺手重写本批不动的条目。
|
|
37
|
+
现在默认 merge,删除必须显式 `--prune`。
|
|
38
|
+
|
|
39
|
+
⚠️ 2026-10-11 修(两条真缺陷,都在盖章期才拦得住):
|
|
40
|
+
① **降版不拦**:并发批在自己的旧文件上继续改 ⇒ diff 一度把三个包**降版**(实测)。
|
|
41
|
+
现在 `--stamp` 见到"新版本 < 锁里的版本"即判红并点名(能力/旧版本/新版本/重建姿势);
|
|
42
|
+
确实要刻意回滚须显式 `--allow-downgrade`(放行但**显著告警**)。
|
|
43
|
+
② **"内容变但版本没变"只在发布期被 `--check-dl` 抓到**:`scripts/flow-requirements.json`
|
|
44
|
+
变了(新增 `failureClasses.capacityFull`)⇒ **每个包的 tgz 都带一份注册表投影** ⇒
|
|
45
|
+
`hr` 的包字节随之改变,而没人提 `hr` 版本 ⇒ 只能等 `--check-dl` 报
|
|
46
|
+
「版本相同但内容摘要不同」。现在锁里给**每个能力**记一个 `digest`
|
|
47
|
+
(口径与 `--emit-server-manifest#sha256` / `--digest-tgz` **逐字一致**,见 `pack_digest()`),
|
|
48
|
+
`--stamp` 时"摘要不同 ∧ 版本未变"即判红;`--check-stamp` 供**批次收口前**离线自检。
|
|
49
|
+
|
|
50
|
+
退出码:0 正常;1 有问题(校验失败 / 装配自检失败 / 拒绝盖章 / 摘要漂移);2 用法或读取失败。
|
|
51
|
+
"""
|
|
52
|
+
from __future__ import annotations
|
|
53
|
+
|
|
54
|
+
import argparse
|
|
55
|
+
import gzip
|
|
56
|
+
import hashlib
|
|
57
|
+
import json
|
|
58
|
+
import os
|
|
59
|
+
import pathlib
|
|
60
|
+
import re
|
|
61
|
+
import shutil
|
|
62
|
+
import tarfile
|
|
63
|
+
import subprocess
|
|
64
|
+
import sys
|
|
65
|
+
|
|
66
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
67
|
+
REPO = HERE.parent
|
|
68
|
+
sys.path.insert(0, str(HERE / "lib"))
|
|
69
|
+
# ⛔ 本进程不种 `.pyc`(2026-10-11 补,**必须在下面 import flow_registry 之前**):
|
|
70
|
+
# `--check-stamp` / `--describe` / `--validate` 都对外声称"只读",而 CPython 默认会在
|
|
71
|
+
# `scripts/__pycache__/` 写下 `flow_registry.*.pyc` ⇒ **只读子命令往被检查的树里写字节**。
|
|
72
|
+
# 与 `_run()` / `_registry_probe()` / `_adapter_files()` 里已有的 `PYTHONDONTWRITEBYTECODE=1`
|
|
73
|
+
# 同一条纪律("别在产物/被检查的树里种 __pycache__"),这里覆盖**本进程自己**那一路。
|
|
74
|
+
# 判据:回归 ⑦ 对整棵夹具树做字节级前后比对(零写入)。
|
|
75
|
+
sys.dont_write_bytecode = True
|
|
76
|
+
from stdio_utf8 import use_utf8_stdio # noqa: E402 出口编码钉 UTF-8(中文 Windows 的 GBK 管道下非 ASCII 会崩)
|
|
77
|
+
REGISTRY = HERE / "flow-requirements.json"
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
from flow_registry import consumer_doc as _registry_doc
|
|
81
|
+
except Exception: # noqa: BLE001 —— 半坏机器上退回单文件(升级链路要能跑)
|
|
82
|
+
def _registry_doc(path=None):
|
|
83
|
+
return json.loads((pathlib.Path(path) if path else REGISTRY).read_text(encoding="utf-8"))
|
|
84
|
+
|
|
85
|
+
LOCK = pathlib.Path(os.environ.get("ARGENT_PACK_LOCK") or (HERE / "capability-packs.lock.json"))
|
|
86
|
+
PROMPTS = HERE / "flow-prompts"
|
|
87
|
+
PRESETS = REPO / "presets"
|
|
88
|
+
SKILLS = REPO / "skills"
|
|
89
|
+
INTEGRATIONS = HERE / "integrations"
|
|
90
|
+
|
|
91
|
+
SEMVER = r"^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def semver_key(v) -> tuple | None:
|
|
95
|
+
"""语义化版本 ⇒ **可比较**的元组;不是语义化版本 ⇒ `None`(⛔ 不猜、不硬比)。
|
|
96
|
+
|
|
97
|
+
为什么需要它(2026-10-11 实测缺陷):`stamp_check()` 此前只判"版本**相等**与否" ——
|
|
98
|
+
相等才查内容,不等就当成"合规提版"直接放行 ⇒ **降版(新 < 旧)与提版走同一条路**,
|
|
99
|
+
并发批次在自己的旧文件上继续改就能把三个包**降版**而全程无红灯。
|
|
100
|
+
|
|
101
|
+
口径:`(major, minor, patch, 是否正式版, 预发布串)`;预发布版按 semver §11 **小于**同号正式版
|
|
102
|
+
(`0.1.7-rc.2 < 0.1.7`),两个预发布之间按字符串比较(本仓只有 `rc.N` 这一种,够用且**可解释**)。
|
|
103
|
+
"""
|
|
104
|
+
m = re.match(r"^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$", str(v or ""))
|
|
105
|
+
if not m:
|
|
106
|
+
return None
|
|
107
|
+
pre = m.group(4)
|
|
108
|
+
return (int(m.group(1)), int(m.group(2)), int(m.group(3)), 0 if pre else 1, pre or "")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_downgrade(new: str, old: str) -> bool:
|
|
112
|
+
"""`new` 是不是**降版**(严格小于 `old`)。任一侧不是语义化版本 ⇒ `False`(不据此判红)。"""
|
|
113
|
+
a, b = semver_key(new), semver_key(old)
|
|
114
|
+
return bool(a and b and a < b)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def load() -> dict:
|
|
118
|
+
return _registry_doc()
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def engine_files(doc: dict) -> list:
|
|
122
|
+
return [str(p) for p in ((doc.get("pack") or {}).get("engine") or [])]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _posix(rel) -> str:
|
|
126
|
+
"""包内相对路径的**唯一归一入口**:一律 POSIX 形态(正斜杠)。
|
|
127
|
+
|
|
128
|
+
为什么必须有它(2026-10-08,Windows 装机验证):包内路径的**全部**下游用法都假定正斜杠 ——
|
|
129
|
+
· 锁文件(`capability-packs.lock.json`)与 `PACK.json#files` 的键是正斜杠;
|
|
130
|
+
· "从路径取业务线 id"(`verticals/<id>/manifest.json`);
|
|
131
|
+
· tgz 成员名(`tarfile` 的 arcname)与 Node 侧 `join(packDir, rel)`。
|
|
132
|
+
而 `str(Path.relative_to(...))` 在 Windows 上给的是**反斜杠** ⇒ 两种故障:
|
|
133
|
+
① 解析当场 IndexError(旧实现按正斜杠切分后取第二段);
|
|
134
|
+
② 反斜杠键与正斜杠键**永不相等** ⇒ 与锁/基线的逐文件比对被**静默绕过**(比崩溃更危险,
|
|
135
|
+
2026-10-08 实测 hr 的 sha 清单就是反斜杠键,因而"没发生比对")。
|
|
136
|
+
⇒ 归一是这一层唯一正确的做法;`PurePosixPath` 顺带压掉 `./`、重复斜杠与尾斜杠。
|
|
137
|
+
⛔ 不要自己拼字符串(先 `os.sep` 替换再 split 会漏掉 `a//b`、`./a` 这类形态)。
|
|
138
|
+
(与 Node 侧 `scripts/argent-capabilities.mjs#normalizePackPath()` **逐字同口径**:
|
|
139
|
+
同一张 sha 表会被两侧分别比对,任一侧口径不同都会变成"永远不比"。)
|
|
140
|
+
"""
|
|
141
|
+
s = str(rel or "")
|
|
142
|
+
if not s:
|
|
143
|
+
return "" # `PurePosixPath("")` 是 `.`:空串必须原样返回空串(与 Node 侧同口径)
|
|
144
|
+
return pathlib.PurePosixPath(s.replace("\\", "/")).as_posix()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _posix_map(files) -> dict:
|
|
148
|
+
"""`{相对路径: sha}` ⇒ 键统一成 POSIX 形态(**历史产物**可能是反斜杠:Windows 上盖过章的锁)。"""
|
|
149
|
+
if not isinstance(files, dict):
|
|
150
|
+
return {}
|
|
151
|
+
return {_posix(k): v for k, v in files.items()}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def digest_tarball(path) -> dict:
|
|
155
|
+
"""算一个**已下载的**能力包 tgz 的整包内容摘要(`--digest-tgz` 的唯一实现)。
|
|
156
|
+
|
|
157
|
+
为什么要它(2026-10-09):发布体检要回答的是"**HTTP 下发的那一个包**,内容是不是清单里写的
|
|
158
|
+
那一个"。只比 md5(传输没坏)不够 —— 包内容与清单 sha256 对不对得上才是判据。为此需要一条
|
|
159
|
+
"从 tgz 重算摘要"的通道。
|
|
160
|
+
|
|
161
|
+
⛔ 为什么不做成"自己 import 本模块再算":`importlib` 加载时 `scripts/` 不在 `sys.path` ⇒
|
|
162
|
+
`import flow_registry` 失败并**静默降级**成未合并的 base 注册表 ⇒ 带流程的能力(full/invoice/
|
|
163
|
+
daily-report)摘要**算错**(2026-10-08 实测)。做成 CLI 子命令 ⇒ 谁调都是同一条、同一份 sys.path。
|
|
164
|
+
|
|
165
|
+
成对返回两个排序口径的摘要:客户端 `packContentDigest()` 排的是**拼好的 `路径:sha` 行**,而
|
|
166
|
+
`pack_digest()` 排的是**元组**;二者只在"某路径是另一路径的前缀且下一字符 < ':'"这类边角上
|
|
167
|
+
分歧(`a/b` vs `a/b-c`)。主值取**客户端口径**(装机时真正被比的就是它),分歧时单独报出来。
|
|
168
|
+
"""
|
|
169
|
+
p = pathlib.Path(path)
|
|
170
|
+
if not p.exists():
|
|
171
|
+
raise ValueError(f"文件不存在:{p}")
|
|
172
|
+
try:
|
|
173
|
+
with tarfile.open(p, "r:gz") as tf:
|
|
174
|
+
try:
|
|
175
|
+
member = tf.getmember("PACK.json")
|
|
176
|
+
except KeyError:
|
|
177
|
+
raise ValueError(f"{p} 里没有 PACK.json —— 这不是能力包 tgz")
|
|
178
|
+
pack_json = json.loads(tf.extractfile(member).read().decode("utf-8"))
|
|
179
|
+
except tarfile.TarError as exc:
|
|
180
|
+
raise ValueError(f"{p} 不是合法的 gzip/tar:{exc}")
|
|
181
|
+
files = _posix_map(pack_json.get("files") or {})
|
|
182
|
+
client = hashlib.sha256("\n".join(sorted(f"{r}:{s}" for r, s in files.items())).encode("utf-8")).hexdigest()
|
|
183
|
+
tuple_order = hashlib.sha256("\n".join(f"{r}:{s}" for r, s in sorted(files.items())).encode("utf-8")).hexdigest()
|
|
184
|
+
return {"capability": str(pack_json.get("capability") or ""), "version": str(pack_json.get("version") or ""),
|
|
185
|
+
"fileCount": len(files), "sha256": client, "sha256TupleOrder": tuple_order,
|
|
186
|
+
"orderingDivergence": client != tuple_order}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _vertical_id(rel: str) -> str:
|
|
190
|
+
"""`verticals/<id>/manifest.json` ⇒ `<id>`(用 `PurePosixPath.parts`,⛔ 不 split 字符串)。"""
|
|
191
|
+
parts = pathlib.PurePosixPath(_posix(rel)).parts
|
|
192
|
+
return parts[1] if len(parts) > 2 and parts[0] == "verticals" else ""
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _expand_files(rel: str) -> list:
|
|
196
|
+
"""把清单里的条目展开成**真实文件**列表(目录 ⇒ 其下所有文件)。
|
|
197
|
+
|
|
198
|
+
为什么必须展开(2026-09-21 实测):`files` 里可以有目录(`presets/argent-hr-office`、`skills/ml-invoice`),
|
|
199
|
+
而 `integrity` 原先只对"恰好是文件"的条目算 sha ⇒ HR 这类只带 preset 的能力**整包没有一条 sha**,
|
|
200
|
+
医生只能 WARN、安装器也没法校验"包内容有没有被改过"。展开后 PACK.json 的 sha 清单才是完整的。
|
|
201
|
+
"""
|
|
202
|
+
p = REPO / rel
|
|
203
|
+
if p.is_dir():
|
|
204
|
+
out = []
|
|
205
|
+
for f in sorted(p.rglob("*")):
|
|
206
|
+
if not f.is_file():
|
|
207
|
+
continue
|
|
208
|
+
relf = _rel(f)
|
|
209
|
+
parts = pathlib.PurePosixPath(relf).parts
|
|
210
|
+
if "__pycache__" in parts or f.suffix == ".pyc" or f.name == ".DS_Store":
|
|
211
|
+
continue
|
|
212
|
+
out.append(relf)
|
|
213
|
+
return out
|
|
214
|
+
return [_rel(REPO / rel)] if p.exists() else []
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _rel(p: pathlib.Path) -> str:
|
|
218
|
+
"""仓库相对路径 ⇒ **POSIX 形态**(包内路径的唯一形态,见 `_posix()`)。"""
|
|
219
|
+
try:
|
|
220
|
+
return _posix(p.relative_to(REPO))
|
|
221
|
+
except ValueError:
|
|
222
|
+
return _posix(p)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def manifest(cap: str, doc: dict | None = None) -> dict:
|
|
226
|
+
"""派生 + 声明的能力包清单(**不落盘**)。缺文件记进 `missing`,由 --validate 判死。"""
|
|
227
|
+
d = doc or load()
|
|
228
|
+
caps = {k: v for k, v in (d.get("capabilities") or {}).items() if not k.startswith("_")}
|
|
229
|
+
if cap not in caps:
|
|
230
|
+
raise KeyError(cap)
|
|
231
|
+
c = caps[cap] or {}
|
|
232
|
+
pack = c.get("pack") or {}
|
|
233
|
+
flows = sorted(fid for fid, f in (d.get("flows") or {}).items() if (f or {}).get("capability") == cap)
|
|
234
|
+
|
|
235
|
+
roles, presets_f, prompts, skills, integrations, scripts = [], [], [], set(), set(), set()
|
|
236
|
+
for fid in flows:
|
|
237
|
+
f = d["flows"][fid] or {}
|
|
238
|
+
role = str(f.get("role") or "")
|
|
239
|
+
if role:
|
|
240
|
+
roles.append(role)
|
|
241
|
+
presets_f.append(f"presets/argent-{role}")
|
|
242
|
+
for p in _prompt_paths(fid, f):
|
|
243
|
+
if True:
|
|
244
|
+
prompts.append(_rel(p))
|
|
245
|
+
# 提示词里引用的技能 = 该能力的技能依赖(派生,不靠人记)
|
|
246
|
+
for ln in p.read_text(encoding="utf-8").splitlines():
|
|
247
|
+
for tok in _skill_refs(ln):
|
|
248
|
+
skills.add(tok)
|
|
249
|
+
integ = f.get("integration")
|
|
250
|
+
if integ:
|
|
251
|
+
integrations.add(str(integ))
|
|
252
|
+
# 集成适配器带来的脚本/模板(runners/template 由契约声明,避免第二份清单)
|
|
253
|
+
integrations_resolved = []
|
|
254
|
+
for name in sorted(integrations):
|
|
255
|
+
adir = INTEGRATIONS / name
|
|
256
|
+
integrations_resolved.append(_rel(adir / "adapter.py") if (adir / "adapter.py").exists()
|
|
257
|
+
else f"scripts/integrations/{name}/adapter.py")
|
|
258
|
+
for fn in _adapter_files(name):
|
|
259
|
+
scripts.add(fn)
|
|
260
|
+
|
|
261
|
+
for s in (pack.get("scripts") or []):
|
|
262
|
+
scripts.add(str(s))
|
|
263
|
+
for s in (pack.get("skills") or []):
|
|
264
|
+
skills.add(str(s))
|
|
265
|
+
# 无 flow 的能力(HR 办公)靠 **preset** 提供入口 ⇒ 显式声明(2026-09-21 批 C1)。
|
|
266
|
+
# 有 flow 的能力不需要声明:preset 由 `flows.<id>.role` 派生(`presets/argent-<role>`)。
|
|
267
|
+
for p in (pack.get("presets") or []):
|
|
268
|
+
presets_f.append(str(p))
|
|
269
|
+
if pack.get("prompts"):
|
|
270
|
+
prompts.extend(str(p) for p in pack["prompts"])
|
|
271
|
+
|
|
272
|
+
# 业务线清单(**技能归类的真源**):`scripts/check-skill-classification.mjs#classify()` 读
|
|
273
|
+
# `verticals/*/manifest.json#skills` 得出"外发集",而技能同步**只发外发集** ⇒ 包不带它,
|
|
274
|
+
# 装出来的机器上外发集为空(G3 红)⇒ 技能同步段无从执行(2026-10-05 演练实测)。
|
|
275
|
+
# 逐字复制(**不**按能力过滤):同一批包里的这份文件 sha 相同,不引入跨包不一致。
|
|
276
|
+
#
|
|
277
|
+
# P2a(2026-10-09,P0 修复):**`flows.json` 必须与 `manifest.json` 同批带上** ——
|
|
278
|
+
# `flow_registry.merge()` 的 `load_vertical()` 见到 `verticals/<id>/manifest.json` 就要求同目录
|
|
279
|
+
# 有 `flows.json`(缺 ⇒ `Conflict: 缺 flows.json` ⇒ `--json` exit 2)。旧包只带 manifest ⇒
|
|
280
|
+
# **纯包装机**(无源码树)装完引擎读不了注册表。两文件都**不按能力过滤**(逐字复制):
|
|
281
|
+
# ① `merge()` 会核对 `manifest.flows == flows.json` 的规范 id 集合 ⇒ 过滤任一份都是冲突;
|
|
282
|
+
# ② 同一批包里的这两份文件 sha 相同 ⇒ 多包同装不会撞"引擎文件版本不一致"。
|
|
283
|
+
vertical_manifests = []
|
|
284
|
+
for vf in sorted((REPO / "verticals").glob("*/manifest.json")):
|
|
285
|
+
try:
|
|
286
|
+
vd = json.loads(vf.read_text(encoding="utf-8"))
|
|
287
|
+
except Exception: # noqa: BLE001 —— 坏 manifest 由 --validate 报,不在这里吞
|
|
288
|
+
continue
|
|
289
|
+
ids = {str(x.get("id")) for x in (vd.get("capabilities") or []) if isinstance(x, dict)}
|
|
290
|
+
if cap in ids:
|
|
291
|
+
vertical_manifests.append(_rel(vf))
|
|
292
|
+
# 没有 flows.json 的业务线是**坏树**(加载器必拒)⇒ 交给 --validate 报,不在这里静默补
|
|
293
|
+
vfl = vf.with_name("flows.json")
|
|
294
|
+
if vfl.exists():
|
|
295
|
+
vertical_manifests.append(_rel(vfl))
|
|
296
|
+
# 业务线**配置目录**(`verticals/<id>/config.json`,契约 §7):它是该线可配项的声明面,
|
|
297
|
+
# 运行期由 `core/lib/config-catalog.mjs` 读取。不带它 ⇒ 纯包装机上该线的可配项**整体消失**
|
|
298
|
+
# (引擎级 configs/ 还在,于是"缺一半"且无报错 —— 比缺件更难发现)。与 manifest/flows 同理
|
|
299
|
+
# **不按能力过滤**:同一批包里 sha 相同,多包同装不会撞版本。
|
|
300
|
+
vcf = vf.with_name("config.json")
|
|
301
|
+
if vcf.exists():
|
|
302
|
+
vertical_manifests.append(_rel(vcf))
|
|
303
|
+
|
|
304
|
+
# 共用引擎只跟"有 flow"的能力走:工作台原生能力(无 flow)不需要流程引擎,
|
|
305
|
+
# 带上一整套会把"轻量"变成"整仓"(与 ⑩ 的初衷相反)。
|
|
306
|
+
files = (list(engine_files(d)) if flows else []) + sorted(scripts) + sorted(prompts) \
|
|
307
|
+
+ sorted(presets_f) + [f"skills/{s}" for s in sorted(skills)] + sorted(set(integrations_resolved)) \
|
|
308
|
+
+ vertical_manifests
|
|
309
|
+
seen, uniq = set(), []
|
|
310
|
+
for f in files:
|
|
311
|
+
if f not in seen:
|
|
312
|
+
seen.add(f)
|
|
313
|
+
uniq.append(f)
|
|
314
|
+
files = uniq
|
|
315
|
+
missing = sorted({f for f in files if not (REPO / f).exists()})
|
|
316
|
+
|
|
317
|
+
entrypoints = [str(e) for e in (pack.get("entrypoints") or [])]
|
|
318
|
+
return {
|
|
319
|
+
"capability": cap,
|
|
320
|
+
"version": str(pack.get("version") or ""),
|
|
321
|
+
"minPlan": str(c.get("minPlan") or ""),
|
|
322
|
+
"entrypoints": entrypoints,
|
|
323
|
+
# 显式声明的 preset(无 flow 的能力用;有 flow 的能力靠 roles 派生)
|
|
324
|
+
"presets": [str(p) for p in (pack.get("presets") or [])],
|
|
325
|
+
"flows": flows,
|
|
326
|
+
"roles": sorted(set(roles)),
|
|
327
|
+
"files": files,
|
|
328
|
+
"missing": missing,
|
|
329
|
+
# sha 清单:把目录展开成其下文件(否则带 preset/skills 的能力整包没有 sha 可核)
|
|
330
|
+
"integrity": {f: _sha(REPO / f)
|
|
331
|
+
for f in dict.fromkeys(x for rel in files for x in _expand_files(rel))},
|
|
332
|
+
# 业务线 id(去重:`vertical_manifests` 现在每条业务线有 **两个** 条目 —— manifest + flows)
|
|
333
|
+
"verticals": sorted({v for v in (_vertical_id(x) for x in vertical_manifests) if v}),
|
|
334
|
+
"unitCount": sum(1 for f in files if not (REPO / f).is_dir()),
|
|
335
|
+
"dirCount": sum(1 for f in files if (REPO / f).is_dir()),
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _sha256_full(p: pathlib.Path) -> str:
|
|
340
|
+
return hashlib.sha256(p.read_bytes()).hexdigest()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def pack_digest(cap: str, doc: dict | None = None) -> str:
|
|
344
|
+
"""能力包的**整包摘要**(64 位 sha256):对 `路径:文件sha256` 排序后逐行求摘要。
|
|
345
|
+
|
|
346
|
+
为什么不是"每文件一个 sha":服务端的清单字段只有一个 `sha256`(安装器做整包比对),
|
|
347
|
+
而单文件明细在 `PACK.json`(--build 产物)里本来就有 ⇒ 这里给一个稳定的整包指纹。
|
|
348
|
+
"""
|
|
349
|
+
# 口径必须与客户端 `packContentDigest()` **逐字一致**:对 `文件:sha16` 排序后求 sha256。
|
|
350
|
+
# 早期版本遍历 `m["files"]` 并对**目录**跳过 ⇒ HR 这类只带 preset 的能力算出来是"空串摘要"
|
|
351
|
+
# (e3b0c442…),而客户端用 PACK.json 里展开后的 3 个文件算 ⇒ 两边对不上,实测被 live 安装当场抓到。
|
|
352
|
+
m = manifest(cap, doc)
|
|
353
|
+
reg = json.dumps(packed_registry(cap, doc, m), ensure_ascii=False, indent=2) + "\n"
|
|
354
|
+
files = {**(m.get("integrity") or {}),
|
|
355
|
+
"scripts/flow-requirements.json": hashlib.sha256(reg.encode("utf-8")).hexdigest()[:16]}
|
|
356
|
+
lines = [f"{rel}:{sha}" for rel, sha in sorted(files.items())]
|
|
357
|
+
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
GEN_NOTE = ("能力包清单(能力 → minPlan/版本/整包 sha256/下载地址)。"
|
|
361
|
+
"由 argent-dsh `scripts/capability_pack.py --emit-server-manifest` 生成,勿手改;"
|
|
362
|
+
"发布新包后重新生成并覆盖本文件。")
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def reg_capabilities(doc: dict | None = None, dl_base: str = "") -> dict:
|
|
366
|
+
"""注册表里**认识**的能力 ⇒ 清单条目(纯派生:版本 / 档位 / 整包摘要 / 下载地址)。"""
|
|
367
|
+
d = doc or load()
|
|
368
|
+
base = (dl_base or os.environ.get("ARGENT_PACK_DL_BASE") or "https://whyshu.com/dl").rstrip("/")
|
|
369
|
+
caps = {}
|
|
370
|
+
for cap in sorted(k for k in (d.get("capabilities") or {}) if not k.startswith("_")):
|
|
371
|
+
m = manifest(cap, d)
|
|
372
|
+
ver = m["version"]
|
|
373
|
+
caps[cap] = {
|
|
374
|
+
"minPlan": m["minPlan"],
|
|
375
|
+
"version": ver,
|
|
376
|
+
"sha256": pack_digest(cap, d),
|
|
377
|
+
"url": f"{base}/packs/{cap}-{ver}.tgz",
|
|
378
|
+
}
|
|
379
|
+
return caps
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def read_manifest_file(path) -> dict:
|
|
383
|
+
"""读一份**已存在**的服务端清单(merge 的来源)。读不了/结构非法 ⇒ 抛 ValueError。
|
|
384
|
+
|
|
385
|
+
⛔ 不静默降级成"没有旧清单":那等于把清单里本工具不认识的条目**当成不存在**而抹掉,
|
|
386
|
+
正是本函数要防的那类损失。宁可报错让操作者决定。
|
|
387
|
+
"""
|
|
388
|
+
p = pathlib.Path(path)
|
|
389
|
+
try:
|
|
390
|
+
doc = json.loads(p.read_text(encoding="utf-8"))
|
|
391
|
+
except Exception as exc: # noqa: BLE001
|
|
392
|
+
raise ValueError(f"读取失败:{exc}") from exc
|
|
393
|
+
if not isinstance(doc, dict) or not isinstance(doc.get("capabilities"), dict):
|
|
394
|
+
raise ValueError("结构非法:缺少 capabilities 对象")
|
|
395
|
+
return doc
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def server_manifest(doc: dict | None = None, dl_base: str = "",
|
|
399
|
+
existing: dict | None = None, prune: bool = False) -> dict:
|
|
400
|
+
"""生成 **whyshu-svr** 消费的能力清单(`src/config/capability-packs.json`)。
|
|
401
|
+
|
|
402
|
+
闭环:`--build` 出包 → 上传到 `DL_BASE/packs/<cap>-<ver>.tgz` → 本命令生成清单 →
|
|
403
|
+
覆盖到 whyshu-svr(`ARGENT_CAPABILITY_PACKS` 可覆盖路径)⇒ 安装票据就能返回
|
|
404
|
+
"这个套餐有哪些能力、各是哪版、sha 是多少、从哪下"。
|
|
405
|
+
|
|
406
|
+
## 默认 **merge**,不是整表替换(2026-10-06 修,脚枪)
|
|
407
|
+
传了 `existing`(现网清单)时:
|
|
408
|
+
· 注册表**认识**的能力 ⇒ 用本次重新派生的值覆盖(这就是本命令的意义);
|
|
409
|
+
· 注册表**不认识**的条目(如历史上线的 `daily-report-lite`)⇒ **原样保留**;
|
|
410
|
+
· 键序沿用旧清单,新能力追加在末尾 ⇒ 没动到的条目**连顺序都不变**(diff 最小)。
|
|
411
|
+
要删掉不认识的条目必须显式 `prune=True`(CLI `--prune`),且调用方应把将删项打印出来。
|
|
412
|
+
|
|
413
|
+
## 为什么曾出事故
|
|
414
|
+
旧实现是整表输出:只输出注册表里的能力 ⇒ 跑一次就把"表里有、注册表没有"的条目**抹掉**,
|
|
415
|
+
还会顺手重写本批不打算动的条目(失去"恰好变更文件"的可审计性)。
|
|
416
|
+
"""
|
|
417
|
+
existing = existing if isinstance(existing, dict) else None
|
|
418
|
+
fresh = reg_capabilities(doc, dl_base)
|
|
419
|
+
if existing is None:
|
|
420
|
+
return {"_note": GEN_NOTE, "capabilities": fresh}
|
|
421
|
+
old_caps = existing.get("capabilities") or {}
|
|
422
|
+
merged = {}
|
|
423
|
+
for k, v in old_caps.items():
|
|
424
|
+
if k in fresh:
|
|
425
|
+
merged[k] = fresh[k] # 认识 ⇒ 刷新
|
|
426
|
+
elif not prune:
|
|
427
|
+
merged[k] = v # 不认识 ⇒ 默认原样保留
|
|
428
|
+
for k, v in fresh.items():
|
|
429
|
+
if k not in merged:
|
|
430
|
+
merged[k] = v # 新能力 ⇒ 追加在末尾
|
|
431
|
+
out = dict(existing) # 顶层其它键(_note / version / …)原样保留
|
|
432
|
+
out["capabilities"] = merged
|
|
433
|
+
return out
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
def manifest_merge_report(existing: dict | None, merged: dict, known: list | None = None,
|
|
437
|
+
prune: bool = False) -> dict:
|
|
438
|
+
"""把"这次合并做了什么"算成可打印的结构(保留/新增/更新/删除),供 CLI 汇报与 --dry-run 用。
|
|
439
|
+
|
|
440
|
+
`known` = **注册表认识**的能力 id 列表(= 本次重新派生的键集合)。判定"保留"看的是
|
|
441
|
+
"不在 known 里"(而不是"不在输出里")—— 保留的条目**本来就在输出里**(原样未动)。
|
|
442
|
+
"""
|
|
443
|
+
old = ((existing or {}).get("capabilities") or {})
|
|
444
|
+
new = merged.get("capabilities") or {}
|
|
445
|
+
known_set = set(known or [])
|
|
446
|
+
preserved = [k for k in old if k not in known_set] if not prune else []
|
|
447
|
+
removed = sorted(k for k in old if k not in known_set) if prune else []
|
|
448
|
+
added = sorted(k for k in new if k not in old)
|
|
449
|
+
refreshed = sorted(k for k in new if k in old and k in known_set)
|
|
450
|
+
updated = []
|
|
451
|
+
for k in new:
|
|
452
|
+
if k in old and old[k] != new[k]:
|
|
453
|
+
old_e, new_e = old[k] or {}, new[k] or {}
|
|
454
|
+
for f in sorted(set(old_e) | set(new_e)):
|
|
455
|
+
if old_e.get(f) != new_e.get(f):
|
|
456
|
+
updated.append((k, f, old_e.get(f), new_e.get(f)))
|
|
457
|
+
return {"preserved": sorted(preserved), "removed": removed,
|
|
458
|
+
"added": added, "updated": updated, "refreshed": refreshed}
|
|
459
|
+
|
|
460
|
+
def packed_registry(cap: str, doc: dict | None = None, m: dict | None = None) -> dict:
|
|
461
|
+
"""**包内注册表**(纯函数):只留本能力 + 它引用的流程 + 共同声明。
|
|
462
|
+
|
|
463
|
+
被 `build()`(写文件)与 `pack_digest()`(算摘要)**共用** ⇒ 两边看到同一份字节;
|
|
464
|
+
这条是 2026-09-22 修的:此前摘要只覆盖源文件,而包内注册表是 build 时生成的 ⇒
|
|
465
|
+
改了它(候选判据/闸门/计费/租约口径全在里面)摘要与校验都发现不了。
|
|
466
|
+
|
|
467
|
+
## P2a(2026-10-09 修,P0):投影**只并引擎级段**,flow 声明不进来
|
|
468
|
+
这份投影的落点就是 `scripts/flow-requirements.json` —— P2a 契约里的**引擎级 base**
|
|
469
|
+
(引擎契约/错误词表/lease/billing/pack.engine)。按拆分约定,**flow 声明属业务线**:
|
|
470
|
+
`verticals/<id>/flows.json`(由 `manifest()` 同批带进包)。
|
|
471
|
+
|
|
472
|
+
旧实现把 `m["flows"]`(规范 id)写进投影 ⇒ 两种机器两种崩法(2026-10-09 真机复核,P0):
|
|
473
|
+
· 源码树装带 flow 的包 ⇒ base 与 `verticals/<id>/flows.json` **重复定义** ⇒ `--json` exit 2;
|
|
474
|
+
· 纯包装机 ⇒ 包带了 manifest 却没带 flows.json ⇒ exit 2。
|
|
475
|
+
所以这里 `flows`/`aliases` **恒为空**(保留键形只是稳定消费者与摘要口径:无 flow 的能力
|
|
476
|
+
hr 的投影字节**不变**,不用跟着提版本)。⛔ 不要为"包自足"再把 flow 抄一份进来 ——
|
|
477
|
+
真正让包自足的是同批带上的 `verticals/<id>/flows.json`(`build()` 的自检会证明它能加载)。
|
|
478
|
+
装配自检也因此**必须走合并路径**(`ARGENT_REGISTRY_ROOT=<产物>`),见 `build()`。
|
|
479
|
+
"""
|
|
480
|
+
d = doc or load()
|
|
481
|
+
m = m or manifest(cap, d)
|
|
482
|
+
caps = {k: v for k, v in (d.get("capabilities") or {}).items() if k == cap or k.startswith("_")}
|
|
483
|
+
return {
|
|
484
|
+
# 空段(不是缺失):base 不该有 flow 声明,但键形保留 ⇒ 消费者 `doc.flows` 恒是对象
|
|
485
|
+
"aliases": {},
|
|
486
|
+
"_doc": f"[能力包 {cap} v{m['version']}] 由 scripts/capability_pack.py --build 生成;"
|
|
487
|
+
f"只含该能力的**引擎级**声明(flow 声明在 verticals/<id>/flows.json,随包同批带上)。"
|
|
488
|
+
f"改能力请改源仓库后重新打包。",
|
|
489
|
+
"flows": {},
|
|
490
|
+
"capabilities": caps,
|
|
491
|
+
"failureClasses": d.get("failureClasses") or {},
|
|
492
|
+
"pack": d.get("pack") or {},
|
|
493
|
+
"entitlements": d.get("entitlements") or {},
|
|
494
|
+
"billing": d.get("billing") or {},
|
|
495
|
+
"lease": d.get("lease") or {},
|
|
496
|
+
"_requiredRoles": d.get("_requiredRoles") or {},
|
|
497
|
+
"_pauseSwitchRule": d.get("_pauseSwitchRule") or {},
|
|
498
|
+
"_integrationNote": d.get("_integrationNote") or {},
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _prompt_paths(fid: str, spec: dict) -> list:
|
|
504
|
+
"""该流程的主提示词、失败提示词,以及**宿主排程的静态轮次指令**(P1-A,2026-10-12)。
|
|
505
|
+
|
|
506
|
+
注册表 `trigger.promptTemplate` 是**唯一出处**;业务线落位后提示词在
|
|
507
|
+
`verticals/<id>/prompts/`,不再在 `scripts/flow-prompts/`。历史目录仅作兜底
|
|
508
|
+
(base 流程仍可能在那里)。
|
|
509
|
+
|
|
510
|
+
`trigger.schedule.promptFile`(宿主原生排程用;相对业务线目录)同样**随包走** ——
|
|
511
|
+
判据与 `trigger.promptTemplate` 同一条:**注册表引用谁,包就带谁**。不带它的话纯包装机上
|
|
512
|
+
那个文件不存在 ⇒ "注册表说有、机器上却没有"这种半缺件最难查(运行期只会响亮警告,
|
|
513
|
+
而人会先怀疑注册表)。
|
|
514
|
+
"""
|
|
515
|
+
out: list[pathlib.Path] = []
|
|
516
|
+
tpl = str(((spec.get("trigger") or {}).get("promptTemplate")) or "")
|
|
517
|
+
if tpl:
|
|
518
|
+
main = HERE.parent / tpl
|
|
519
|
+
out += [main, main.with_name(main.name[:-3] + ".failure.md")]
|
|
520
|
+
sched = str((((spec.get("trigger") or {}).get("schedule") or {}).get("promptFile")) or "")
|
|
521
|
+
if sched:
|
|
522
|
+
out.append(schedule_prompt_path(fid, sched))
|
|
523
|
+
out += [PROMPTS / f"{fid}.md", PROMPTS / f"{fid}.failure.md"]
|
|
524
|
+
seen, uniq = set(), []
|
|
525
|
+
for p in out:
|
|
526
|
+
if p.exists() and p not in seen:
|
|
527
|
+
seen.add(p); uniq.append(p)
|
|
528
|
+
return uniq
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def schedule_prompt_path(fid: str, rel: str) -> pathlib.Path:
|
|
532
|
+
"""`trigger.schedule.promptFile` 的路径解析(与 `scripts/flow_config.py` **同一套**)。
|
|
533
|
+
|
|
534
|
+
两种写法都认:`verticals/<id>/…`(仓库根相对)与 `prompts/<x>.md`(**业务线内**相对)。
|
|
535
|
+
⛔ 两份实现不许漂移 —— 回归 `scripts/tests/schedule-declaration.test.mjs` 对同一份声明
|
|
536
|
+
断言"运行期读取器与打包器解析到**同一个文件**"。
|
|
537
|
+
"""
|
|
538
|
+
p = pathlib.Path(str(rel))
|
|
539
|
+
if p.is_absolute():
|
|
540
|
+
return p
|
|
541
|
+
if str(rel).startswith("verticals/"):
|
|
542
|
+
return HERE.parent / p
|
|
543
|
+
vid = str(fid).split(".", 1)[0] if "." in str(fid) else ""
|
|
544
|
+
if vid:
|
|
545
|
+
return HERE.parent / "verticals" / vid / p
|
|
546
|
+
return HERE.parent / p
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _skill_refs(line: str) -> list:
|
|
550
|
+
"""提示词里的技能引用:`skills/<名>` 或 `~/.dsh/skills/<名>`。"""
|
|
551
|
+
out = []
|
|
552
|
+
for marker in ("skills/",):
|
|
553
|
+
idx = 0
|
|
554
|
+
while True:
|
|
555
|
+
i = line.find(marker, idx)
|
|
556
|
+
if i < 0:
|
|
557
|
+
break
|
|
558
|
+
j = i + len(marker)
|
|
559
|
+
k = j
|
|
560
|
+
while k < len(line) and (line[k].isalnum() or line[k] in "-_"):
|
|
561
|
+
k += 1
|
|
562
|
+
name = line[j:k].strip("/-_ ")
|
|
563
|
+
if name and (SKILLS / name).is_dir():
|
|
564
|
+
out.append(name)
|
|
565
|
+
idx = max(k, i + 1)
|
|
566
|
+
return out
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _adapter_files(name: str) -> list:
|
|
570
|
+
"""从适配器契约里读它与它引用的脚本/模板(**不复制契约的实现**,只读声明)。"""
|
|
571
|
+
code = (
|
|
572
|
+
"import sys, json, pathlib\n"
|
|
573
|
+
f"sys.path.insert(0, {str(HERE)!r})\n"
|
|
574
|
+
"import integration_contract as C\n"
|
|
575
|
+
f"m = C.load({name!r})\n"
|
|
576
|
+
"out = list((m.RUNNERS or {}).values()) + ([m.TEMPLATE] if m.TEMPLATE else [])\n"
|
|
577
|
+
"print(json.dumps(out))\n"
|
|
578
|
+
)
|
|
579
|
+
try:
|
|
580
|
+
# `PYTHONDONTWRITEBYTECODE=1`(2026-10-11 补):探针子进程会 `import integration_contract`
|
|
581
|
+
# ⇒ 默认会在 `scripts/` 下种 `__pycache__/*.pyc` —— 而本模块的 `--check-stamp` 与
|
|
582
|
+
# `--describe` 都对外声称"只读"。「只读子命令往仓库里写字节」正是要防的那类隐性副作用,
|
|
583
|
+
# 故在这里掐掉(判据见回归 ⑦ 的字节级断言)。
|
|
584
|
+
r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True, timeout=30,
|
|
585
|
+
env=dict(os.environ, PYTHONDONTWRITEBYTECODE="1"))
|
|
586
|
+
names = json.loads(r.stdout or "[]")
|
|
587
|
+
except Exception: # noqa: BLE001
|
|
588
|
+
return []
|
|
589
|
+
out = []
|
|
590
|
+
for n in names:
|
|
591
|
+
if not n:
|
|
592
|
+
continue
|
|
593
|
+
p = HERE / str(n)
|
|
594
|
+
if p.exists():
|
|
595
|
+
out.append(_rel(p))
|
|
596
|
+
else:
|
|
597
|
+
out.append(f"scripts/{n}") # 交给 missing 报错,不静默吞
|
|
598
|
+
return out
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def _sha(p: pathlib.Path) -> str:
|
|
602
|
+
return hashlib.sha256(p.read_bytes()).hexdigest()[:16]
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _display(p: pathlib.Path) -> str:
|
|
606
|
+
"""打印用路径:能相对仓库就相对,否则原样(`ARGENT_PACK_LOCK` 允许把锁指到仓库外 —— 夹具就这么用)。"""
|
|
607
|
+
try:
|
|
608
|
+
return str(pathlib.Path(p).relative_to(REPO))
|
|
609
|
+
except ValueError:
|
|
610
|
+
return str(p)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def read_lock() -> dict:
|
|
614
|
+
"""读版本锁。**文件映射的键一律归一成 POSIX 形态**后再比对(见 `_posix_map()`)。
|
|
615
|
+
|
|
616
|
+
Windows 上盖过章的锁(旧版本 `_rel()` 给的是反斜杠)若原样与正斜杠清单比 ⇒ 每个文件都"不符"
|
|
617
|
+
⇒ `--validate` 恒红、`--stamp` 拒绝盖章(自锁死)。归一是让比对**真正发生**的那一步:
|
|
618
|
+
反斜杠与正斜杠指向同一文件 ⇒ 必须同判,而不是"绕过"或"全判不符"。
|
|
619
|
+
"""
|
|
620
|
+
if not LOCK.exists():
|
|
621
|
+
return {}
|
|
622
|
+
try:
|
|
623
|
+
doc = json.loads(LOCK.read_text(encoding="utf-8"))
|
|
624
|
+
except Exception: # noqa: BLE001
|
|
625
|
+
return {}
|
|
626
|
+
caps = doc.get("capabilities") if isinstance(doc, dict) else None
|
|
627
|
+
if isinstance(caps, dict):
|
|
628
|
+
for entry in caps.values():
|
|
629
|
+
if isinstance(entry, dict) and isinstance(entry.get("files"), dict):
|
|
630
|
+
entry["files"] = _posix_map(entry["files"])
|
|
631
|
+
return doc
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def downgrade_check(doc: dict | None = None) -> list:
|
|
635
|
+
"""只列**降版**项(锁里的旧版本 → 当前注册表的版本),供 `--allow-downgrade` 时显著告警。
|
|
636
|
+
|
|
637
|
+
2026-10-11 补(实测缺陷):两批并发时,某批在自己的**旧文件**上继续改 ⇒ diff 一度把三个包
|
|
638
|
+
**降版**(发现后以 `git show HEAD:<file>` 重建)。`--stamp` 此前对"版本不等"一律放行
|
|
639
|
+
⇒ 降版与提版走同一条路、全程无红灯。现在降版默认判红;确实要刻意回滚须显式
|
|
640
|
+
`--allow-downgrade`,那时**放行但把这一项显著打出来**(回滚是有意行为,不是静默事故)。
|
|
641
|
+
"""
|
|
642
|
+
d = doc or load()
|
|
643
|
+
lock = (read_lock() or {}).get("capabilities") or {}
|
|
644
|
+
out = []
|
|
645
|
+
for cap in sorted(k for k in (d.get("capabilities") or {}) if not k.startswith("_")):
|
|
646
|
+
try:
|
|
647
|
+
m = manifest(cap, d)
|
|
648
|
+
except KeyError:
|
|
649
|
+
continue
|
|
650
|
+
lk = lock.get(cap)
|
|
651
|
+
if not lk:
|
|
652
|
+
continue
|
|
653
|
+
old_v, new_v = str(lk.get("version") or ""), str(m["version"] or "")
|
|
654
|
+
if is_downgrade(new_v, old_v):
|
|
655
|
+
out.append(f"能力 {cap} **降版**:锁里 {old_v} → 当前注册表 {new_v}")
|
|
656
|
+
return out
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def stamp_check(doc: dict | None = None, allow_downgrade: bool = False) -> list:
|
|
660
|
+
"""盖章前的**纪律检查**:内容变了但版本没动 ⇒ 报问题(2026-09-21 补;2026-10-11 扩两条)。
|
|
661
|
+
|
|
662
|
+
## ① 「内容变了但版本没动」(2026-09-21)
|
|
663
|
+
子代理指出并实测过 —— C4 改了共用引擎(engine 18 → 20 项)却只 `--stamp` 没提版本,
|
|
664
|
+
"内容变了必须提版本"这条纪律被绕过(锁里记录了新 sha,版本号却原地不动 ⇒ 客户机无法靠版本号
|
|
665
|
+
判断"这台机器跑的代码变没变")。
|
|
666
|
+
|
|
667
|
+
## ② 「降版」(2026-10-11 实测)
|
|
668
|
+
旧实现只判"版本**相等**与否" ⇒ 不等即当成合规提版放行 ⇒ **降版与提版同路**。并发批在自己的
|
|
669
|
+
旧文件上继续改就会把包降版而**没有红灯**。现在降版默认判红并点名;`allow_downgrade=True`
|
|
670
|
+
(CLI `--allow-downgrade`)时放行,由调用方把 `downgrade_check()` 的结果**显著告警**。
|
|
671
|
+
|
|
672
|
+
## ③ 「摘要变了但版本没动」(2026-10-11 实测,本函数此前**看不见**的那一层)
|
|
673
|
+
今天的实例:`scripts/flow-requirements.json` 变了(新增 `failureClasses.capacityFull`),
|
|
674
|
+
`hr` 包字节随之改变 —— **每个包的 tgz 都带一份注册表投影** —— 但没人提 `hr` 版本。
|
|
675
|
+
旧实现只比 `files`(各能力**自身**的源文件 sha)⇒ `hr` 的 6 个 preset 一个没变 ⇒ **放行**,
|
|
676
|
+
直到发布期才被 `release-argent.sh --check-dl` 报
|
|
677
|
+
「✗ hr: 版本相同(0.1.51)但内容摘要不同」。现在锁里给每个能力记 `digest`
|
|
678
|
+
(= `pack_digest()`,与 `--emit-server-manifest#sha256` / `--digest-tgz` **逐字同口径**):
|
|
679
|
+
摘要不同 ∧ 版本未变 ⇒ 判红(**摘要相同 ⇒ 不变**,幂等)。
|
|
680
|
+
旧锁没有 `digest` 字段 ⇒ **不报错**(首次 `--stamp` 补上,向后兼容)。
|
|
681
|
+
"""
|
|
682
|
+
d = doc or load()
|
|
683
|
+
lock = (read_lock() or {}).get("capabilities") or {}
|
|
684
|
+
problems = []
|
|
685
|
+
for cap in sorted(k for k in (d.get("capabilities") or {}) if not k.startswith("_")):
|
|
686
|
+
try:
|
|
687
|
+
m = manifest(cap, d)
|
|
688
|
+
except KeyError:
|
|
689
|
+
continue
|
|
690
|
+
lk = lock.get(cap)
|
|
691
|
+
if not lk:
|
|
692
|
+
continue # 新能力:首次盖章,无所谓
|
|
693
|
+
old_v, new_v = str(lk.get("version") or ""), str(m["version"] or "")
|
|
694
|
+
if is_downgrade(new_v, old_v):
|
|
695
|
+
if not allow_downgrade:
|
|
696
|
+
problems.append(
|
|
697
|
+
f"能力 {cap} **降版**:锁里 {old_v} → 现在 {new_v}"
|
|
698
|
+
f"(疑似用旧文件覆盖了别人的提版;请先 `git log -- <file>` / "
|
|
699
|
+
f"`git show HEAD:<file>` 以 HEAD 为基准重建,⛔ 不要在自己的旧副本上改)"
|
|
700
|
+
f" ⇒ 确认要回滚请显式加 --allow-downgrade(会显著告警)")
|
|
701
|
+
continue # 版本确实动了:不再按"版本没动"重复报
|
|
702
|
+
if old_v != new_v:
|
|
703
|
+
continue # 提版 = 合规路径(③ 的摘要检查只在"版本未变"下成立)
|
|
704
|
+
old_files, new_files = lk.get("files") or {}, m["integrity"]
|
|
705
|
+
if old_files != new_files:
|
|
706
|
+
changed = sorted(set(new_files) ^ set(old_files)) or \
|
|
707
|
+
sorted(f for f in new_files if old_files.get(f) != new_files[f])
|
|
708
|
+
problems.append(f"能力 {cap} v{m['version']} 内容变了但版本没动"
|
|
709
|
+
f"({len(changed)} 个文件;例:{changed[:3]})⇒ 请提版本后再盖章")
|
|
710
|
+
locked_digest = str(lk.get("digest") or "")
|
|
711
|
+
if locked_digest and old_files == new_files:
|
|
712
|
+
cur_digest = pack_digest(cap, d) # 只算一次:两次算可能读到不同字节(并发批次正在改)
|
|
713
|
+
if locked_digest != cur_digest:
|
|
714
|
+
# 只有包**字节**变了(源文件表一字未动)⇒ 命中的正是"改共用注册表/引擎投影"那一类改动
|
|
715
|
+
problems.append(
|
|
716
|
+
f"能力 {cap} v{m['version']} 的**内容摘要变了但版本没动**"
|
|
717
|
+
f"(锁 {locked_digest[:16]}… → 现 {cur_digest[:16]}…)"
|
|
718
|
+
f" ⇒ 内容变 ⇒ 版本必变(含无 flow 的包);本包含注册表投影"
|
|
719
|
+
f"(scripts/flow-requirements.json)⇒ 请提版本后再盖章")
|
|
720
|
+
return problems
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
def check_stamp(doc: dict | None = None) -> list:
|
|
724
|
+
"""**只读**自检(`--check-stamp`):锁里记的**内容摘要** == 当前内容重算的摘要。
|
|
725
|
+
|
|
726
|
+
给**批次收口前**用(⛔ 不联网、⛔ 不写盘):`--check-dl` 负责服务端那一侧,这一条在本地
|
|
727
|
+
就能回答"我这一版盖章之后,包内容有没有再被动过" —— 包括**无 flow 的包**因注册表投影变化
|
|
728
|
+
而漂移、而 `files` 表看不出来的那一层(见 `stamp_check()` ③)。
|
|
729
|
+
|
|
730
|
+
与 `stamp_check()` 的分工:那个是**盖章闸门**(回答"能不能盖"),这个是**收口体检**
|
|
731
|
+
(回答"盖过的章还准不准")。判据同源(都走 `pack_digest()`)。
|
|
732
|
+
"""
|
|
733
|
+
d = doc or load()
|
|
734
|
+
lock = (read_lock() or {}).get("capabilities") or {}
|
|
735
|
+
problems = []
|
|
736
|
+
for cap in sorted(k for k in (d.get("capabilities") or {}) if not k.startswith("_")):
|
|
737
|
+
try:
|
|
738
|
+
m = manifest(cap, d)
|
|
739
|
+
except KeyError:
|
|
740
|
+
continue
|
|
741
|
+
lk = lock.get(cap)
|
|
742
|
+
if not lk:
|
|
743
|
+
problems.append(f"能力 {cap} 没登记进版本锁 ⇒ 先跑 --stamp")
|
|
744
|
+
continue
|
|
745
|
+
locked = str(lk.get("digest") or "")
|
|
746
|
+
cur = pack_digest(cap, d)
|
|
747
|
+
if not locked:
|
|
748
|
+
problems.append(f"能力 {cap} v{m['version']} 的锁里**没有内容摘要**(旧锁的形态)"
|
|
749
|
+
f" ⇒ 跑一次 --stamp 补上(首次补写不报错、不改版本)")
|
|
750
|
+
continue
|
|
751
|
+
if locked != cur:
|
|
752
|
+
problems.append(
|
|
753
|
+
f"能力 {cap} 的锁内摘要与当前内容不符:锁 {locked[:16]}… vs 现 {cur[:16]}…"
|
|
754
|
+
f"(锁登记 v{lk.get('version')},当前 v{m['version']})"
|
|
755
|
+
f" ⇒ 说明盖章之后包内容又变了 ⇒ 提版本并重跑 --stamp")
|
|
756
|
+
return problems
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def stamp(doc: dict | None = None) -> dict:
|
|
760
|
+
"""把当前能力包内容(版本 + 每个文件的 sha + **整包内容摘要**)登记进锁文件。改了内容必须重盖章。
|
|
761
|
+
|
|
762
|
+
`digest`(2026-10-11 补):与 `--emit-server-manifest#sha256` / `--digest-tgz` / 客户端
|
|
763
|
+
`packContentDigest()` **同一条口径**(都走 `pack_digest()`)⇒ 本地盖章期就能判"包字节变没变",
|
|
764
|
+
不必等到发布期 `--check-dl`。旧锁(无该字段)首次 `--stamp` 会被补上,**不报错、不改版本**。
|
|
765
|
+
"""
|
|
766
|
+
d = doc or load()
|
|
767
|
+
caps = {k: v for k, v in (d.get("capabilities") or {}).items() if not k.startswith("_")}
|
|
768
|
+
out = {"_doc": "能力包版本锁(迭代3-⑨):由 scripts/capability_pack.py --stamp 生成。"
|
|
769
|
+
"内容变了(sha 或 digest 变)而版本没变 ⇒ --stamp 直接拒绝(stamp_check)且 --validate 报错;"
|
|
770
|
+
"版本**降**了同样拒绝(--allow-downgrade 才放行,且显著告警);提了版本再盖章。"
|
|
771
|
+
"digest = 整包内容摘要,与 --emit-server-manifest#sha256 / --digest-tgz 同口径;"
|
|
772
|
+
"批次收口前用只读的 --check-stamp 自检。",
|
|
773
|
+
"capabilities": {}}
|
|
774
|
+
for cap in sorted(caps):
|
|
775
|
+
try:
|
|
776
|
+
m = manifest(cap, d)
|
|
777
|
+
except KeyError:
|
|
778
|
+
continue
|
|
779
|
+
out["capabilities"][cap] = {"version": m["version"], "flows": m["flows"],
|
|
780
|
+
"files": m["integrity"], "digest": pack_digest(cap, d)}
|
|
781
|
+
return out
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
def validate(doc: dict | None = None) -> list:
|
|
785
|
+
d = doc or load()
|
|
786
|
+
caps = {k: v for k, v in (d.get("capabilities") or {}).items() if not k.startswith("_")}
|
|
787
|
+
problems = []
|
|
788
|
+
if not (d.get("pack") or {}).get("engine"):
|
|
789
|
+
problems.append("注册表缺顶层 pack.engine(共用引擎分发清单)⇒ 打出来的包跑不起来")
|
|
790
|
+
flows_no_cap = [fid for fid, f in (d.get("flows") or {}).items() if not (f or {}).get("capability")]
|
|
791
|
+
if flows_no_cap:
|
|
792
|
+
problems.append(f"这些流程没声明 capability ⇒ 无法归属任何能力包:{flows_no_cap}")
|
|
793
|
+
for cap in sorted(caps):
|
|
794
|
+
try:
|
|
795
|
+
m = manifest(cap, d)
|
|
796
|
+
except KeyError:
|
|
797
|
+
continue
|
|
798
|
+
if not m["version"]:
|
|
799
|
+
problems.append(f"能力 {cap} 缺 pack.version(能力包必须可版本化)")
|
|
800
|
+
elif not re.match(SEMVER, m["version"]):
|
|
801
|
+
problems.append(f"能力 {cap} 的 pack.version={m['version']!r} 不是语义化版本")
|
|
802
|
+
if m["missing"]:
|
|
803
|
+
problems.append(f"能力 {cap} 缺 {len(m['missing'])} 个文件:{m['missing']}")
|
|
804
|
+
# P2a(2026-10-09,P0):带 flow 的能力必须把业务线的 `flows.json` 一起打进包 ——
|
|
805
|
+
# `flow_registry.load_vertical()` 见到 `manifest.json` 就要求同目录有 `flows.json`
|
|
806
|
+
# (缺 ⇒ `Conflict: 缺 flows.json` ⇒ `--json` exit 2)⇒ 漏带它 = **纯包装机**装完引擎读不了注册表。
|
|
807
|
+
# 这一条把该形态从"装完才发现"提前到打包前判红。
|
|
808
|
+
for vid in m.get("verticals") or []:
|
|
809
|
+
if (REPO / "verticals" / vid / "manifest.json").exists() \
|
|
810
|
+
and not (REPO / "verticals" / vid / "flows.json").exists():
|
|
811
|
+
problems.append(f"能力 {cap} 带业务线 {vid} 的 manifest.json 却没有 verticals/{vid}/flows.json "
|
|
812
|
+
f"⇒ 装完 flow_registry.py 必 exit 2(缺 flows.json)")
|
|
813
|
+
if not m["flows"] and not m.get("entrypoints") and not (m.get("presets") or []):
|
|
814
|
+
problems.append(f"能力 {cap} 既没有 flow、也没有 pack.entrypoints / pack.presets ⇒ 空包"
|
|
815
|
+
f"(工作台原生能力允许无 flow,但必须声明入口:工作台入口或角色 preset)")
|
|
816
|
+
for fid in m["flows"]:
|
|
817
|
+
# P2a:提示词路径以注册表声明为准(业务线落位后在 verticals/<id>/prompts/)
|
|
818
|
+
_spec = (d.get("flows") or {}).get(fid) or {}
|
|
819
|
+
if not _prompt_paths(fid, _spec):
|
|
820
|
+
problems.append(f"能力 {cap} 的流程 {fid} 缺主提示词(注册表 trigger.promptTemplate 指向的文件不存在)")
|
|
821
|
+
role = str((d["flows"][fid] or {}).get("role") or "")
|
|
822
|
+
if role and not (PRESETS / f"argent-{role}").is_dir():
|
|
823
|
+
problems.append(f"能力 {cap} 的流程 {fid} 角色 {role} 缺 preset presets/argent-{role}")
|
|
824
|
+
|
|
825
|
+
# 版本锁:内容变了必须提版本(否则"客户机上跑的是哪一版"是假的)
|
|
826
|
+
lock = (read_lock() or {}).get("capabilities") or {}
|
|
827
|
+
for cap in sorted(caps):
|
|
828
|
+
m = manifest(cap, d)
|
|
829
|
+
lk = lock.get(cap)
|
|
830
|
+
if not lk:
|
|
831
|
+
problems.append(f"能力 {cap} 未登记进版本锁(跑 --stamp);缺锁 ⇒ 内容改了也不会被拦")
|
|
832
|
+
continue
|
|
833
|
+
if lk.get("version") != m["version"]:
|
|
834
|
+
problems.append(f"能力 {cap} 版本不一致:注册表 {m['version']} vs 锁 {lk.get('version')}"
|
|
835
|
+
f"(提了版本请重跑 --stamp)")
|
|
836
|
+
old, new = lk.get("files") or {}, m["integrity"]
|
|
837
|
+
changed = sorted(f for f in set(old) & set(new) if old[f] != new[f])
|
|
838
|
+
added = sorted(set(new) - set(old))
|
|
839
|
+
gone = sorted(set(old) - set(new))
|
|
840
|
+
if changed or added or gone:
|
|
841
|
+
detail = []
|
|
842
|
+
if changed:
|
|
843
|
+
detail.append(f"内容变了 {changed}")
|
|
844
|
+
if added:
|
|
845
|
+
detail.append(f"新增 {added}")
|
|
846
|
+
if gone:
|
|
847
|
+
detail.append(f"移除 {gone}")
|
|
848
|
+
problems.append(f"能力 {cap} v{m['version']} 的包内容与锁不符({'; '.join(detail)})"
|
|
849
|
+
f"⇒ 要么回退改动,要么**提版本**后重跑 --stamp(版本绑死就是靠这条守住的)")
|
|
850
|
+
# 整包内容摘要(2026-10-11 补):`files` 只看该能力**自身**的源文件,看不见**包内的注册表投影**
|
|
851
|
+
# —— 而每个包的 tgz 都带一份(含 `failureClasses`/`pack.engine`)⇒ 改了共用注册表时,无 flow 的
|
|
852
|
+
# `hr` 也会变字节,而上面那条逐文件比对**一声不响**。只在"版本没变"下设判(提版就是合规路径);
|
|
853
|
+
# 旧锁没有 `digest` ⇒ 跳过(首次 --stamp 补上,⛔ 不因缺字段判红)。
|
|
854
|
+
locked_digest = str(lk.get("digest") or "")
|
|
855
|
+
if locked_digest and lk.get("version") == m["version"]:
|
|
856
|
+
cur_digest = pack_digest(cap, d) # 只算一次(同上:避免两次读之间被并发批次改动)
|
|
857
|
+
if locked_digest != cur_digest:
|
|
858
|
+
problems.append(f"能力 {cap} v{m['version']} 的**内容摘要**与锁不符"
|
|
859
|
+
f"(锁 {locked_digest[:16]}… vs 现 {cur_digest[:16]}…)"
|
|
860
|
+
f"⇒ 内容变 ⇒ 版本必变(含无 flow 的包);本包含注册表投影"
|
|
861
|
+
f"(scripts/flow-requirements.json)⇒ 提版本后重跑 --stamp")
|
|
862
|
+
return problems
|
|
863
|
+
|
|
864
|
+
|
|
865
|
+
def pack_tarball(cap: str, pack_dir: pathlib.Path, doc: dict | None = None) -> pathlib.Path:
|
|
866
|
+
"""把装配目录打成 `<cap>-<version>.tgz`(文件名与服务端清单的 url 约定一致)。
|
|
867
|
+
|
|
868
|
+
确定性:按相对路径排序写入、成员 mtime 固定为 0、uid/gid 归零,**且 gzip 容器头不给 mtime**
|
|
869
|
+
⇒ **同样内容出同样字节**,这样"发布出去的包"与"本地校验的包"可以逐字节对上(sha256 才有意义)。
|
|
870
|
+
⚠️ 2026-10-09 补:容器头的 mtime 此前漏了(`w:gz` 默认填**当前时间**)⇒ 同内容两次打包出**不同字节**
|
|
871
|
+
(实测 hr 0.1.41 两次 `--publish-packs`:tgz sha256 `0c0193f3…` vs `39ead5d2…`,而内容摘要
|
|
872
|
+
`09e1fef9…` 两次相同)—— 「同样内容出同样字节」当时是假的。⛔ 该字段不进 `PACK.json`,
|
|
873
|
+
**内容摘要(清单 sha256,客户端真正校验的那个)不受影响**。
|
|
874
|
+
"""
|
|
875
|
+
d = doc or load()
|
|
876
|
+
ver = str(manifest(cap, d)["version"])
|
|
877
|
+
tgz = pack_dir.parent / f"{cap}-{ver}.tgz"
|
|
878
|
+
if tgz.exists():
|
|
879
|
+
tgz.unlink()
|
|
880
|
+
# ⚠️ gzip 头里有一个 **mtime 字段**:`tarfile.open(..., "w:gz")` 会把它填成**当前时间**
|
|
881
|
+
# ⇒ 同一份内容两次打包得到**不同字节**(2026-10-09 实测:hr 0.1.41 两次 `--publish-packs`
|
|
882
|
+
# 打出 `0c0193f3…` 与 `39ead5d2…` 两个 tgz sha256,而**内容摘要** `09e1fef9…` 两次相同)。
|
|
883
|
+
# 后果:同一个 URL 下的**字节**会变(内容没变),"发布出去的包"与"本地校验的包"无法逐字节对上,
|
|
884
|
+
# 本函数头声称的「同样内容出同样字节」是假的。这里显式给 `mtime=0`,把它变成真的。
|
|
885
|
+
# ⛔ 只影响容器头:包内成员 mtime 早已固定为 0,**内容摘要(清单 sha256)不受影响**。
|
|
886
|
+
# ⛔ 别写成 `tarfile.open(..., "w:gz", fileobj=GzipFile(...))`:那会**再套一层 gzip**
|
|
887
|
+
# (`w:gz` 自己就会包 GzipFile)⇒ 产出不是合法 gzip,`--digest-tgz` 当场报 invalid header(实测)。
|
|
888
|
+
# 正确形态 = 外层自己建 GzipFile、tarfile 用**不压缩**的 `"w"` 写进去。
|
|
889
|
+
with gzip.GzipFile(str(tgz), "wb", mtime=0) as gz:
|
|
890
|
+
with tarfile.open(fileobj=gz, mode="w") as tf:
|
|
891
|
+
# 成员名走 `_posix()`:tgz 的 arcname 是**包内路径**,Windows 上 `str(relative_to(...))`
|
|
892
|
+
# 会给反斜杠 ⇒ 包在 Linux/macOS 上解出来是一个「名字里带反斜杠的单文件」(实测类缺陷同 ⑧)。
|
|
893
|
+
for rel in sorted(_posix(p.relative_to(pack_dir)) for p in pack_dir.rglob("*")):
|
|
894
|
+
if rel.endswith(".pyc") or "__pycache__" in rel or rel.endswith(".DS_Store"):
|
|
895
|
+
continue
|
|
896
|
+
src = pack_dir / rel
|
|
897
|
+
info = tf.gettarinfo(str(src), arcname=rel)
|
|
898
|
+
info.mtime = 0
|
|
899
|
+
info.uid = info.gid = 0
|
|
900
|
+
info.uname = info.gname = ""
|
|
901
|
+
if src.is_dir():
|
|
902
|
+
tf.addfile(info)
|
|
903
|
+
else:
|
|
904
|
+
with open(src, "rb") as fh:
|
|
905
|
+
tf.addfile(info, fh)
|
|
906
|
+
return tgz
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def build(cap: str, out_dir: pathlib.Path, doc: dict | None = None, force: bool = False) -> dict:
|
|
910
|
+
d = doc or load()
|
|
911
|
+
m = manifest(cap, d)
|
|
912
|
+
if m["missing"]:
|
|
913
|
+
raise RuntimeError(f"能力 {cap} 缺文件,拒绝装配:{m['missing']}")
|
|
914
|
+
out_dir = out_dir.resolve()
|
|
915
|
+
if out_dir == REPO or REPO in out_dir.parents:
|
|
916
|
+
raise RuntimeError(f"拒绝把包装配进仓库({out_dir})——包是分发产物,不是源码")
|
|
917
|
+
if out_dir.exists():
|
|
918
|
+
if not force:
|
|
919
|
+
raise RuntimeError(f"目标已存在:{out_dir}(要覆盖加 --force)")
|
|
920
|
+
shutil.rmtree(out_dir)
|
|
921
|
+
out_dir.mkdir(parents=True)
|
|
922
|
+
|
|
923
|
+
for rel in m["files"]:
|
|
924
|
+
src, dst = REPO / rel, out_dir / rel
|
|
925
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
926
|
+
if src.is_dir():
|
|
927
|
+
shutil.copytree(src, dst, dirs_exist_ok=True,
|
|
928
|
+
ignore=shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store"))
|
|
929
|
+
else:
|
|
930
|
+
shutil.copy2(src, dst)
|
|
931
|
+
|
|
932
|
+
# 过滤注册表:只留本能力(+ 它引用的流程与共同声明),装到客户机上不会"顺手带上别家能力"
|
|
933
|
+
sub = packed_registry(cap, d, m)
|
|
934
|
+
(out_dir / "scripts").mkdir(parents=True, exist_ok=True)
|
|
935
|
+
reg_text = json.dumps(sub, ensure_ascii=False, indent=2) + "\n"
|
|
936
|
+
(out_dir / "scripts" / "flow-requirements.json").write_text(reg_text, encoding="utf-8")
|
|
937
|
+
(out_dir / "PACK.json").write_text(json.dumps({
|
|
938
|
+
"capability": cap, "version": m["version"], "minPlan": m["minPlan"],
|
|
939
|
+
"flows": m["flows"], "entrypoints": m.get("entrypoints") or [],
|
|
940
|
+
"roles": m["roles"],
|
|
941
|
+
# 2026-09-22 修:生成出来的 `scripts/flow-requirements.json`(过滤后的注册表)**也必须进 sha 清单** ——
|
|
942
|
+
# 它决定客户机按什么口径跑(候选判据/闸门/计费/租约),此前不在清单里 ⇒ 被改过也没人发现。
|
|
943
|
+
"files": {**m["integrity"], "scripts/flow-requirements.json":
|
|
944
|
+
hashlib.sha256(reg_text.encode("utf-8")).hexdigest()[:16]},
|
|
945
|
+
"sourceRegistry": "scripts/flow-requirements.json",
|
|
946
|
+
}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
947
|
+
|
|
948
|
+
# 装配后自检:在**产物目录里**真跑(不是回头跑源仓库)。P2a(2026-10-09)起**必须走合并路径**
|
|
949
|
+
# (`ARGENT_REGISTRY_ROOT=<产物>`),不再用 `--registry` 的 legacy 单文件模式 —— 因为 flow 声明
|
|
950
|
+
# 不在投影里,而在随包带上的 `verticals/<id>/flows.json`。这一条同时就是**「装完即加载注册表」**
|
|
951
|
+
# 的装配期判据:产物自己必须是一棵能加载的注册表根(`flow_registry.py --json` exit 0 + 本能力
|
|
952
|
+
# 的流程一条不少)。⛔ 别退回去用 `--registry <投影>`:那正是 2026-10-09 P0 的形态。
|
|
953
|
+
checks = []
|
|
954
|
+
reg_env = {"ARGENT_REGISTRY_ROOT": str(out_dir), "ARGENT_REGISTRY_MODE": "merged"}
|
|
955
|
+
if m["flows"]:
|
|
956
|
+
probe = _registry_probe(out_dir, reg_env)
|
|
957
|
+
checks.append(probe["check"])
|
|
958
|
+
if probe["code"] == 0:
|
|
959
|
+
missing = [f for f in m["flows"] if f not in probe["flows"]]
|
|
960
|
+
if missing:
|
|
961
|
+
checks.append({"cmd": "flow_registry.py --json(本能力的流程都在?)", "code": 1,
|
|
962
|
+
"out": f"合并后共 {len(probe['flows'])} 条 flow",
|
|
963
|
+
"err": f"缺本能力的流程 {missing} —— 投影不含 flow,必须由 "
|
|
964
|
+
f"verticals/<id>/flows.json 提供(包有没有带上它?)"})
|
|
965
|
+
for fid in m["flows"]:
|
|
966
|
+
checks.append(_run([sys.executable, str(out_dir / "scripts" / "flow_config.py"),
|
|
967
|
+
"--flow", fid, "--lax"], cwd=out_dir, env_over=reg_env))
|
|
968
|
+
if m["integrity"] and any(r.startswith("scripts/integrations/") for r in m["files"]):
|
|
969
|
+
checks.append(_run([sys.executable, str(out_dir / "scripts" / "integration_contract.py"), "--validate"],
|
|
970
|
+
cwd=out_dir))
|
|
971
|
+
bad = [c for c in checks if c["code"] != 0]
|
|
972
|
+
return {"manifest": m, "outDir": str(out_dir), "checks": checks, "ok": not bad,
|
|
973
|
+
"registryFlows": list(m["flows"])}
|
|
974
|
+
|
|
975
|
+
|
|
976
|
+
def _registry_probe(out_dir: pathlib.Path, env_over: dict) -> dict:
|
|
977
|
+
"""在产物目录里跑 `flow_registry.py --json` ⇒ `{code, flows, check}`(装配期"装完即加载注册表"判据)。"""
|
|
978
|
+
argv = [sys.executable, str(out_dir / "scripts" / "flow_registry.py"), "--json"]
|
|
979
|
+
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1", **env_over)
|
|
980
|
+
check = {"cmd": "flow_registry.py --json", "code": 2, "out": "", "err": ""}
|
|
981
|
+
try:
|
|
982
|
+
r = subprocess.run(argv, capture_output=True, text=True, cwd=str(out_dir), timeout=120, env=env)
|
|
983
|
+
except Exception as exc: # noqa: BLE001 —— 起不来也算自检失败(不静默跳过)
|
|
984
|
+
check["err"] = f"跑不起来:{exc}"
|
|
985
|
+
return {"code": 2, "flows": [], "check": check}
|
|
986
|
+
if r.returncode != 0:
|
|
987
|
+
check.update(code=r.returncode, out=(r.stdout or "")[-300:], err=(r.stderr or "")[-300:])
|
|
988
|
+
return {"code": r.returncode, "flows": [], "check": check}
|
|
989
|
+
try:
|
|
990
|
+
flows = sorted((json.loads(r.stdout or "{}").get("flows") or {}).keys())
|
|
991
|
+
except Exception as exc: # noqa: BLE001
|
|
992
|
+
check["err"] = f"输出不是 JSON:{exc}"
|
|
993
|
+
return {"code": 2, "flows": [], "check": check}
|
|
994
|
+
check.update(code=0, out=f"flow {len(flows)} 条:{', '.join(flows)}")
|
|
995
|
+
return {"code": 0, "flows": flows, "check": check}
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _run(argv: list, cwd: pathlib.Path, env_over: dict | None = None) -> dict:
|
|
999
|
+
import os
|
|
1000
|
+
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1") # 自检别在产物里种 __pycache__
|
|
1001
|
+
env.update(env_over or {})
|
|
1002
|
+
r = subprocess.run(argv, capture_output=True, text=True, cwd=str(cwd), timeout=120, env=env)
|
|
1003
|
+
base = pathlib.Path(argv[1]).name if len(argv) > 1 else ""
|
|
1004
|
+
args = [str(a) for a in argv[2:]]
|
|
1005
|
+
# 命令回显保留脚本名(只报最后几个参数会看不出跑的是谁)
|
|
1006
|
+
return {"cmd": " ".join([base] + args[:3] + (["…"] if len(args) > 3 else [])),
|
|
1007
|
+
"code": r.returncode,
|
|
1008
|
+
"out": (r.stdout or "")[-600:], "err": (r.stderr or "")[-600:]}
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def main(argv=None) -> int:
|
|
1012
|
+
use_utf8_stdio() # 唯一出口编码判据:见 scripts/lib/stdio_utf8.py 的事故说明
|
|
1013
|
+
ap = argparse.ArgumentParser(description="能力包版本化(清单 / 校验 / 装配自检)")
|
|
1014
|
+
ap.add_argument("--list", action="store_true")
|
|
1015
|
+
ap.add_argument("--describe")
|
|
1016
|
+
ap.add_argument("--validate", action="store_true")
|
|
1017
|
+
ap.add_argument("--stamp", action="store_true")
|
|
1018
|
+
ap.add_argument("--allow-downgrade", action="store_true",
|
|
1019
|
+
help="--stamp:放行**降版**(刻意回滚用)。默认降版判红;放行时仍会显著告警")
|
|
1020
|
+
ap.add_argument("--check-stamp", action="store_true",
|
|
1021
|
+
help="**只读**自检:锁里记的内容摘要 == 当前内容重算摘要(批次收口前跑;"
|
|
1022
|
+
"⛔ 不联网、⛔ 不写盘;服务端那一侧仍由 release-argent.sh --check-dl 负责)")
|
|
1023
|
+
ap.add_argument("--build")
|
|
1024
|
+
ap.add_argument("--out-dir")
|
|
1025
|
+
ap.add_argument("--force", action="store_true")
|
|
1026
|
+
ap.add_argument("--tar", action="store_true",
|
|
1027
|
+
help="--build 时额外产出 <cap>-<version>.tgz(确定性打包;服务端清单的 url 指向它)")
|
|
1028
|
+
ap.add_argument("--emit-server-manifest", action="store_true",
|
|
1029
|
+
help="打印 whyshu-svr 消费的能力清单(能力→minPlan/版本/整包 sha256/下载地址)。"
|
|
1030
|
+
"默认 **merge**:给了 --out/--merge-from 且文件已存在时,注册表不认识的条目原样保留")
|
|
1031
|
+
ap.add_argument("--dl-base", help="包下载根(默认 $ARGENT_PACK_DL_BASE 或 https://whyshu.com/dl)")
|
|
1032
|
+
ap.add_argument("--out", help="--emit-server-manifest 的输出文件(省略则打到 stdout)")
|
|
1033
|
+
ap.add_argument("--merge-from", help="--emit-server-manifest 的合并来源清单(默认 = --out)")
|
|
1034
|
+
ap.add_argument("--prune", action="store_true",
|
|
1035
|
+
help="--emit-server-manifest:**显式**删除合并来源里注册表不认识的条目(默认保留;会把将删项打印出来)")
|
|
1036
|
+
ap.add_argument("--dry-run", action="store_true",
|
|
1037
|
+
help="--emit-server-manifest:只打印清单 diff,**不写文件**(仍建议发布前跑一次复核)")
|
|
1038
|
+
ap.add_argument("--json", action="store_true")
|
|
1039
|
+
ap.add_argument("--digest-tgz",
|
|
1040
|
+
help="算一个**已下载的**能力包 tgz 的整包内容摘要(与客户端 packContentDigest 同口径;"
|
|
1041
|
+
"发布体检用:HTTP 取回的包 vs 清单 sha256)")
|
|
1042
|
+
a = ap.parse_args(argv)
|
|
1043
|
+
if a.digest_tgz:
|
|
1044
|
+
# ⛔ 放在 `load()` **之前**:这一步只读一个 tgz,不该被"注册表读不出来"牵连
|
|
1045
|
+
# (发布体检可能在别的工作目录/裁剪树上跑)。
|
|
1046
|
+
try:
|
|
1047
|
+
info = digest_tarball(a.digest_tgz)
|
|
1048
|
+
except Exception as exc: # noqa: BLE001
|
|
1049
|
+
print(f" ❌ 摘要计算失败:{exc}", file=sys.stderr)
|
|
1050
|
+
return 2
|
|
1051
|
+
if a.json:
|
|
1052
|
+
print(json.dumps(info, ensure_ascii=False, indent=2))
|
|
1053
|
+
else:
|
|
1054
|
+
print(info["sha256"])
|
|
1055
|
+
if info["orderingDivergence"]:
|
|
1056
|
+
print(f" ⚠️ 路径排序口径分歧:按元组排序 = {info['sha256TupleOrder']}"
|
|
1057
|
+
f"(与客户端口径不同 ⇒ 这类路径名会让清单摘要与实际装机比对永远对不上)", file=sys.stderr)
|
|
1058
|
+
return 0
|
|
1059
|
+
if a.dry_run and not a.emit_server_manifest:
|
|
1060
|
+
print("--dry-run 目前只用于 --emit-server-manifest(打印清单 diff、不写文件)", file=sys.stderr)
|
|
1061
|
+
return 2
|
|
1062
|
+
try:
|
|
1063
|
+
doc = load()
|
|
1064
|
+
except Exception as exc: # noqa: BLE001
|
|
1065
|
+
print(f"❌ 读取注册表失败:{exc}", file=sys.stderr)
|
|
1066
|
+
return 2
|
|
1067
|
+
|
|
1068
|
+
if a.stamp:
|
|
1069
|
+
# `--allow-downgrade` 只关掉**降版**这一条闸门(明知故犯的回滚),⛔ 不顺带关掉"内容变但版本没动"。
|
|
1070
|
+
gate = [] if a.force else stamp_check(doc, allow_downgrade=a.allow_downgrade)
|
|
1071
|
+
if gate:
|
|
1072
|
+
print(" ❌ 拒绝盖章(版本纪律):", file=sys.stderr)
|
|
1073
|
+
for g in gate:
|
|
1074
|
+
print(f" {g}", file=sys.stderr)
|
|
1075
|
+
print(" ⇒ 先改 capabilities.<id>.pack.version(引擎文件改动会让**所有**能力包内容变化,"
|
|
1076
|
+
"四个包都要提),再重跑 --stamp;确实要跳过着装纪律请用 --force", file=sys.stderr)
|
|
1077
|
+
return 1
|
|
1078
|
+
# 降版是**有意行为**:放行了也要显著打出来(否则回滚会静默发生,与"降版事故"无从区分)
|
|
1079
|
+
drops = downgrade_check(doc)
|
|
1080
|
+
data = stamp(doc)
|
|
1081
|
+
LOCK.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
1082
|
+
if drops:
|
|
1083
|
+
# ⛔ 走 stderr:`--stamp --json` 的 stdout 必须是**纯 JSON**(既有调用方按此解析)
|
|
1084
|
+
print(" ⚠️⚠️ 已按 --allow-downgrade 放行**降版**(刻意回滚才应出现这一行)⚠️⚠️", file=sys.stderr)
|
|
1085
|
+
for x in drops:
|
|
1086
|
+
print(f" ⚠️ {x}", file=sys.stderr)
|
|
1087
|
+
print(" ⇒ 客户机按**版本号**判更新 ⇒ 降版后存量装机不会自动退回;"
|
|
1088
|
+
"要回滚请连同发布/投递一起规划(本命令只改锁)", file=sys.stderr)
|
|
1089
|
+
if a.json:
|
|
1090
|
+
print(json.dumps(data, ensure_ascii=False, indent=2))
|
|
1091
|
+
else:
|
|
1092
|
+
print(f" 🔖 版本锁已更新:{_display(LOCK)}"
|
|
1093
|
+
+ "".join(f"\n {c} v{v['version']}({len(v['files'])} 文件 / 摘要 {str(v.get('digest') or '')[:16]}…)"
|
|
1094
|
+
for c, v in sorted(data["capabilities"].items())))
|
|
1095
|
+
return 0
|
|
1096
|
+
|
|
1097
|
+
if a.check_stamp:
|
|
1098
|
+
# ⛔ 只读:不写锁、不写注册表、不种 __pycache__(_adapter_files 已带 PYTHONDONTWRITEBYTECODE)
|
|
1099
|
+
problems = check_stamp(doc)
|
|
1100
|
+
if a.json:
|
|
1101
|
+
print(json.dumps({"problems": problems}, ensure_ascii=False, indent=2))
|
|
1102
|
+
elif problems:
|
|
1103
|
+
print(" ❌ 盖章自检不通过(锁内摘要 ≠ 当前内容重算摘要):\n"
|
|
1104
|
+
+ "\n".join(f" {p}" for p in problems))
|
|
1105
|
+
else:
|
|
1106
|
+
caps = sorted(k for k in (doc.get("capabilities") or {}) if not k.startswith("_"))
|
|
1107
|
+
print(f" ✅ 盖章自检通过:{len(caps)} 个能力的锁内摘要 == 当前内容重算摘要"
|
|
1108
|
+
f"({'、'.join(caps)})")
|
|
1109
|
+
return 1 if problems else 0
|
|
1110
|
+
|
|
1111
|
+
if a.emit_server_manifest:
|
|
1112
|
+
src = a.merge_from or a.out
|
|
1113
|
+
existing = None
|
|
1114
|
+
if src and pathlib.Path(src).exists():
|
|
1115
|
+
try:
|
|
1116
|
+
existing = read_manifest_file(src)
|
|
1117
|
+
except ValueError as exc:
|
|
1118
|
+
print(f" ❌ 合并来源清单 {src} {exc}", file=sys.stderr)
|
|
1119
|
+
print(" ⇒ 拒绝在「读不懂旧清单」的情况下写盘(那会把它独有的条目抹掉);"
|
|
1120
|
+
"确认要整表重建请显式换一个不存在的 --out,或先修好该文件", file=sys.stderr)
|
|
1121
|
+
return 2
|
|
1122
|
+
sm = server_manifest(doc, a.dl_base or "", existing, a.prune)
|
|
1123
|
+
known = sorted(k for k in (doc.get("capabilities") or {}) if not k.startswith("_"))
|
|
1124
|
+
rep = manifest_merge_report(existing, sm, known, a.prune)
|
|
1125
|
+
text = json.dumps(sm, ensure_ascii=False, indent=2) + "\n"
|
|
1126
|
+
if a.dry_run:
|
|
1127
|
+
print(f" ── dry-run:清单 diff(⛔ 未写盘)──")
|
|
1128
|
+
print(f" 来源:{src or '(无 —— 未给 --out/--merge-from,按全新清单计算)'}")
|
|
1129
|
+
print(f" 刷新(注册表认识):{', '.join(rep['refreshed']) or '—'}")
|
|
1130
|
+
if rep["updated"]:
|
|
1131
|
+
for cap, field, old, new in rep["updated"]:
|
|
1132
|
+
print(f" ~ {cap}.{field}: {json.dumps(old, ensure_ascii=False)} → "
|
|
1133
|
+
f"{json.dumps(new, ensure_ascii=False)}")
|
|
1134
|
+
else:
|
|
1135
|
+
print(" ~ (无字段值变化)")
|
|
1136
|
+
print(f" ➕ 新增:{', '.join(rep['added']) or '—'}")
|
|
1137
|
+
print(f" ➖ 删除(--prune):{', '.join(rep['removed']) or '—'}"
|
|
1138
|
+
+ ("" if a.prune else "(未加 --prune ⇒ 不认识的条目一律保留)"))
|
|
1139
|
+
print(f" 🛡 保留(注册表不认识,原样未动):{', '.join(rep['preserved']) or '—'}")
|
|
1140
|
+
print(" ⇒ 复核无误后去掉 --dry-run 再跑一次才会落盘")
|
|
1141
|
+
return 0
|
|
1142
|
+
if a.out:
|
|
1143
|
+
pathlib.Path(a.out).write_text(text, encoding="utf-8")
|
|
1144
|
+
print(f" ✅ 已写出服务端能力清单:{a.out}({len(sm['capabilities'])} 个能力)")
|
|
1145
|
+
if existing is not None:
|
|
1146
|
+
print(f" 刷新 {len(rep['refreshed'])} 条:{', '.join(rep['refreshed']) or '—'}")
|
|
1147
|
+
if rep["added"]:
|
|
1148
|
+
print(f" 新增:{', '.join(rep['added'])}")
|
|
1149
|
+
if rep["preserved"]:
|
|
1150
|
+
print(f" 🛡 保留(注册表不认识,原样未动):{', '.join(rep['preserved'])}")
|
|
1151
|
+
if rep["removed"]:
|
|
1152
|
+
print(f" ➖ 已按 --prune 删除:{', '.join(rep['removed'])}")
|
|
1153
|
+
else:
|
|
1154
|
+
print(text, end="")
|
|
1155
|
+
return 0
|
|
1156
|
+
if a.validate:
|
|
1157
|
+
problems = validate(doc)
|
|
1158
|
+
if a.json:
|
|
1159
|
+
print(json.dumps({"problems": problems}, ensure_ascii=False, indent=2))
|
|
1160
|
+
elif problems:
|
|
1161
|
+
print(" ❌ 能力包校验不通过:\n" + "\n".join(f" {p}" for p in problems))
|
|
1162
|
+
else:
|
|
1163
|
+
print(" ✅ 每个能力包都自洽(版本 / 文件 / 流程 / preset / 提示词)")
|
|
1164
|
+
return 1 if problems else 0
|
|
1165
|
+
|
|
1166
|
+
if a.build:
|
|
1167
|
+
if not a.out_dir:
|
|
1168
|
+
print("--build 需要 --out-dir(包是分发产物,不落仓库)", file=sys.stderr)
|
|
1169
|
+
return 2
|
|
1170
|
+
try:
|
|
1171
|
+
res = build(a.build, pathlib.Path(a.out_dir), doc, a.force)
|
|
1172
|
+
except Exception as exc: # noqa: BLE001
|
|
1173
|
+
print(f" ❌ 装配失败:{exc}", file=sys.stderr)
|
|
1174
|
+
return 1
|
|
1175
|
+
tgz = None
|
|
1176
|
+
if a.tar:
|
|
1177
|
+
try:
|
|
1178
|
+
tgz = pack_tarball(a.build, pathlib.Path(res["outDir"]), doc)
|
|
1179
|
+
res["tarball"] = str(tgz)
|
|
1180
|
+
res["tarballSha256"] = hashlib.sha256(tgz.read_bytes()).hexdigest()
|
|
1181
|
+
except Exception as exc: # noqa: BLE001
|
|
1182
|
+
print(f" ❌ 打包失败:{exc}", file=sys.stderr)
|
|
1183
|
+
return 1
|
|
1184
|
+
if a.json:
|
|
1185
|
+
print(json.dumps(res, ensure_ascii=False, indent=2))
|
|
1186
|
+
else:
|
|
1187
|
+
m = res["manifest"]
|
|
1188
|
+
print(f" 📦 {a.build} v{m['version']} → {res['outDir']}"
|
|
1189
|
+
f"({m['unitCount']} 文件 / {m['dirCount']} 目录;流程 {', '.join(m['flows'])})")
|
|
1190
|
+
if tgz:
|
|
1191
|
+
print(f" 🗜 {tgz.name}(sha256 {res['tarballSha256'][:16]}…)")
|
|
1192
|
+
for c in res["checks"]:
|
|
1193
|
+
print(f" {'✅' if c['code'] == 0 else '❌'} 装配后自检 {c['cmd']}"
|
|
1194
|
+
+ ("" if c["code"] == 0 else f" → exit {c['code']}:{(c['err'] or c['out']).strip()[:200]}"))
|
|
1195
|
+
return 0 if res["ok"] else 1
|
|
1196
|
+
|
|
1197
|
+
if a.describe:
|
|
1198
|
+
try:
|
|
1199
|
+
m = manifest(a.describe, doc)
|
|
1200
|
+
except KeyError:
|
|
1201
|
+
print(f"❌ 没有这个能力:{a.describe}", file=sys.stderr)
|
|
1202
|
+
return 1
|
|
1203
|
+
if a.json:
|
|
1204
|
+
print(json.dumps(m, ensure_ascii=False, indent=2))
|
|
1205
|
+
else:
|
|
1206
|
+
print(f" {m['capability']} v{m['version']}(档位 {m['minPlan']})")
|
|
1207
|
+
print(f" 流程:{', '.join(m['flows']) or '(无 —— 工作台原生能力)'}")
|
|
1208
|
+
if m.get("entrypoints"):
|
|
1209
|
+
print(f" 入口:{', '.join(m['entrypoints'])}")
|
|
1210
|
+
print(f" 角色/preset:{', '.join(m['roles'])}")
|
|
1211
|
+
for f in m["files"]:
|
|
1212
|
+
kind = "目录" if (REPO / f).is_dir() else "文件"
|
|
1213
|
+
print(f" [{kind}] {f}")
|
|
1214
|
+
if m["missing"]:
|
|
1215
|
+
print(f" ❌ 缺:{m['missing']}")
|
|
1216
|
+
return 0
|
|
1217
|
+
|
|
1218
|
+
caps = {k: v for k, v in (doc.get("capabilities") or {}).items() if not k.startswith("_")}
|
|
1219
|
+
rows = []
|
|
1220
|
+
for cap in sorted(caps):
|
|
1221
|
+
m = manifest(cap, doc)
|
|
1222
|
+
rows.append({"capability": cap, "version": m["version"], "minPlan": m["minPlan"],
|
|
1223
|
+
"flows": m["flows"], "entrypoints": m.get("entrypoints") or [],
|
|
1224
|
+
"files": m["unitCount"] + m["dirCount"],
|
|
1225
|
+
"missing": len(m["missing"])})
|
|
1226
|
+
if a.json or a.list:
|
|
1227
|
+
if a.json:
|
|
1228
|
+
print(json.dumps(rows, ensure_ascii=False, indent=2))
|
|
1229
|
+
else:
|
|
1230
|
+
for r in rows:
|
|
1231
|
+
what = f"流程={len(r['flows'])}" if r["flows"] else \
|
|
1232
|
+
f"入口={len(r.get('entrypoints') or [])}"
|
|
1233
|
+
print(f" {r['capability']:18} v{r['version']:10} 档位={r['minPlan']:11}"
|
|
1234
|
+
f" {what} 条目={r['files']}"
|
|
1235
|
+
+ (f" 缺 {r['missing']} 个文件" if r["missing"] else ""))
|
|
1236
|
+
return 0
|
|
1237
|
+
ap.print_help()
|
|
1238
|
+
return 2
|
|
1239
|
+
|
|
1240
|
+
|
|
1241
|
+
if __name__ == "__main__":
|
|
1242
|
+
raise SystemExit(main())
|