@whyshu/argent-engine 0.0.0-stage → 0.6.0
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 +307 -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 +575 -0
- package/engine/scripts/billing-mode-daily.sh +12 -0
- package/engine/scripts/build-engine-package.mjs +123 -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 +377 -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/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 +134 -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 +46 -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 +260 -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/fix-skill-frontmatter.py +209 -0
- package/engine/scripts/flow-auto-retry.py +454 -0
- package/engine/scripts/flow-requirements.json +720 -0
- package/engine/scripts/flow-scaffold.py +275 -0
- package/engine/scripts/flow-timers-plist.sh +86 -0
- package/engine/scripts/flow-timers.sh +182 -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 +1024 -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 +2275 -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 +194 -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.py +262 -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 +293 -0
- package/engine/scripts/poll-invoice.sh +74 -0
- package/engine/scripts/profile-doctor.mjs +686 -0
- package/engine/scripts/provider-select.sh +70 -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/run-tests.sh +256 -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 +136 -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 +73 -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 +34 -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 +216 -0
- package/engine/scripts/ziniao-post-upgrade-check.sh +120 -0
- package/engine/scripts/ziniao-windows.sh +153 -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 +706 -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 +21 -4
- package/README.md +0 -3
|
@@ -0,0 +1,1024 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""flow_config —— **每流程运行时配置的唯一读取点**(引擎泛化的配置面)。
|
|
3
|
+
|
|
4
|
+
为什么要有它(2026-09-16,P0 引擎泛化):
|
|
5
|
+
泛化前,"并发上限 / 暂停开关 / 日志名 / 唤醒目标 preset / 提示词模板"这些**运行时参数**
|
|
6
|
+
硬编码在 `flow-trigger.sh`(`--flow wenyao-invoice`、`PAUSE_FLOW_TRIGGER`、`flow-trigger.log`)
|
|
7
|
+
和 `flow-trigger-wake.mjs`(`agentPreset:'argent-ml-invoice-wy'` + 内嵌提示词)里 ⇒ 加一条流程
|
|
8
|
+
就要改脚本、就会漏改。现在全部收进注册表,本模块是唯一读取点 + 缺省推导;
|
|
9
|
+
bash / node / python 一律通过它取配置(`--get` / `--json` / `--paths`)。
|
|
10
|
+
|
|
11
|
+
配置分段(`scripts/flow-requirements.json#flows.<id>`):
|
|
12
|
+
source {base,table,identity,routing,required} 数据源与字段要求(既有段,不动)
|
|
13
|
+
· **多源变体**(2026-09-16 用户口径:"开始用 legacy 表,后面用 lip 表,无论要求用
|
|
14
|
+
什么我们都要适配")⇒ `source.variants{<名>: {...}}` + `source.select`
|
|
15
|
+
(`env:VAR` 或字面量,缺省 `source.default` 或第一个变体)+ `source.default`;
|
|
16
|
+
解析结果放在输出的 `source`(含 `variant` 字段)与 `sourceVariant`。
|
|
17
|
+
· 表 id 可来自**部署环境**而非仓库:`baseEnv`/`tableEnv`(环境变量名)
|
|
18
|
+
或 `baseRef`/`tableRef`(`路径#a.b` 引用,如 invoice-mapping.json)。
|
|
19
|
+
trigger {intervalSec,wake,pauseSwitch,log,promptTemplate,
|
|
20
|
+
dispatch{preset,cwd,sessionIdFile}} 触发与唤醒
|
|
21
|
+
concurrency {max,leaseMin} 并发上限与租约
|
|
22
|
+
guards {dryRunDefault,backup,audit,writeFields} 写入守卫(P3 用)
|
|
23
|
+
failures {enabled,scanSec,maxPerCase,cooldownMin,maxPerRound}
|
|
24
|
+
「失败→唤醒角色」回路策略(P0.6):默认 **enabled=false**;只有显式打开的流程
|
|
25
|
+
才会被引擎唤醒(当前仅 wenyao-invoice)。仅 needs_agent 类失败会唤醒。
|
|
26
|
+
|
|
27
|
+
缺省推导(**新流程只写 source 也能跑起来**,运行时参数全部有缺省):
|
|
28
|
+
pauseSwitch → PAUSE_FLOW_<FLOW_ID 大写、`-`→`_`>
|
|
29
|
+
log → flow-trigger-<flow>.log
|
|
30
|
+
promptTemplate → flow-prompts/<flow>.md
|
|
31
|
+
sessionIdFile → flow-dispatch-<flow>.json (唤醒复用常驻会话,避免每轮 session/create)
|
|
32
|
+
dispatch.preset → argent-<role> (role 取自注册表)
|
|
33
|
+
dispatch.cwd → ~/Code
|
|
34
|
+
lock → flow-claim-<flow>.lock (认领锁按流程分文件)
|
|
35
|
+
wake → session(合法值只有 session|off;旧的"飞书 @ 唤醒"已废弃,不保留)
|
|
36
|
+
|
|
37
|
+
用法:
|
|
38
|
+
python3 scripts/flow_config.py --list
|
|
39
|
+
python3 scripts/flow_config.py --flow wenyao-invoice --json
|
|
40
|
+
python3 scripts/flow_config.py --flow wenyao-invoice --get trigger.pauseSwitch
|
|
41
|
+
python3 scripts/flow_config.py --flow wenyao-invoice --paths --json
|
|
42
|
+
python3 scripts/flow_config.py --flow full --json --lax # 缺部署配置也不报错(控制台用)
|
|
43
|
+
退出码:0 成功;2 未知流程/参数错误。
|
|
44
|
+
"""
|
|
45
|
+
from __future__ import annotations
|
|
46
|
+
import argparse
|
|
47
|
+
import json
|
|
48
|
+
import os
|
|
49
|
+
import pathlib
|
|
50
|
+
import re
|
|
51
|
+
import sys
|
|
52
|
+
|
|
53
|
+
HERE = pathlib.Path(__file__).resolve().parent
|
|
54
|
+
REGISTRY = HERE / 'flow-requirements.json'
|
|
55
|
+
# P1(2026-09-29):注册表改为「引擎级 base + 每条业务线一份」,合并与冲突判据在
|
|
56
|
+
# flow_registry 里(唯一真源)。显式传 registry_path 时走 legacy 单文件(历史行为)。
|
|
57
|
+
sys.path.insert(0, str(HERE))
|
|
58
|
+
try:
|
|
59
|
+
import flow_registry # noqa: E402
|
|
60
|
+
except Exception: # noqa: BLE001 —— 加载器缺失时退回单文件,避免整条链路瘫痪
|
|
61
|
+
flow_registry = None
|
|
62
|
+
# 数据目录:**唯一判据**在 `scripts/lib/argent_datadir.py`(三级链 `ARGENT_DATA_DIR` →
|
|
63
|
+
# `$DSH_HOME/argent` → `~/.dsh/argent`,与 JS 内核 `core/lib/argent-datadir.mjs` 同语义)。
|
|
64
|
+
# 此前这里只认两级(**不看 DSH_HOME**)⇒ 只设 `DSH_HOME` 的隔离实例里插件写隔离目录,
|
|
65
|
+
# 而 CLI/cron 直跑(flow_config/flow_claim/flow_triage…)读**生产**目录(2026-10-01 事故形态)。
|
|
66
|
+
sys.path.insert(0, str(HERE / 'lib'))
|
|
67
|
+
from argent_datadir import resolve_data_dir # noqa: E402 —— 数据目录唯一判据(勿在此复制链)
|
|
68
|
+
DATA_DIR = pathlib.Path(resolve_data_dir()['dir'])
|
|
69
|
+
|
|
70
|
+
DEFAULTS = {
|
|
71
|
+
# B2(2026-09-24):jitterSec = 每轮认领前随机等待的上限(秒)。固定 300s 整点起是最规律的
|
|
72
|
+
# 机器节拍;人工不会每次都在同一秒发起。可被注册表 flows.<id>.trigger.jitterSec 覆盖;
|
|
73
|
+
# 0 = 关闭(flow-trigger.sh 里另有 FLOW_TRIGGER_NO_JITTER=1 应急开关)。
|
|
74
|
+
'trigger': {'intervalSec': 300, 'jitterSec': 45, 'wake': 'session', 'mode': 'shadow'},
|
|
75
|
+
'concurrency': {'max': 2, 'leaseMin': 10},
|
|
76
|
+
'guards': {'dryRunDefault': True, 'backup': False, 'audit': False, 'writeFields': []},
|
|
77
|
+
'failures': {'enabled': False, 'scanSec': 60, 'maxPerCase': 3, 'cooldownMin': 15, 'maxPerRound': 2},
|
|
78
|
+
# 失败自动重试(用户口径方案 a,2026-10-13):**可用性类**失败(唯一分类器判 retryable)
|
|
79
|
+
# 在上限 + 冷却约束下自动"重置后重建",而不是等人工手跑。四个量的**唯一读取点**在这里
|
|
80
|
+
# (判据与守卫在 `scripts/flow-auto-retry.py`;⛔ 触发器与脚本都不得自带第二份默认值)。
|
|
81
|
+
'autoRetry': {'enabled': True, 'max': 2, 'cooldownMin': 20, 'perCycle': 2},
|
|
82
|
+
}
|
|
83
|
+
WAKE_MODES = ('session', 'off')
|
|
84
|
+
# 本轮语义:real=真实写入生产;shadow=只读/零副作用(缺省 shadow —— 不宣称真实写入)
|
|
85
|
+
WRITE_MODES = ('real', 'shadow')
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
89
|
+
# 配置中心:声明的 `default` 读取(2026-10-10,FULL 自动路径成功率修复批)
|
|
90
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
91
|
+
# **为什么需要它**:配置项的声明面(`configs/<域>.json` 引擎级 + `verticals/<id>/config.json`
|
|
92
|
+
# 业务线级)是「有哪些可配项、默认值是多少」的**唯一真源**,而它的读取器
|
|
93
|
+
# (`core/lib/config-catalog.mjs`)是 **Node 侧**实现 —— Python 侧(编排器/CLI)import 不到。
|
|
94
|
+
# 若在 Python 里再写一个字面量默认值,同一个数就有两处真源 ⇒ 迟早漂移(本仓库反复踩的
|
|
95
|
+
# "判据第二份"形态:门禁能证明"注册表与代码一致",但证明不了"两个字面量恰好相等")。
|
|
96
|
+
#
|
|
97
|
+
# ⛔ **它不是第二条解析链**:本函数**只**按 key 取声明的 `default` 一个字段,
|
|
98
|
+
# **不**解析 env / envfile / json / 机器文件的优先级 —— 那条链仍然只有 Node 读取器一份
|
|
99
|
+
# (`node scripts/config-explain.mjs --key <k>` 是它的只读出口)。
|
|
100
|
+
# ✅ 生效链(与声明逐字一致,见 `configs/*.json#items[].todayChain`):
|
|
101
|
+
# ① 进程环境变量 `<binding.env>` → ② 声明的 `default`。env **优先**(逃生阀:运维临时改大窗口)。
|
|
102
|
+
def catalog_default(key: str, root: pathlib.Path | None = None):
|
|
103
|
+
"""按 key 从配置声明面取 `default`(**只读声明,不解析链**);取不到返回 `None`。
|
|
104
|
+
|
|
105
|
+
扫描面与 Node 读取器**同源**:`<root>/configs/*.json` + `<root>/verticals/*/config.json`。
|
|
106
|
+
⛔ 读不到 / 声明非法 / 解析失败一律返回 `None`(调用方决定怎么保守),**不抛异常**。
|
|
107
|
+
"""
|
|
108
|
+
import glob as _glob # 局部 import:本模块被 CLI 高频调用,避免污染模块级命名空间
|
|
109
|
+
root = pathlib.Path(root) if root else HERE.parent
|
|
110
|
+
pats = [str(root / 'configs' / '*.json'),
|
|
111
|
+
str(root / 'verticals' / '*' / 'config.json')]
|
|
112
|
+
for pat in pats:
|
|
113
|
+
for fp in sorted(_glob.glob(pat)):
|
|
114
|
+
try:
|
|
115
|
+
doc = json.loads(pathlib.Path(fp).read_text(encoding='utf-8'))
|
|
116
|
+
except Exception: # noqa: BLE001
|
|
117
|
+
continue
|
|
118
|
+
for it in (doc.get('items') or []):
|
|
119
|
+
if isinstance(it, dict) and it.get('key') == key:
|
|
120
|
+
return it.get('default')
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _coerce_int(raw, dflt: int) -> int:
|
|
125
|
+
try:
|
|
126
|
+
return int(str(raw).strip())
|
|
127
|
+
except (TypeError, ValueError):
|
|
128
|
+
return dflt
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _coerce_bool(raw, dflt: bool) -> bool:
|
|
132
|
+
"""声明里的布尔(缺省值取 `dflt`)。字符串按常见真/假写法认,认不出 ⇒ 取缺省(保守)。"""
|
|
133
|
+
if raw is None:
|
|
134
|
+
return dflt
|
|
135
|
+
if isinstance(raw, bool):
|
|
136
|
+
return raw
|
|
137
|
+
s = str(raw).strip().lower()
|
|
138
|
+
if s in ('1', 'true', 'yes', 'on'):
|
|
139
|
+
return True
|
|
140
|
+
if s in ('0', 'false', 'no', 'off', ''):
|
|
141
|
+
return False
|
|
142
|
+
return dflt
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def resolve_auto_retry(spec: dict) -> dict:
|
|
146
|
+
"""「失败自动重试」的四个量 —— **唯一读取点**(2026-10-13,用户口径方案 a)。
|
|
147
|
+
|
|
148
|
+
背景:可用性类失败(网络/限流/浏览器瞬时类,由 `flow_failure.classify` 判 `retryable`)
|
|
149
|
+
落到台账 `failed` → `blocked` 之后,触发器只会报"需人工",从此不再自动跑(现场两条票就是
|
|
150
|
+
人工手跑回来的)。方案 a = 这类失败在**上限 + 冷却**约束下自动重试。
|
|
151
|
+
|
|
152
|
+
生效链:注册表 `flows.<id>.autoRetry.<键>`(按流程覆盖,可选)→ 本模块 `DEFAULTS['autoRetry']`。
|
|
153
|
+
⛔ 调用方(`scripts/flow-auto-retry.py` / `scripts/flow-trigger.sh`)**不得自带第二份默认值**:
|
|
154
|
+
需要这几个数就只经 `flow_config --get autoRetry.<键>` 取;本段缺失即"配置读取点版本过旧",
|
|
155
|
+
调用方应停住并点名,而不是猜一个数继续。
|
|
156
|
+
"""
|
|
157
|
+
raw = (spec or {}).get('autoRetry') or {}
|
|
158
|
+
if not isinstance(raw, dict):
|
|
159
|
+
die(f'autoRetry 声明非法(需要对象): {raw!r}')
|
|
160
|
+
d = DEFAULTS['autoRetry']
|
|
161
|
+
out = {
|
|
162
|
+
'enabled': _coerce_bool(raw.get('enabled'), d['enabled']),
|
|
163
|
+
'max': _coerce_int(raw.get('max') if raw.get('max') is not None else d['max'], d['max']),
|
|
164
|
+
'cooldownMin': _coerce_int(raw.get('cooldownMin') if raw.get('cooldownMin') is not None
|
|
165
|
+
else d['cooldownMin'], d['cooldownMin']),
|
|
166
|
+
'perCycle': _coerce_int(raw.get('perCycle') if raw.get('perCycle') is not None
|
|
167
|
+
else d['perCycle'], d['perCycle']),
|
|
168
|
+
}
|
|
169
|
+
if out['max'] < 1 or out['perCycle'] < 1 or out['cooldownMin'] < 0:
|
|
170
|
+
die(f'autoRetry 取值非法: {out}(max/perCycle ≥ 1、cooldownMin ≥ 0)')
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def appointment_max_day() -> int:
|
|
175
|
+
"""步骤4 顺延窗口(天)——**配置中心唯一入口**:`advanced.appointment.max_day`。
|
|
176
|
+
|
|
177
|
+
生效链:`ARGENT_APPOINTMENT_MAX_DAY`(进程 env,**优先**)→ 声明 `default`。
|
|
178
|
+
⛔ **行为变更(2026-10-10)**:默认值 3 → **5**。理由(用户亲历 + 我方实测):
|
|
179
|
+
约 1/3 的 FULL 失败与"最近几天都被平台拒"相关,窗口=3(最多顺延 2 天)**够不到**
|
|
180
|
+
可约日期;手动串行路径显式用 10 跑 6 票 8/8 成功。取 5 是"够用且不至于把店铺窗口
|
|
181
|
+
耗在无望的日期上"的折中;窗口越大会线性增加单票最长耗时(每天一次导航 + 最多 3 次提交)。
|
|
182
|
+
运维要改回旧行为:`ARGENT_APPOINTMENT_MAX_DAY=3`(env 优先,不必改声明)。
|
|
183
|
+
"""
|
|
184
|
+
raw = os.environ.get('ARGENT_APPOINTMENT_MAX_DAY')
|
|
185
|
+
if raw is not None and str(raw).strip() != '':
|
|
186
|
+
return max(1, min(90, _coerce_int(raw, 5)))
|
|
187
|
+
d = catalog_default('advanced.appointment.max_day')
|
|
188
|
+
return max(1, min(90, _coerce_int(d, 5) if d is not None else 5))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def die(msg: str) -> None:
|
|
192
|
+
"""错误口径:一律 stderr + exit 2(与 flow_case/flow_source/flow_claim 一致)。"""
|
|
193
|
+
print(msg, file=sys.stderr)
|
|
194
|
+
raise SystemExit(2)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _resolve_prompt_path(rel: str) -> pathlib.Path:
|
|
199
|
+
"""提示词路径解析(P2a,2026-09-29)。
|
|
200
|
+
|
|
201
|
+
历史缺省是 `flow-prompts/<flow>.md`(相对 `scripts/`);业务线落位后注册表声明的是
|
|
202
|
+
**仓库根相对**路径(`verticals/<id>/prompts/<flow>.md`)。两种都要认 ——
|
|
203
|
+
否则业务线提示词会被解析成 `scripts/verticals/...`(实测踩过),投递时报找不到模板。
|
|
204
|
+
"""
|
|
205
|
+
p = pathlib.Path(str(rel))
|
|
206
|
+
if p.is_absolute():
|
|
207
|
+
return p
|
|
208
|
+
for base in (HERE.parent, HERE):
|
|
209
|
+
cand = base / p
|
|
210
|
+
if cand.exists():
|
|
211
|
+
return cand
|
|
212
|
+
return (HERE.parent if str(rel).startswith('verticals/') else HERE) / p
|
|
213
|
+
|
|
214
|
+
def load_registry(registry_path=None) -> dict:
|
|
215
|
+
"""读 flows(历史形状)。P1 起经 flow_registry 合并 base + verticals。"""
|
|
216
|
+
if flow_registry is not None:
|
|
217
|
+
try:
|
|
218
|
+
return flow_registry.load_flows(registry_path)
|
|
219
|
+
except flow_registry.Conflict as exc:
|
|
220
|
+
die(f'流程注册表合并失败: {exc}')
|
|
221
|
+
except SystemExit:
|
|
222
|
+
raise
|
|
223
|
+
except Exception as exc: # noqa: BLE001
|
|
224
|
+
die(f'读取流程注册表失败: {exc}')
|
|
225
|
+
p = pathlib.Path(registry_path) if registry_path else REGISTRY
|
|
226
|
+
try:
|
|
227
|
+
doc = json.loads(p.read_text(encoding='utf-8'))
|
|
228
|
+
except Exception as exc:
|
|
229
|
+
die(f'读取流程注册表失败: {p}: {exc}')
|
|
230
|
+
flows = doc.get('flows')
|
|
231
|
+
if not isinstance(flows, dict):
|
|
232
|
+
die(f'流程注册表格式错误(缺 flows 对象): {p}')
|
|
233
|
+
return flows
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def load_capabilities(registry_path=None) -> dict:
|
|
237
|
+
"""读注册表文档里的 `capabilities` 段(与 `flows` 同级)。
|
|
238
|
+
|
|
239
|
+
⚠️ `load_registry()` 只返回 `flows`(历史形状,多处依赖)⇒ 能力层必须单独取,
|
|
240
|
+
否则 `_derive` 里会看到"未知 capability"(2026-09-20 实现步骤 3 时踩到)。
|
|
241
|
+
"""
|
|
242
|
+
if flow_registry is not None:
|
|
243
|
+
try:
|
|
244
|
+
return flow_registry.load_capabilities(registry_path)
|
|
245
|
+
except Exception: # noqa: BLE001 —— 能力层缺失不该拖垮配置解析
|
|
246
|
+
return {}
|
|
247
|
+
p = pathlib.Path(registry_path) if registry_path else REGISTRY
|
|
248
|
+
try:
|
|
249
|
+
doc = json.loads(p.read_text(encoding='utf-8'))
|
|
250
|
+
except Exception:
|
|
251
|
+
return {}
|
|
252
|
+
caps = doc.get('capabilities')
|
|
253
|
+
return caps if isinstance(caps, dict) else {}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def flow_ids(registry_path=None) -> list:
|
|
257
|
+
return sorted(load_registry(registry_path).keys())
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _resolve_env_path(rel_or_abs: str) -> pathlib.Path:
|
|
262
|
+
"""env 文件解析(P2a,2026-09-29):**规范路径优先、旧平铺路径兜底**。
|
|
263
|
+
|
|
264
|
+
规范:`verticals/<id>/env/<name>.env`(入库相对路径,落在仓库内或 DATA_DIR 下均可)。
|
|
265
|
+
兜底:`~/.dsh/argent/<name>.env`(历史平铺,两条业务线同机时会撞名 ⇒ 逐阶段退休)。
|
|
266
|
+
迁移期两种都要能读,否则"改注册表"那一刻流程会读不到凭据。
|
|
267
|
+
"""
|
|
268
|
+
p = pathlib.Path(os.path.expanduser(rel_or_abs))
|
|
269
|
+
if p.is_absolute():
|
|
270
|
+
return p
|
|
271
|
+
if rel_or_abs.startswith('verticals/'):
|
|
272
|
+
for base in (HERE.parent, DATA_DIR):
|
|
273
|
+
cand = base / rel_or_abs
|
|
274
|
+
if cand.exists():
|
|
275
|
+
return cand
|
|
276
|
+
# 规范路径不存在 ⇒ 回退旧平铺文件(`~/.dsh/argent/<name>.env`):装机脚本/历史机器
|
|
277
|
+
# 可能仍把 env 写在旧位置,宁可用上也不能"静默没有凭据"(strict 时会打印路径)。
|
|
278
|
+
flat = DATA_DIR / p.name
|
|
279
|
+
if flat.exists():
|
|
280
|
+
return flat
|
|
281
|
+
return DATA_DIR / rel_or_abs # 两处都没有:按规范路径报错
|
|
282
|
+
# 旧平铺名:若规范位置已有同名文件(verticals/*/env/),优先用它
|
|
283
|
+
for hit in sorted((DATA_DIR / 'verticals').glob(f'*/env/{p.name}')):
|
|
284
|
+
return hit
|
|
285
|
+
return DATA_DIR / p
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def load_env_file(rel_or_abs: str, strict: bool = False) -> int:
|
|
289
|
+
"""加载部署环境文件(KEY=VALUE 逐行;`#` 注释;已存在的环境变量**不覆盖**)。
|
|
290
|
+
|
|
291
|
+
为什么:FULL 的表 id/凭据放在 `~/.dsh/argent/fulfillment.env`(不进仓库、不进进程 env),
|
|
292
|
+
流程要能读到它才能工作。返回加载的键数;文件不存在时 strict=False 静默返回 0。
|
|
293
|
+
"""
|
|
294
|
+
if not rel_or_abs:
|
|
295
|
+
return 0
|
|
296
|
+
path = _resolve_env_path(str(rel_or_abs))
|
|
297
|
+
if not path.exists():
|
|
298
|
+
if strict:
|
|
299
|
+
die(f'流程声明的环境文件不存在: {path}')
|
|
300
|
+
return 0
|
|
301
|
+
n = 0
|
|
302
|
+
try:
|
|
303
|
+
for raw in path.read_text(encoding='utf-8').splitlines():
|
|
304
|
+
line = raw.strip()
|
|
305
|
+
if not line or line.startswith('#') or '=' not in line:
|
|
306
|
+
continue
|
|
307
|
+
k, _, v = line.partition('=')
|
|
308
|
+
k, v = k.strip(), v.strip().strip('"').strip("'")
|
|
309
|
+
if k and k not in os.environ:
|
|
310
|
+
os.environ[k] = v
|
|
311
|
+
n += 1
|
|
312
|
+
except Exception as exc:
|
|
313
|
+
if strict:
|
|
314
|
+
die(f'读取环境文件失败 {path}: {exc}')
|
|
315
|
+
return n
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _read_ref(ref: str, strict: bool = True) -> str:
|
|
319
|
+
"""解析 `路径#a.b.c` 引用(数据目录里的部署配置,如 invoice-mapping.json)。"""
|
|
320
|
+
path_part, _, dotted = ref.partition('#')
|
|
321
|
+
p = pathlib.Path(os.path.expanduser(path_part))
|
|
322
|
+
try:
|
|
323
|
+
cur = json.loads(p.read_text(encoding='utf-8'))
|
|
324
|
+
except Exception as exc:
|
|
325
|
+
if not strict:
|
|
326
|
+
return ''
|
|
327
|
+
die(f'读取配置引用失败: {p}: {exc}')
|
|
328
|
+
for part in [x for x in dotted.split('.') if x]:
|
|
329
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
330
|
+
if not strict:
|
|
331
|
+
return ''
|
|
332
|
+
die(f'配置引用缺键: {ref}(在 {part} 处)')
|
|
333
|
+
cur = cur[part]
|
|
334
|
+
return str(cur)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def resolve_source(flow_id: str, spec: dict, strict: bool = True) -> tuple:
|
|
338
|
+
"""把 source 解析成**当前生效的一份**:变体选择 + 表 id 的 env/ref 解析。
|
|
339
|
+
|
|
340
|
+
返回 (resolved_source, variant_name|None, variants_raw)。
|
|
341
|
+
"""
|
|
342
|
+
src = dict(spec.get('source') or {})
|
|
343
|
+
variants = src.pop('variants', None)
|
|
344
|
+
select = src.pop('select', None)
|
|
345
|
+
default = src.pop('default', None)
|
|
346
|
+
if not variants:
|
|
347
|
+
return _resolve_ids(flow_id, src, strict), None, {}
|
|
348
|
+
if not isinstance(variants, dict) or not variants:
|
|
349
|
+
die(f'流程 {flow_id} 的 source.variants 必须是非空对象')
|
|
350
|
+
if isinstance(select, str) and select.startswith('env:'):
|
|
351
|
+
var = select[4:]
|
|
352
|
+
active = os.environ.get(var) or default or next(iter(variants))
|
|
353
|
+
else:
|
|
354
|
+
active = (select if isinstance(select, str) and select else None) or default or next(iter(variants))
|
|
355
|
+
if active not in variants:
|
|
356
|
+
die(f'流程 {flow_id} 的源变体非法: {active}(可选: {", ".join(variants)};选择器: {select or "缺省"})')
|
|
357
|
+
merged = {**src, **(variants[active] or {})}
|
|
358
|
+
merged['variant'] = active
|
|
359
|
+
return _resolve_ids(flow_id, merged, strict), active, variants
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
363
|
+
# 宿主排程声明(P1-A,2026-10-12)—— `trigger.schedule` 的**唯一运行期读取点**
|
|
364
|
+
# ══════════════════════════════════════════════════════════════════════════
|
|
365
|
+
# 为什么要有:把某条流程的触发从「每 5 分钟轮询」改成「宿主原生 daily 排程」时,"排程器该读哪一段"
|
|
366
|
+
# 必须有**唯一**答案,否则装机脚本、运维命令、声明门禁各读一份 ⇒ 迟早漂移。这里与
|
|
367
|
+
# `scripts/vertical-lint.mjs#schedule` **同一套判据**(那边是装配期门禁,这边是运行期读取):
|
|
368
|
+
# · 键白名单(⛔ 不许把业务规则/字段名/阈值写进排程段 —— 那属 L0/L1);
|
|
369
|
+
# · 选择器自洽(kind/time/timeZone/weekdays/expression/everySecondsFrom);
|
|
370
|
+
# · `taskTitle` 1..120 且无首尾空白(宿主硬约束);
|
|
371
|
+
# · `promptFile` **必须存在**且是**静态**指令(宿主在创建任务时固定 prompt);
|
|
372
|
+
# · `onPause` ∈ deactivate|prompt-guard。
|
|
373
|
+
# ⛔ 周期**不在**这一段:`kind=every` 时用 `everySecondsFrom: "intervalSec"` 指回 `trigger.intervalSec`
|
|
374
|
+
# (周期只有一份真源,本仓库为"第二份真源"付过多次代价)。
|
|
375
|
+
SCHEDULE_KEYS = ('enabled', 'kind', 'time', 'timeZone', 'weekdays', 'expression', 'at',
|
|
376
|
+
'everySecondsFrom', 'taskTitle', 'promptFile', 'onPause')
|
|
377
|
+
SCHEDULE_KINDS = ('every', 'daily', 'weekly', 'cron', 'at')
|
|
378
|
+
SCHEDULE_ON_PAUSE = ('deactivate', 'prompt-guard')
|
|
379
|
+
_SCHEDULE_TIME_RE = re.compile(r'^([01]\d|2[0-3]):[0-5]\d(?::[0-5]\d(?:\.\d{1,3})?)?$')
|
|
380
|
+
_SCHEDULE_BAD_CRON_RE = re.compile(r'[LW#]|\b(?:JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|MON|TUE|WED|THU|FRI|SAT|SUN)\b|^@')
|
|
381
|
+
# `{{name}}` 形态的模板占位符(宿主 prompt 创建时固定 ⇒ 静态指令里 ⛔ 不许出现)
|
|
382
|
+
_SCHEDULE_TEMPLATE_RE = re.compile(r'\{\{\s*[A-Za-z_][\w.-]*\s*\}\}')
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _resolve_vertical_path(flow_id: str, rel: str) -> pathlib.Path:
|
|
386
|
+
"""业务线文件的路径解析(P1-A):**相对业务线目录**优先,也接受 `verticals/<id>/…` 的仓库根相对写法。
|
|
387
|
+
|
|
388
|
+
`flow_id` 是规范 id(`<vertical>.<local>`)⇒ 业务线 id 取点号前的那一段。
|
|
389
|
+
两种写法都认的理由:注册表里既有 `verticals/<id>/prompts/x.md`(trigger.promptTemplate 的历史形态),
|
|
390
|
+
P1-A 的 `schedule.promptFile` 用的是业务线内相对路径(`prompts/x.md`)—— 同一个解析器认两种,
|
|
391
|
+
好过在两处各写一套拼接规则。
|
|
392
|
+
"""
|
|
393
|
+
p = pathlib.Path(str(rel))
|
|
394
|
+
if p.is_absolute():
|
|
395
|
+
return p
|
|
396
|
+
if str(rel).startswith('verticals/'):
|
|
397
|
+
return HERE.parent / p
|
|
398
|
+
vid = str(flow_id).split('.', 1)[0] if '.' in str(flow_id) else ''
|
|
399
|
+
if vid:
|
|
400
|
+
return HERE.parent / 'verticals' / vid / p
|
|
401
|
+
return HERE.parent / p
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def resolve_schedule(flow_id: str, spec: dict, strict: bool = True) -> dict:
|
|
405
|
+
"""解析并校验 `trigger.schedule`(未声明 ⇒ 返回 `{}`,不产生任何副作用)。
|
|
406
|
+
|
|
407
|
+
`strict=True`(运行路径)⇒ 声明非法**响亮失败**(exit 2);`strict=False`(`--list` 巡查)
|
|
408
|
+
⇒ 不中断列表,把问题放进返回值 `_errors`(并保证**不静默**:调用方会打印)。
|
|
409
|
+
"""
|
|
410
|
+
raw_t = spec.get('trigger') or {}
|
|
411
|
+
raw = raw_t.get('schedule')
|
|
412
|
+
if raw is None:
|
|
413
|
+
return {}
|
|
414
|
+
errs: list = [] # 致命:违反宿主硬约束或自相矛盾(会 die)
|
|
415
|
+
warns: list = [] # 响亮告警:**不许**因为一段尚未启用的排程声明把在跑的触发器打死
|
|
416
|
+
if not isinstance(raw, dict):
|
|
417
|
+
errs.append('trigger.schedule 必须是对象')
|
|
418
|
+
raw = {}
|
|
419
|
+
for k in raw:
|
|
420
|
+
if str(k).startswith('_') or k in SCHEDULE_KEYS:
|
|
421
|
+
continue
|
|
422
|
+
errs.append(f'trigger.schedule 未登记键 `{k}`(白名单:{" / ".join(SCHEDULE_KEYS)})')
|
|
423
|
+
kind = str(raw.get('kind') or '')
|
|
424
|
+
if kind not in SCHEDULE_KINDS:
|
|
425
|
+
errs.append(f'trigger.schedule.kind={raw.get("kind")!r} 不在 {"|".join(SCHEDULE_KINDS)} 内')
|
|
426
|
+
enabled = raw.get('enabled')
|
|
427
|
+
if not isinstance(enabled, bool):
|
|
428
|
+
errs.append(f'trigger.schedule.enabled 必须是布尔(当前 {enabled!r})')
|
|
429
|
+
if kind == 'every':
|
|
430
|
+
if str(raw.get('everySecondsFrom') or '') != 'intervalSec':
|
|
431
|
+
errs.append('trigger.schedule: kind=every 必须写 "everySecondsFrom": "intervalSec"(周期只有一份真源)')
|
|
432
|
+
iv = raw_t.get('intervalSec')
|
|
433
|
+
try:
|
|
434
|
+
ivn = int(iv)
|
|
435
|
+
except (TypeError, ValueError):
|
|
436
|
+
ivn = None
|
|
437
|
+
if ivn is None:
|
|
438
|
+
errs.append('trigger.schedule: kind=every 但 trigger.intervalSec 缺失 ⇒ 没有周期可用')
|
|
439
|
+
elif ivn < 60:
|
|
440
|
+
errs.append(f'trigger.schedule: trigger.intervalSec={ivn} < 60(宿主 every_seconds 硬下限)')
|
|
441
|
+
tz = raw.get('timeZone')
|
|
442
|
+
if kind in ('daily', 'weekly', 'cron'):
|
|
443
|
+
if not str(tz or ''):
|
|
444
|
+
errs.append(f'trigger.schedule: kind={kind} 必须显式给 timeZone(IANA)')
|
|
445
|
+
else:
|
|
446
|
+
try:
|
|
447
|
+
import zoneinfo as _zi # 3.9+ 标准库
|
|
448
|
+
_zi.ZoneInfo(str(tz))
|
|
449
|
+
except Exception: # noqa: BLE001 —— 含 tzdata 缺失的机器
|
|
450
|
+
errs.append(f'trigger.schedule.timeZone={tz!r} 不是可用的 IANA 时区')
|
|
451
|
+
if kind in ('daily', 'weekly') and not _SCHEDULE_TIME_RE.match(str(raw.get('time') or '')):
|
|
452
|
+
errs.append(f'trigger.schedule.time={raw.get("time")!r} 不是 `HH:mm:ss`')
|
|
453
|
+
if kind == 'weekly':
|
|
454
|
+
wd = raw.get('weekdays')
|
|
455
|
+
if not isinstance(wd, list) or not wd or any(not isinstance(d, int) or d < 1 or d > 7 for d in wd):
|
|
456
|
+
errs.append('trigger.schedule: kind=weekly 的 weekdays 必须是 1..7 的非空整数数组(ISO 周一=1)')
|
|
457
|
+
if kind == 'cron':
|
|
458
|
+
fields = str(raw.get('expression') or '').split()
|
|
459
|
+
if len(fields) != 5:
|
|
460
|
+
errs.append(f'trigger.schedule: kind=cron 的 expression 必须是**五字段** Vixie(当前 {len(fields)} 段)')
|
|
461
|
+
if _SCHEDULE_BAD_CRON_RE.search(str(raw.get('expression') or '')):
|
|
462
|
+
errs.append('trigger.schedule: cron 表达式用了宿主明确拒绝的方言(L/W/#、月份/星期英文名、@宏)')
|
|
463
|
+
if kind == 'at':
|
|
464
|
+
at_v = str(raw.get('at') or '')
|
|
465
|
+
if not at_v:
|
|
466
|
+
errs.append('trigger.schedule: kind=at 必须给绝对时点 `at`')
|
|
467
|
+
title = str(raw.get('taskTitle') or '')
|
|
468
|
+
if not title.strip():
|
|
469
|
+
errs.append('trigger.schedule 缺 taskTitle(宿主必填,去空白后 1..120 字符)')
|
|
470
|
+
elif title != title.strip():
|
|
471
|
+
errs.append('trigger.schedule.taskTitle 首尾有空白(宿主拒)')
|
|
472
|
+
elif len(title) > 120:
|
|
473
|
+
errs.append(f'trigger.schedule.taskTitle 超长({len(title)} > 120,宿主硬上限)')
|
|
474
|
+
rel = str(raw.get('promptFile') or '')
|
|
475
|
+
pf_path = None
|
|
476
|
+
if not rel:
|
|
477
|
+
errs.append('trigger.schedule 缺 promptFile(静态轮次指令;⛔ 与 trigger.promptTemplate 是两份不同的东西)')
|
|
478
|
+
else:
|
|
479
|
+
pf_path = _resolve_vertical_path(flow_id, rel)
|
|
480
|
+
if not pf_path.exists():
|
|
481
|
+
# ⚠️ **只告警、不 die**(2026-10-12 定):`promptFile` 是"**将来**建宿主任务时才用得上"的
|
|
482
|
+
# 资产,而 `flow_config` 是在跑的触发器**每一轮**都要走的路。若因为一个尚未启用的排程
|
|
483
|
+
# 声明把 `flow-trigger.sh` 打死,那是"用一段没在用的配置停掉在跑的生产路径"。
|
|
484
|
+
# 装配期的**硬判据在 vertical-lint**(声明期就拦住),运行期只要求"绝不静默"。
|
|
485
|
+
warns.append(f'trigger.schedule.promptFile 指向的文件不存在:{pf_path}'
|
|
486
|
+
'(宿主排程尚未启用时可忽略;要启用必须先补齐该文件)')
|
|
487
|
+
else:
|
|
488
|
+
try:
|
|
489
|
+
body = pf_path.read_text(encoding='utf-8')
|
|
490
|
+
if _SCHEDULE_TEMPLATE_RE.search(body):
|
|
491
|
+
errs.append('trigger.schedule.promptFile 含模板占位符(双花括号包住的名字)—— '
|
|
492
|
+
'宿主在**创建任务时**就固定 prompt,占位符不会被替换')
|
|
493
|
+
except Exception as exc: # noqa: BLE001
|
|
494
|
+
warns.append(f'trigger.schedule.promptFile 读不了:{exc}')
|
|
495
|
+
on_pause = str(raw.get('onPause') or '')
|
|
496
|
+
if on_pause not in SCHEDULE_ON_PAUSE:
|
|
497
|
+
errs.append(f'trigger.schedule.onPause={raw.get("onPause")!r} 不在 {"|".join(SCHEDULE_ON_PAUSE)} 内')
|
|
498
|
+
if enabled is True:
|
|
499
|
+
for need in ('kind', 'taskTitle', 'promptFile'):
|
|
500
|
+
if not raw.get(need):
|
|
501
|
+
errs.append(f'trigger.schedule: enabled=true 必须同时给 `{need}`')
|
|
502
|
+
if errs and strict:
|
|
503
|
+
die(f'流程 {flow_id} 的 trigger.schedule 声明非法:\n - ' + '\n - '.join(errs))
|
|
504
|
+
out = {
|
|
505
|
+
'enabled': enabled is True,
|
|
506
|
+
'kind': kind,
|
|
507
|
+
'time': raw.get('time'),
|
|
508
|
+
'timeZone': raw.get('timeZone'),
|
|
509
|
+
'weekdays': raw.get('weekdays'),
|
|
510
|
+
'expression': raw.get('expression'),
|
|
511
|
+
'at': raw.get('at'),
|
|
512
|
+
'everySecondsFrom': raw.get('everySecondsFrom'),
|
|
513
|
+
'taskTitle': title,
|
|
514
|
+
'promptFile': rel,
|
|
515
|
+
'promptPath': str(pf_path) if pf_path else '',
|
|
516
|
+
'onPause': on_pause,
|
|
517
|
+
}
|
|
518
|
+
if warns:
|
|
519
|
+
out['_warnings'] = warns
|
|
520
|
+
print(f'⚠️ 流程 {flow_id} 的 trigger.schedule 声明有告警:' + ';'.join(warns), file=sys.stderr)
|
|
521
|
+
if errs and not strict:
|
|
522
|
+
out['_errors'] = errs
|
|
523
|
+
print(f'⚠️ 流程 {flow_id} 的 trigger.schedule 声明有问题(非严格模式,继续):'
|
|
524
|
+
+ ';'.join(errs), file=sys.stderr)
|
|
525
|
+
return out
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def schedule_declaration(registry_path=None, strict: bool = True) -> dict:
|
|
529
|
+
"""**「定时怎么来」的 Python 侧唯一读取点**(0.6.0 W8)—— 读合并注册表的 `schedule` 段。
|
|
530
|
+
|
|
531
|
+
⭐ 判据**不在这里**:声明面 = 注册表顶层 `schedule`(引擎级 base:`scripts/flow-requirements.json`),
|
|
532
|
+
唯一判据实现 = `scripts/lib/schedule-mechanism.mjs#resolveScheduleMechanism()`
|
|
533
|
+
(本函数只**读**、只**归一化**,⛔ 不重写判据、⛔ 不写任何文件)。
|
|
534
|
+
与 Node 侧的 `scripts/lib/schedule-mechanism.mjs#declaredScheduleMechanism()` 读的是**同一份**
|
|
535
|
+
合并注册表(`flow_registry.py` 是合并的唯一真源)。
|
|
536
|
+
|
|
537
|
+
返回结构(`--schedule-declaration` 的原样输出)::
|
|
538
|
+
|
|
539
|
+
{"source": "...", "mechanism": "auto|native|activation-shim|none",
|
|
540
|
+
"jobs": {"source","labelPrefix","platform","installCommand"}, "flows": [...]}
|
|
541
|
+
|
|
542
|
+
未声明 `schedule` 段 ⇒ 返回**中性默认**(`mechanism="auto"` + 本仓库的垫片口径),
|
|
543
|
+
⛔ 不报错(老树/旧注册表是合法输入),但 `_note` 里写明"声明段缺失"。
|
|
544
|
+
"""
|
|
545
|
+
doc = {}
|
|
546
|
+
if flow_registry is not None:
|
|
547
|
+
try:
|
|
548
|
+
doc = flow_registry.load_doc(registry_path) if hasattr(flow_registry, 'load_doc') \
|
|
549
|
+
else flow_registry.merge()
|
|
550
|
+
except SystemExit:
|
|
551
|
+
raise
|
|
552
|
+
except Exception as exc: # noqa: BLE001
|
|
553
|
+
die(f'读取流程注册表失败(schedule 声明面): {exc}')
|
|
554
|
+
else:
|
|
555
|
+
path = pathlib.Path(registry_path) if registry_path else REGISTRY
|
|
556
|
+
try:
|
|
557
|
+
doc = json.loads(path.read_text(encoding='utf-8'))
|
|
558
|
+
except Exception as exc: # noqa: BLE001
|
|
559
|
+
die(f'读取流程注册表失败(schedule 声明面): {path}: {exc}')
|
|
560
|
+
raw = doc.get('schedule') if isinstance(doc, dict) else None
|
|
561
|
+
if raw is not None and not isinstance(raw, dict):
|
|
562
|
+
die('注册表 `schedule` 段必须是对象')
|
|
563
|
+
raw = raw or {}
|
|
564
|
+
mech = raw.get('mechanism', 'auto')
|
|
565
|
+
allowed = ('auto', 'native', 'activation-shim', 'none')
|
|
566
|
+
if mech not in allowed:
|
|
567
|
+
die(f'注册表 schedule.mechanism={mech!r} 不在 {"|".join(allowed)} 内'
|
|
568
|
+
f'(唯一声明点:scripts/flow-requirements.json#schedule)')
|
|
569
|
+
rj = raw.get('jobs') if isinstance(raw.get('jobs'), dict) else {}
|
|
570
|
+
jobs = {
|
|
571
|
+
'source': str(rj.get('source') or 'scripts/flow-timers.sh'),
|
|
572
|
+
'labelPrefix': str(rj.get('labelPrefix') or 'com.whyshu.flow-trigger'),
|
|
573
|
+
'platform': str(rj.get('platform') or 'darwin'),
|
|
574
|
+
'installCommand': str(rj.get('installCommand')
|
|
575
|
+
or 'bash {repo}/scripts/flow-timers.sh install --flow {flow}'),
|
|
576
|
+
}
|
|
577
|
+
install_tpl = jobs['installCommand'].replace('{repo}', str(HERE.parent))
|
|
578
|
+
# 逐流程 job(**周期/日志/暂停开关** 只从 load_config 取 —— 那是这些字段的唯一读取点)
|
|
579
|
+
reg = load_registry(registry_path)
|
|
580
|
+
aliases = (doc.get('aliases') or {}) if isinstance(doc, dict) else {}
|
|
581
|
+
back = {v: k for k, v in aliases.items()}
|
|
582
|
+
flows = []
|
|
583
|
+
for canonical in sorted(reg):
|
|
584
|
+
try:
|
|
585
|
+
cfg = load_config(canonical, registry_path=registry_path, strict=False)
|
|
586
|
+
except SystemExit:
|
|
587
|
+
raise
|
|
588
|
+
except Exception as exc: # noqa: BLE001
|
|
589
|
+
if strict:
|
|
590
|
+
die(f'流程 {canonical} 的配置读不到(schedule 声明面需要它): {exc}')
|
|
591
|
+
continue
|
|
592
|
+
t = cfg.get('trigger') or {}
|
|
593
|
+
sch = t.get('schedule')
|
|
594
|
+
if not isinstance(sch, dict):
|
|
595
|
+
continue
|
|
596
|
+
local = back.get(canonical, canonical.split('.')[-1])
|
|
597
|
+
label = f"{jobs['labelPrefix']}-{local}"
|
|
598
|
+
flows.append({
|
|
599
|
+
'flowId': canonical,
|
|
600
|
+
'localId': local,
|
|
601
|
+
'label': label,
|
|
602
|
+
'enabled': sch.get('enabled') is True,
|
|
603
|
+
'kind': str(sch.get('kind') or ''),
|
|
604
|
+
'taskTitle': str(sch.get('taskTitle') or ''),
|
|
605
|
+
'promptFile': str(sch.get('promptPath') or sch.get('promptFile') or ''),
|
|
606
|
+
'onPause': str(sch.get('onPause') or ''),
|
|
607
|
+
'intervalSec': t.get('intervalSec'),
|
|
608
|
+
'jitterSec': t.get('jitterSec'),
|
|
609
|
+
'pauseSwitch': str((cfg.get('paths') or {}).get('pauseSwitch') or ''),
|
|
610
|
+
'pauseSwitchLegacy': str((cfg.get('paths') or {}).get('pauseSwitchLegacy') or ''),
|
|
611
|
+
'log': str((cfg.get('paths') or {}).get('log') or ''),
|
|
612
|
+
'installCommand': install_tpl.replace('{flow}', local).replace('{label}', label),
|
|
613
|
+
})
|
|
614
|
+
note = raw.get('_note')
|
|
615
|
+
out = {
|
|
616
|
+
'source': 'registry:schedule',
|
|
617
|
+
'declared': bool(raw),
|
|
618
|
+
'mechanism': mech,
|
|
619
|
+
'onConflict': str(raw.get('onConflict') or 'refuse'),
|
|
620
|
+
'jobs': jobs,
|
|
621
|
+
'flows': flows,
|
|
622
|
+
'judge': 'scripts/lib/schedule-mechanism.mjs#resolveScheduleMechanism(唯一判据实现)',
|
|
623
|
+
}
|
|
624
|
+
if note:
|
|
625
|
+
out['_note'] = note
|
|
626
|
+
else:
|
|
627
|
+
out['_note'] = '注册表未声明 `schedule` 段 ⇒ 按中性默认(auto + 本仓库垫片口径);' \
|
|
628
|
+
'声明面唯一落点是 scripts/flow-requirements.json#schedule'
|
|
629
|
+
return out
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def _resolve_ids(flow_id: str, src: dict, strict: bool = True) -> dict:
|
|
633
|
+
"""表 id 解析优先级:env > ref > 字面量。
|
|
634
|
+
|
|
635
|
+
`strict=True`(运行路径):部署配置缺失 ⇒ 立刻 exit 2(早失败胜过静默用错表)。
|
|
636
|
+
`strict=False`(`--list` 巡查):把未解析的键标成空串并留 `*Unresolved`,绝不中断列表。
|
|
637
|
+
"""
|
|
638
|
+
out = dict(src)
|
|
639
|
+
if out.get('envFile'):
|
|
640
|
+
load_env_file(str(out['envFile']), strict=False) # 部署凭据:先加载再解析表 id
|
|
641
|
+
for key in ('base', 'table'):
|
|
642
|
+
env_key = out.get(f'{key}Env')
|
|
643
|
+
ref = out.get(f'{key}Ref')
|
|
644
|
+
if env_key:
|
|
645
|
+
val = os.environ.get(str(env_key))
|
|
646
|
+
if not val:
|
|
647
|
+
if strict:
|
|
648
|
+
die(f'流程 {flow_id} 需要环境变量 {env_key}({key}),当前未设置')
|
|
649
|
+
out[f'{key}Unresolved'] = str(env_key)
|
|
650
|
+
out[key] = val or ''
|
|
651
|
+
elif ref:
|
|
652
|
+
val = _read_ref(str(ref), strict)
|
|
653
|
+
if not val and not strict:
|
|
654
|
+
out[f'{key}Unresolved'] = str(ref)
|
|
655
|
+
out[key] = val
|
|
656
|
+
out.pop(f'{key}Env', None)
|
|
657
|
+
out.pop(f'{key}Ref', None)
|
|
658
|
+
# identity(用哪个 bot 身份读/写这张表)同样是**部署事实** ⇒ 允许用 env 覆盖声明的缺省值。
|
|
659
|
+
# 2026-09-17 用户口径:FULL 现在以 lip 表为准,注册表要同时在两台机器上跑,各自身份 profile 名
|
|
660
|
+
# 可能不同(客户机用自家 bot),故引入 `identityEnv`;未设置时**沿用声明里的 identity** ——
|
|
661
|
+
# 宁可读表时因权限而响亮失败(91403),也不要在这里提前把流程判死。
|
|
662
|
+
id_env = out.get('identityEnv')
|
|
663
|
+
if id_env:
|
|
664
|
+
val = os.environ.get(str(id_env))
|
|
665
|
+
if val:
|
|
666
|
+
out['identity'] = val
|
|
667
|
+
else:
|
|
668
|
+
out['identityUnresolved'] = str(id_env)
|
|
669
|
+
out.pop('identityEnv', None)
|
|
670
|
+
return out
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def _derive(flow_id: str, spec: dict, caps: dict | None = None, caps_from_doc: bool = False) -> dict:
|
|
674
|
+
role = str(spec.get('role') or flow_id)
|
|
675
|
+
return {
|
|
676
|
+
'pauseSwitch': 'PAUSE_FLOW_' + flow_id.upper().replace('-', '_'),
|
|
677
|
+
'log': f'flow-trigger-{flow_id}.log',
|
|
678
|
+
'promptTemplate': f'flow-prompts/{flow_id}.md',
|
|
679
|
+
'sessionIdFile': f'flow-dispatch-{flow_id}.json',
|
|
680
|
+
'preset': f'argent-{role}',
|
|
681
|
+
'cwd': '~/Code',
|
|
682
|
+
'lock': f'flow-claim-{flow_id}.lock',
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def resolve_flow_id(flow_id: str, registry_path=None) -> str:
|
|
687
|
+
"""别名 → 规范 id(P2a):业务线落位后旧 id(`full`)仍可用,避免全仓库/全机器改名。"""
|
|
688
|
+
if flow_registry is None:
|
|
689
|
+
return flow_id
|
|
690
|
+
try:
|
|
691
|
+
return flow_registry.resolve_flow_id(flow_id, flow_registry.load_doc(registry_path))
|
|
692
|
+
except Exception: # noqa: BLE001 —— 解析器不可用时按原样处理(历史行为)
|
|
693
|
+
return flow_id
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def load_config(flow_id: str, registry=None, registry_path=None, strict: bool = True) -> dict:
|
|
697
|
+
"""合并缺省后的流程配置。registry 可直接传 dict(测试/批量用,避免重复读盘)。
|
|
698
|
+
|
|
699
|
+
`strict=False` 只用于 `--list` 这类**巡查**场景(缺部署环境变量时标 `*Unresolved` 而不中断)。
|
|
700
|
+
"""
|
|
701
|
+
reg = registry if registry is not None else load_registry(registry_path)
|
|
702
|
+
# 能力层与 flows 同级;registry 直传(测试夹具/批量)时拿不到 ⇒ 传 {} 跳过能力检查
|
|
703
|
+
caps = load_capabilities(registry_path) if registry is None else {}
|
|
704
|
+
caps_from_doc = registry is None
|
|
705
|
+
# ── 别名解析(P2a,2026-09-29):业务线落位后规范 id 是 `<vertical>.<flow>`,
|
|
706
|
+
# 而全仓库/全机器的旧调用点仍写短名。别名让两者同时可用,避免一次性大改名
|
|
707
|
+
# (改名要动 10+ 个生产脚本与机器配置,风险远大于收益)。显式传 registry 时不解析。
|
|
708
|
+
canonical = resolve_flow_id(flow_id, registry_path) if registry is None else flow_id
|
|
709
|
+
spec = reg.get(canonical)
|
|
710
|
+
if spec is None:
|
|
711
|
+
hint = f'(`{flow_id}` 解析为 `{canonical}`)' if canonical != flow_id else ''
|
|
712
|
+
die(f'未知流程: {flow_id}{hint}(可用: {", ".join(sorted(reg)) or "无"})')
|
|
713
|
+
if not isinstance(spec, dict):
|
|
714
|
+
die(f'流程 {flow_id} 的配置不是对象')
|
|
715
|
+
|
|
716
|
+
# ── 集成层(2026-09-20 迭代2):声明的货代适配器必须存在、货代写法必须与路由一致 ──
|
|
717
|
+
# 为什么在配置解析期就校验:货代细节散落曾是"接一个新货代要改 9 处"的根因;
|
|
718
|
+
# 声明与实现对不上(如 routing.match 改了、适配器没改)必须**当场响亮失败**。
|
|
719
|
+
integ = str(spec.get('integration') or '')
|
|
720
|
+
if integ:
|
|
721
|
+
_integ_dir = pathlib.Path(__file__).resolve().parent / 'integrations' / integ
|
|
722
|
+
_adapter = _integ_dir / 'adapter.py'
|
|
723
|
+
if not _adapter.exists():
|
|
724
|
+
die(f'流程 {flow_id} 声明了 integration={integ!r},但适配器不存在:{_adapter}')
|
|
725
|
+
try:
|
|
726
|
+
import importlib.util as _ilu
|
|
727
|
+
_sp = _ilu.spec_from_file_location(f'integration_{integ}', _adapter)
|
|
728
|
+
_mod = _ilu.module_from_spec(_sp)
|
|
729
|
+
_sp.loader.exec_module(_mod)
|
|
730
|
+
except Exception as exc: # noqa: BLE001
|
|
731
|
+
die(f'流程 {flow_id} 的集成适配器加载失败({integ}):{exc}')
|
|
732
|
+
_match = str((spec.get('routing') or {}).get('match') or '')
|
|
733
|
+
_carrier = str(getattr(_mod, 'CARRIER', '') or '')
|
|
734
|
+
if _match and _carrier and _match != _carrier:
|
|
735
|
+
die(f'流程 {flow_id} 的 routing.match={_match!r} 与适配器 CARRIER={_carrier!r} 不一致\n'
|
|
736
|
+
f' ⇒ 二者必须同源(适配器是货代事实的唯一出处)')
|
|
737
|
+
# P2a:业务线落位后规范 id 带前缀(`<vertical>.<flow>`),而适配器里写的可能仍是旧名
|
|
738
|
+
# ⇒ 用别名解析后再比(回写守卫按流程 id 解析目标,两种写法都指向同一条流程)。
|
|
739
|
+
_wb = str(getattr(_mod, 'WRITEBACK_FLOW', '') or '')
|
|
740
|
+
if _wb != flow_id and resolve_flow_id(_wb, registry_path) != flow_id:
|
|
741
|
+
die(f'流程 {flow_id} 的适配器 WRITEBACK_FLOW='
|
|
742
|
+
f'{getattr(_mod, "WRITEBACK_FLOW", "")!r} 与流程 id 不一致(回写守卫按流程 id 解析目标)')
|
|
743
|
+
|
|
744
|
+
# ── capability 层(2026-09-20 迭代1-步骤3)────────────────────────────────────
|
|
745
|
+
# 同一 capability 的多个 flow 必须共享共同规则;**差异必须显式写在 flow.overrides 里**。
|
|
746
|
+
# 这里做两件事:① 把 capability 的共同声明合并进本次解析结果(缺省继承);
|
|
747
|
+
# ② 若 flow 自己声明了与 capability **冲突**的值且未在 overrides 里登记 ⇒ 拒绝。
|
|
748
|
+
# 为什么:两条发票流程此前已静默漂移(required 20 vs 8、guards.writeFields 一方缺失、
|
|
749
|
+
# 暂停开关命名不一)——漂移只能靠"看见"来治理,不能靠自觉。
|
|
750
|
+
caps = caps if caps is not None else {}
|
|
751
|
+
# 只有"从注册表文档读到能力层"时才做未知-capability 校验 —— 调用方直接传 flows 字典
|
|
752
|
+
# (测试夹具 / --list 的旧路径)时结构上就没有 capabilities,不该误报。
|
|
753
|
+
_caps_known = bool(caps_from_doc or caps)
|
|
754
|
+
cap_id = str(spec.get('capability') or '')
|
|
755
|
+
ov = spec.get('overrides') or {}
|
|
756
|
+
cap = caps.get(cap_id) or {}
|
|
757
|
+
if cap_id and not cap and _caps_known:
|
|
758
|
+
die(f'流程 {flow_id} 声明了未知 capability={cap_id!r}(可用: '
|
|
759
|
+
f'{", ".join(k for k in caps if not k.startswith("_")) or "无"})')
|
|
760
|
+
if cap_id and not cap and not _caps_known:
|
|
761
|
+
cap = {} # 结构上无法校验 ⇒ 跳过能力检查(不误报)
|
|
762
|
+
if cap:
|
|
763
|
+
# ① gate.equals:flow 未声明则继承;声明了但不同 ⇒ 必须显式 override
|
|
764
|
+
flow_gate = ((spec.get('source') or {}).get('pick') or {}).get('equals') or {}
|
|
765
|
+
cap_gate = (cap.get('gate') or {}).get('equals') or {}
|
|
766
|
+
if cap_gate and not flow_gate and 'gate' not in ov:
|
|
767
|
+
spec.setdefault('source', {}).setdefault('pick', {})['equals'] = dict(cap_gate)
|
|
768
|
+
elif cap_gate and flow_gate and flow_gate != cap_gate and 'gate' not in ov:
|
|
769
|
+
die(f'流程 {flow_id} 的 pick.equals 与 capability「{cap_id}」冲突且未在 overrides 声明\n'
|
|
770
|
+
f' flow = {flow_gate}\n capability= {cap_gate}\n'
|
|
771
|
+
f' ⇒ 要么改成一致,要么在 flow.overrides 里加 "gate" 说明差异')
|
|
772
|
+
# ①b 排除规则(excludeEquals / excludeAllPresent):与 gate.equals 同一套纪律 ——
|
|
773
|
+
# 能力层声明"哪些行已经做完了、不该进候选",流程未声明则继承;声明了但不同 ⇒ 必须
|
|
774
|
+
# 显式写 overrides。为什么放能力层:两个发票流程读的是同一张 lip 表,判据必须一致。
|
|
775
|
+
cap_ex = cap.get('gate') or {}
|
|
776
|
+
flow_pick = spec.setdefault('source', {}).setdefault('pick', {})
|
|
777
|
+
for ek in ('excludeEquals', 'excludeAllPresent'):
|
|
778
|
+
cap_val = cap_ex.get(ek)
|
|
779
|
+
if not cap_val:
|
|
780
|
+
continue
|
|
781
|
+
flow_val = flow_pick.get(ek)
|
|
782
|
+
if flow_val is None and ek not in ov:
|
|
783
|
+
flow_pick[ek] = cap_val
|
|
784
|
+
elif flow_val is not None and flow_val != cap_val and ek not in ov:
|
|
785
|
+
die(f'流程 {flow_id} 的 pick.{ek} 与 capability「{cap_id}」冲突且未在 overrides 声明\n'
|
|
786
|
+
f' flow = {flow_val}\n capability= {cap_val}\n'
|
|
787
|
+
f' ⇒ 排除判据是能力的共同属性(两个发票流程读同一张表),不要各写一套')
|
|
788
|
+
# ② guards.writeFields:同上
|
|
789
|
+
cap_wf = list(cap.get('writeFields') or [])
|
|
790
|
+
flow_wf = list((spec.get('guards') or {}).get('writeFields') or [])
|
|
791
|
+
if cap_wf:
|
|
792
|
+
if not flow_wf and 'writeFields' not in ov:
|
|
793
|
+
# 继承:写回 spec(`raw_g` 在下方才取,届时能读到)
|
|
794
|
+
spec.setdefault('guards', {})['writeFields'] = list(cap_wf)
|
|
795
|
+
elif flow_wf and flow_wf != cap_wf and 'writeFields' not in ov:
|
|
796
|
+
die(f'流程 {flow_id} 的 guards.writeFields 与 capability「{cap_id}」冲突且未声明 overrides\n'
|
|
797
|
+
f' flow = {flow_wf}\n capability= {cap_wf}')
|
|
798
|
+
# ②b guards.backup/audit:能力层声明 ⇒ flow 继承;声明冲突且未 overrides ⇒ 拒绝
|
|
799
|
+
cap_g = cap.get('guards') or {}
|
|
800
|
+
flow_g = spec.get('guards') or {}
|
|
801
|
+
for gk in ('backup', 'audit'):
|
|
802
|
+
if gk not in cap_g:
|
|
803
|
+
continue
|
|
804
|
+
if gk not in flow_g:
|
|
805
|
+
spec.setdefault('guards', {})[gk] = bool(cap_g[gk])
|
|
806
|
+
elif bool(flow_g[gk]) != bool(cap_g[gk]) and f'guards.{gk}' not in ov:
|
|
807
|
+
die(f'流程 {flow_id} 的 guards.{gk}={bool(flow_g[gk])} 与 capability「{cap_id}」'
|
|
808
|
+
f'声明的 {bool(cap_g[gk])} 冲突且未在 overrides 声明\n'
|
|
809
|
+
f' ⇒ 守卫姿态是能力的共同属性(写手只有一份),不要每个流程各写一套')
|
|
810
|
+
# ③ requiredCommon:flow 必须至少覆盖共同必需字段
|
|
811
|
+
# ⚠️ 只比 **input** 字段:state(状态位)/output(产物)/derived(生成器提供)由能力层
|
|
812
|
+
# 的 stateFields/writeFields/producedFields 声明,不属于"运营要填什么"的差异。
|
|
813
|
+
common = list(cap.get('requiredCommon') or [])
|
|
814
|
+
input_names = {str(r.get('name')) for r in (spec.get('required') or [])
|
|
815
|
+
if str(r.get('role') or '') == 'input'}
|
|
816
|
+
miss = [c for c in common if c not in input_names]
|
|
817
|
+
if miss:
|
|
818
|
+
die(f'流程 {flow_id} 缺少 capability「{cap_id}」的共同必需字段: {miss}')
|
|
819
|
+
# ④ 超出共同部分的 input 字段必须显式登记(否则就是漂移)
|
|
820
|
+
extra = sorted(input_names - set(common))
|
|
821
|
+
declared = sorted(str(x) for x in (ov.get('requiredExtra') or []))
|
|
822
|
+
undeclared = [x for x in extra if x not in declared]
|
|
823
|
+
if undeclared:
|
|
824
|
+
die(f'流程 {flow_id} 有未登记的能力差异字段 {undeclared} ⇒ 请在 flow.overrides.requiredExtra 里声明'
|
|
825
|
+
f'(capability「{cap_id}」的共同字段: {common})')
|
|
826
|
+
|
|
827
|
+
# 2026-09-20:required 的机器判据是显式 role(不再用 owner 的中文子串匹配)。
|
|
828
|
+
# 缺 role / 非法值 ⇒ **响亮拒绝**(静默回退会重现"发票状态被排除出投影"那类隐性行为改变)。
|
|
829
|
+
REQUIRED_ROLES = ('input', 'state', 'output', 'derived')
|
|
830
|
+
for _r in (spec.get('required') or []):
|
|
831
|
+
_n = str((_r or {}).get('name') or '?')
|
|
832
|
+
_role = (_r or {}).get('role')
|
|
833
|
+
if _role not in REQUIRED_ROLES:
|
|
834
|
+
die(f'流程 {flow_id} 的 required「{_n}」缺少合法 role(当前={_role!r};'
|
|
835
|
+
f'可选: {" / ".join(REQUIRED_ROLES)})—— 见注册表 _requiredRoles')
|
|
836
|
+
d = _derive(canonical, spec, caps, caps_from_doc)
|
|
837
|
+
# 顶层直通段(引擎级开关):注册表里写了就原样带出,供 flow_config --get 与各脚本读取。
|
|
838
|
+
# 2026-09-23:envProbe / staleReclaim 加进来时漏了这一步 ⇒ CLI 报"未知配置键"、
|
|
839
|
+
# 探针与停滞回收都被当成"未声明"(静默不生效)。新增顶层段时**必须**登记在此。
|
|
840
|
+
for _k in ('envProbe', 'staleReclaim'):
|
|
841
|
+
if _k in spec:
|
|
842
|
+
d[_k] = spec[_k]
|
|
843
|
+
|
|
844
|
+
raw_t = spec.get('trigger') or {}
|
|
845
|
+
raw_d = raw_t.get('dispatch') or {}
|
|
846
|
+
raw_c = spec.get('concurrency') or {}
|
|
847
|
+
raw_g = spec.get('guards') or {}
|
|
848
|
+
|
|
849
|
+
wake = str(raw_t.get('wake') or DEFAULTS['trigger']['wake'])
|
|
850
|
+
if wake not in WAKE_MODES:
|
|
851
|
+
die(f'流程 {flow_id} 的 trigger.wake 非法: {wake}(可选: {", ".join(WAKE_MODES)})')
|
|
852
|
+
|
|
853
|
+
# 2026-09-18:本轮**是否真实写入**必须来自注册表 —— 此前 flow-trigger.sh / flow-trigger-wake.mjs
|
|
854
|
+
# 把「影子模式」写死在日志里,FULL 已转真实运行后日志仍在说"影子模式"(误导操作者)。
|
|
855
|
+
# 保守默认 shadow:注册表没写 mode 的流程,日志一律按影子播报,绝不宣称真实写入。
|
|
856
|
+
mode = str(raw_t.get('mode') or DEFAULTS['trigger']['mode'])
|
|
857
|
+
if mode not in WRITE_MODES:
|
|
858
|
+
die(f'流程 {flow_id} 的 trigger.mode 非法: {mode}(可选: {", ".join(WRITE_MODES)})')
|
|
859
|
+
|
|
860
|
+
trigger = {
|
|
861
|
+
'intervalSec': int(raw_t.get('intervalSec') or DEFAULTS['trigger']['intervalSec']),
|
|
862
|
+
'jitterSec': int(raw_t.get('jitterSec') if raw_t.get('jitterSec') is not None
|
|
863
|
+
else DEFAULTS['trigger']['jitterSec']),
|
|
864
|
+
'wake': wake,
|
|
865
|
+
'mode': mode,
|
|
866
|
+
'pauseSwitch': str(raw_t.get('pauseSwitch') or d['pauseSwitch']),
|
|
867
|
+
'log': str(raw_t.get('log') or d['log']),
|
|
868
|
+
'promptTemplate': str(raw_t.get('promptTemplate') or d['promptTemplate']),
|
|
869
|
+
}
|
|
870
|
+
# 迁移期兜底开关(P2a):白名单不放行的话,注册表里声明了也会被静默丢弃
|
|
871
|
+
# (首版就踩了:paths 里读不到 pauseSwitchLegacy ⇒ 兜底形同虚设)
|
|
872
|
+
if raw_t.get('pauseSwitchLegacy'):
|
|
873
|
+
trigger['pauseSwitchLegacy'] = str(raw_t['pauseSwitchLegacy'])
|
|
874
|
+
# P1-A(2026-10-12):宿主排程声明(只声明了才出现这个键 —— 未迁移的流程 cfg 形状**逐字不变**)
|
|
875
|
+
schedule = resolve_schedule(canonical, spec, strict)
|
|
876
|
+
if schedule:
|
|
877
|
+
trigger['schedule'] = schedule
|
|
878
|
+
dispatch = {
|
|
879
|
+
'preset': str(raw_d.get('preset') or d['preset']),
|
|
880
|
+
'cwd': str(raw_d.get('cwd') or d['cwd']),
|
|
881
|
+
'sessionIdFile': str(raw_d.get('sessionIdFile') or d['sessionIdFile']),
|
|
882
|
+
}
|
|
883
|
+
concurrency = {
|
|
884
|
+
'max': int(raw_c.get('max') if raw_c.get('max') is not None else DEFAULTS['concurrency']['max']),
|
|
885
|
+
'leaseMin': int(raw_c.get('leaseMin') if raw_c.get('leaseMin') is not None else DEFAULTS['concurrency']['leaseMin']),
|
|
886
|
+
# P5:同店并发上限(0=不限)。full 设为 1(一个店铺=一个浏览器实例+下载目录)
|
|
887
|
+
'perStore': int((spec.get('concurrency') or {}).get('perStore') or 0),
|
|
888
|
+
# 2026-09-18(用户口径 B「一店一批」):同店单轮最多带回几张;>1 时由**一个**
|
|
889
|
+
# subagent 跑 `orchestrator.py --records a,b`(KB-085 单 CDP 多标签页、顺序执行),
|
|
890
|
+
# 且"该店已有活租约 ⇒ 整店跳过"(绝不追加到正在跑的批)。缺省 1 = 旧的同店串行。
|
|
891
|
+
'batchPerStore': int((spec.get('concurrency') or {}).get('batchPerStore') or 1),
|
|
892
|
+
}
|
|
893
|
+
if concurrency['max'] < 0 or concurrency['leaseMin'] <= 0:
|
|
894
|
+
die(f'流程 {flow_id} 的 concurrency 非法: {concurrency}')
|
|
895
|
+
raw_f = spec.get('failures') or {}
|
|
896
|
+
failures = {
|
|
897
|
+
'enabled': bool(raw_f.get('enabled', DEFAULTS['failures']['enabled'])),
|
|
898
|
+
'scanSec': int(raw_f.get('scanSec') or DEFAULTS['failures']['scanSec']),
|
|
899
|
+
'maxPerCase': int(raw_f.get('maxPerCase') or DEFAULTS['failures']['maxPerCase']),
|
|
900
|
+
'cooldownMin': int(raw_f.get('cooldownMin') or DEFAULTS['failures']['cooldownMin']),
|
|
901
|
+
'maxPerRound': int(raw_f.get('maxPerRound') or DEFAULTS['failures']['maxPerRound']),
|
|
902
|
+
}
|
|
903
|
+
guards = {
|
|
904
|
+
'dryRunDefault': bool(raw_g.get('dryRunDefault', DEFAULTS['guards']['dryRunDefault'])),
|
|
905
|
+
'backup': bool(raw_g.get('backup', DEFAULTS['guards']['backup'])),
|
|
906
|
+
'audit': bool(raw_g.get('audit', DEFAULTS['guards']['audit'])),
|
|
907
|
+
'writeFields': list(raw_g.get('writeFields') or []),
|
|
908
|
+
}
|
|
909
|
+
# 2026-10-09(缺陷 B):**能力专属的守卫声明也透出**(标量键,如 `writer`=写手路径、
|
|
910
|
+
# `errorField`=失败原因列)。为什么必须透出:不透出 ⇒ 声明了也读不到 ⇒ 调用方只能各自把
|
|
911
|
+
# 路径/列名写死(那正是"判据第二份"与业务词泄漏进引擎的来源)。只透出标量、跳过 `_` 注释键,
|
|
912
|
+
# 且**不覆盖**上面四个归一化键(它们有缺省与冲突校验,语义更强)。
|
|
913
|
+
for _gk, _gv in (raw_g or {}).items():
|
|
914
|
+
if _gk.startswith('_') or _gk in guards:
|
|
915
|
+
continue
|
|
916
|
+
if isinstance(_gv, (str, bool, int, float)):
|
|
917
|
+
guards[_gk] = _gv
|
|
918
|
+
resolved_source, variant, variants = resolve_source(flow_id, spec, strict)
|
|
919
|
+
return {
|
|
920
|
+
'flowId': canonical,
|
|
921
|
+
# 调用方传进来的旧 id(别名);与 flowId 相同表示本来就是规范 id
|
|
922
|
+
'flowIdRequested': flow_id,
|
|
923
|
+
'capability': cap_id,
|
|
924
|
+
# 引擎级顶层段(2026-09-23):注册表写了就带出。**新增顶层段必须登记在此 + _derive 直通**,
|
|
925
|
+
# 否则 flow_config --get 报"未知配置键",而 env_probe/flow_stale 会把它当"未声明"静默不生效。
|
|
926
|
+
'envProbe': spec.get('envProbe') or {},
|
|
927
|
+
'staleReclaim': spec.get('staleReclaim') or {},
|
|
928
|
+
# 失败自动重试(方案 a,2026-10-13):四个量在这里**归一化后**透出(按流程声明覆盖 → 缺省),
|
|
929
|
+
# `flow_config --get autoRetry.<键>` 与调用方读的都是这一份。
|
|
930
|
+
'autoRetry': resolve_auto_retry(spec),
|
|
931
|
+
'integration': integ,
|
|
932
|
+
'title': str(spec.get('title') or flow_id),
|
|
933
|
+
'role': str(spec.get('role') or flow_id),
|
|
934
|
+
'source': resolved_source,
|
|
935
|
+
'sourceVariant': variant,
|
|
936
|
+
'sourceVariants': sorted(variants.keys()),
|
|
937
|
+
'routing': spec.get('routing') or {},
|
|
938
|
+
# 字段要求也可按变体覆盖(2026-09-17:FULL 的 legacy 表与 lip 表列名不同 ——
|
|
939
|
+
# legacy 用「店铺名称/品名」,lip 用「店铺/产品名称」⇒ 写死一套必然在另一套上响亮失败)。
|
|
940
|
+
'required': (variants.get(variant) or {}).get('required') or spec.get('required') or [],
|
|
941
|
+
'requiredSource': f'variant:{variant}' if (variants.get(variant) or {}).get('required') else 'flow',
|
|
942
|
+
'trigger': trigger,
|
|
943
|
+
'dispatch': dispatch,
|
|
944
|
+
'concurrency': concurrency,
|
|
945
|
+
'guards': guards,
|
|
946
|
+
'failures': failures,
|
|
947
|
+
'paths': {
|
|
948
|
+
'log': str(DATA_DIR / trigger['log']),
|
|
949
|
+
'pauseSwitch': str(DATA_DIR / trigger['pauseSwitch']),
|
|
950
|
+
# 迁移期兜底:业务线落位后开关搬到 `verticals/<id>/PAUSE.<flow>`,
|
|
951
|
+
# 但机器上的旧开关还在 ⇒ 两条都查(flow-trigger.sh),退休见 base._aliasNote
|
|
952
|
+
**({'pauseSwitchLegacy': str(DATA_DIR / trigger['pauseSwitchLegacy'])}
|
|
953
|
+
if trigger.get('pauseSwitchLegacy') else {}),
|
|
954
|
+
'sessionIdFile': str(DATA_DIR / dispatch['sessionIdFile']),
|
|
955
|
+
'promptTemplate': str(_resolve_prompt_path(trigger['promptTemplate'])),
|
|
956
|
+
# P1-A:静态轮次指令(宿主排程用)—— 只有声明了 schedule 的流程才有这个键
|
|
957
|
+
**({'schedulePromptFile': str(schedule.get('promptPath') or '')} if schedule else {}),
|
|
958
|
+
'lock': str(DATA_DIR / d['lock']),
|
|
959
|
+
'ledger': str(DATA_DIR / 'flow-ledger.jsonl'),
|
|
960
|
+
'watchLog': str(DATA_DIR / 'flow-watch.log'),
|
|
961
|
+
'pauseWatch': str(DATA_DIR / 'PAUSE_FLOW_WATCH'),
|
|
962
|
+
},
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
|
|
966
|
+
def main(argv=None) -> int:
|
|
967
|
+
ap = argparse.ArgumentParser(description='每流程运行时配置(唯一读取点)')
|
|
968
|
+
ap.add_argument('--flow', help='流程 id,如 wenyao-invoice')
|
|
969
|
+
ap.add_argument('--registry', help='注册表路径(默认 scripts/flow-requirements.json)')
|
|
970
|
+
ap.add_argument('--list', action='store_true', help='列出已注册流程及关键参数')
|
|
971
|
+
ap.add_argument('--paths', action='store_true', help='只输出解析后的路径')
|
|
972
|
+
ap.add_argument('--get', help='取单个键(点号路径,如 trigger.pauseSwitch / concurrency.max)')
|
|
973
|
+
ap.add_argument('--json', action='store_true', help='JSON 输出(--get 时忽略)')
|
|
974
|
+
ap.add_argument('--lax', action='store_true', help='非严格:缺部署环境变量时标 *Unresolved 而不报错(控制台/巡查用)')
|
|
975
|
+
ap.add_argument('--schedule-declaration', action='store_true',
|
|
976
|
+
help='打印「定时怎么来」的唯一声明(注册表 schedule 段 + 逐流程 job;只读)')
|
|
977
|
+
a = ap.parse_args(argv)
|
|
978
|
+
|
|
979
|
+
if a.list:
|
|
980
|
+
reg = load_registry(a.registry)
|
|
981
|
+
if not reg:
|
|
982
|
+
print(' (注册表为空)')
|
|
983
|
+
return 0
|
|
984
|
+
for fid in sorted(reg):
|
|
985
|
+
cfg = load_config(fid, registry=None, registry_path=a.registry, strict=False)
|
|
986
|
+
vs = f" 源变体={'/'.join(cfg['sourceVariants'])}→{cfg['sourceVariant']}" if cfg['sourceVariant'] else ''
|
|
987
|
+
un = [f"{k}={v}" for k, v in cfg['source'].items() if k.endswith('Unresolved')]
|
|
988
|
+
if un:
|
|
989
|
+
vs += '(部署配置未设置: ' + '、'.join(un) + ')'
|
|
990
|
+
print(f" {fid:22s} role={cfg['role']:18s} preset={cfg['dispatch']['preset']:26s}"
|
|
991
|
+
f" max={cfg['concurrency']['max']} 租约={cfg['concurrency']['leaseMin']}m"
|
|
992
|
+
f" wake={cfg['trigger']['wake']} pause={cfg['trigger']['pauseSwitch']}{vs}")
|
|
993
|
+
return 0
|
|
994
|
+
|
|
995
|
+
if a.schedule_declaration:
|
|
996
|
+
print(json.dumps(schedule_declaration(a.registry), ensure_ascii=False, indent=2))
|
|
997
|
+
return 0
|
|
998
|
+
|
|
999
|
+
if not a.flow:
|
|
1000
|
+
print('需要 --flow(或用 --list)', file=sys.stderr)
|
|
1001
|
+
return 2
|
|
1002
|
+
cfg = load_config(a.flow, registry_path=a.registry, strict=not a.lax)
|
|
1003
|
+
|
|
1004
|
+
if a.get:
|
|
1005
|
+
cur = cfg
|
|
1006
|
+
for part in a.get.split('.'):
|
|
1007
|
+
if not isinstance(cur, dict) or part not in cur:
|
|
1008
|
+
print(f'未知配置键: {a.get}(流程 {a.flow})', file=sys.stderr)
|
|
1009
|
+
return 2
|
|
1010
|
+
cur = cur[part]
|
|
1011
|
+
if isinstance(cur, (dict, list)):
|
|
1012
|
+
print(json.dumps(cur, ensure_ascii=False))
|
|
1013
|
+
elif isinstance(cur, bool):
|
|
1014
|
+
print('true' if cur else 'false')
|
|
1015
|
+
else:
|
|
1016
|
+
print(cur)
|
|
1017
|
+
return 0
|
|
1018
|
+
|
|
1019
|
+
print(json.dumps(cfg['paths'] if a.paths else cfg, ensure_ascii=False, indent=2))
|
|
1020
|
+
return 0
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
if __name__ == '__main__':
|
|
1024
|
+
raise SystemExit(main())
|