Files
EP-Hub-Skill/.agents/skills/dingtalk-misc/scripts/yida_process_update.py
T
2026-09-02 11:44:52 +08:00

590 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Yida process draft save/publish helper.
Flow for creating a new process definition:
1. create draft from the initial/published process id
2. generate process schema with yida_process_flow.build_flow_schema
3. save draft with `dws yida design process update`
4. optionally publish with `dws yida design process publish`
The flow schema is the same shape used by integration automation:
`build_automation_flow` output from references/yida-process-node.md can
be passed directly via --flow-file / --flow-json.
"""
from __future__ import annotations
import argparse
import copy
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
_SCRIPT_DIR = Path(__file__).resolve().parent
if str(_SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(_SCRIPT_DIR))
from yida_process_flow import ( # noqa: E402
FlowSchemaError,
build_flow_schema,
build_process_view_schema,
merge_process_view_schema,
)
MAX_FLOW_FILE_SIZE = 1024 * 1024
MAX_INLINE_JSON = 256 * 1024
def _gather_allowed_roots() -> list[Path]:
roots: list[Path] = []
extra = os.environ.get("OPENYIDA_ALLOWED_ROOTS", "")
if extra:
parts: list[str] = [extra]
for sep in (os.pathsep, ":", ","):
parts = [seg for chunk in parts for seg in chunk.split(sep)]
roots.extend(Path(part).expanduser().resolve() for part in parts if part.strip())
legacy = os.environ.get("OPENCLAW_WORKSPACE")
if legacy:
roots.append(Path(legacy).expanduser().resolve())
roots.append(Path.cwd().resolve())
roots.append(Path(tempfile.gettempdir()).resolve())
roots.append(Path("/tmp").resolve())
roots.append(Path("/private/tmp").resolve())
seen: set[str] = set()
result: list[Path] = []
for root in roots:
key = str(root)
if key not in seen:
seen.add(key)
result.append(root)
return result
def _resolve_safe_path(path_str: str) -> Path:
target = Path(path_str).expanduser()
target = target.resolve() if target.is_absolute() else (Path.cwd() / target).resolve()
roots = _gather_allowed_roots()
for root in roots:
try:
target.relative_to(root)
return target
except ValueError:
continue
listing = "\n - ".join(str(root) for root in roots)
raise ValueError(f"路径超出允许范围:{path_str}\n已尝试的允许根目录:\n - {listing}")
def _load_json(file_path: str | None, inline_json: str | None, label: str) -> Any:
if file_path:
safe = _resolve_safe_path(file_path)
if not safe.exists():
raise ValueError(f"{label} 文件不存在: {safe}")
if safe.stat().st_size > MAX_FLOW_FILE_SIZE:
raise ValueError(f"{label} 文件过大 (限制 {MAX_FLOW_FILE_SIZE:,} 字节)")
return json.loads(safe.read_text(encoding="utf-8"))
if inline_json:
if len(inline_json.encode("utf-8")) > MAX_INLINE_JSON:
raise ValueError(f"{label} 内联 JSON 过长 (限制 {MAX_INLINE_JSON:,} 字节)")
return json.loads(inline_json)
raise ValueError(f"必须提供 --{label}-file 或 --{label}-json")
def _run_dws(args: list[str], dry_run: bool = False) -> Any | None:
cmd = ["dws"] + args
if dry_run:
print(f" [dry-run] {' '.join(cmd)}")
return {"dry_run": True}
try:
result = subprocess.run(cmd, capture_output=True, text=True, timeout=120)
except FileNotFoundError:
print(" [FAIL] 找不到 'dws' 命令", file=sys.stderr)
return None
except subprocess.TimeoutExpired:
print(" [FAIL] dws 超时", file=sys.stderr)
return None
if result.returncode != 0:
err = result.stderr.strip() or result.stdout.strip()
print(f" [FAIL] dws 失败 (exit {result.returncode}): {err}", file=sys.stderr)
return None
try:
return json.loads(result.stdout)
except json.JSONDecodeError as exc:
print(f" [FAIL] 非 JSON: {exc}\n 输出: {result.stdout[:300]}", file=sys.stderr)
return None
def _iter_dicts(value: Any):
if isinstance(value, dict):
yield value
for child in value.values():
yield from _iter_dicts(child)
elif isinstance(value, list):
for child in value:
yield from _iter_dicts(child)
def _iter_flow_nodes(nodes: list[dict[str, Any]]):
for node in nodes:
yield node
child_nodes = node.get("childNodes") or []
if isinstance(child_nodes, list):
yield from _iter_flow_nodes(child_nodes)
def _extract_payload(data: Any) -> Any:
if isinstance(data, dict):
for key in ("result", "content", "data"):
value = data.get(key)
if value is not None:
return value
return data
def _loads_maybe(value: Any) -> Any:
if isinstance(value, str):
try:
return json.loads(value)
except json.JSONDecodeError:
return value
return value
def _i18n_text(value: Any) -> str:
value = _loads_maybe(value)
if isinstance(value, dict):
return str(value.get("zh_CN") or value.get("en_US") or value.get("pureEn_US") or value.get("value") or "")
return str(value or "")
def _extract_records(data: Any) -> list[Any]:
payload = _extract_payload(data)
payload = _loads_maybe(payload)
if isinstance(payload, dict):
for key in ("data", "result", "records", "list"):
value = payload.get(key)
if isinstance(value, list):
return value
if isinstance(payload, list):
return payload
return []
def _load_form_titles(app_type: str, dry_run: bool = False) -> dict[str, str]:
resp = _run_dws([
"yida", "app", "list-forms",
"--app", app_type,
"--format", "json",
], dry_run=dry_run)
titles: dict[str, str] = {}
if not resp or dry_run:
return titles
for item in _extract_records(resp):
item = _loads_maybe(item)
if not isinstance(item, dict):
continue
form_uuid = item.get("formUuid")
if isinstance(form_uuid, str) and form_uuid:
titles[form_uuid] = _i18n_text(item.get("title")) or form_uuid
return titles
def _field_from_component(item: Any) -> dict[str, Any] | None:
item = _loads_maybe(item)
if not isinstance(item, dict):
return None
field_id = item.get("key") or item.get("fieldId") or item.get("componentId") or item.get("id")
component_name = item.get("componentName")
if not isinstance(field_id, str) or not field_id:
return None
if component_name in {"Page", "FormContainer"}:
return None
label = _i18n_text(item.get("label") or item.get("varName")) or field_id
return {
"fieldId": field_id,
"name": field_id,
"value": field_id,
"label": label,
"text": label,
"componentName": component_name,
"componentOption": "[]",
"required": False,
"componentProps": {
"defaultDataSource": {},
"relateAppType": "",
"relateOrderEnable": False,
"relateOrderConfig": [],
},
}
def _load_form_schema(app_type: str, form_uuid: str, dry_run: bool = False) -> dict[str, Any] | None:
resp = _run_dws([
"yida", "design", "form", "get-schema",
"--app", app_type,
"--form", form_uuid,
"--format", "json",
], dry_run=dry_run)
if not resp or dry_run:
return None
payload = _extract_payload(resp)
payload = _loads_maybe(payload)
return payload if isinstance(payload, dict) else None
def _iter_schema_components(value: Any, parent: dict[str, Any] | None = None):
value = _loads_maybe(value)
if isinstance(value, list):
for item in value:
yield from _iter_schema_components(item, parent)
return
if not isinstance(value, dict):
return
current_parent = parent
if isinstance(value.get("componentName"), str):
yield value, parent
current_parent = value
for key in ("pages", "componentsTree", "children"):
child = value.get(key)
if isinstance(child, (list, dict)):
yield from _iter_schema_components(child, current_parent)
def _field_from_schema_node(item: Any, parent: dict[str, Any] | None = None) -> dict[str, Any] | None:
item = _loads_maybe(item)
if not isinstance(item, dict):
return None
component_name = item.get("componentName")
props = item.get("props") if isinstance(item.get("props"), dict) else {}
field_id = props.get("fieldId") or item.get("key") or item.get("fieldId") or item.get("componentId") or item.get("id")
if not isinstance(field_id, str) or not field_id:
return None
if component_name in {"Page", "RootHeader", "RootContent", "RootFooter", "FormContainer"}:
return None
label = _i18n_text(props.get("label") or item.get("label") or item.get("varName")) or field_id
field_props = copy.deepcopy(props)
if parent and isinstance(parent, dict):
parent_component = parent.get("componentName")
parent_props = parent.get("props") if isinstance(parent.get("props"), dict) else {}
parent_id = parent_props.get("fieldId") or parent.get("fieldId") or parent.get("id")
if parent_component:
field_props.setdefault("parentComponentName", parent_component)
if parent_id:
field_props.setdefault("parentId", parent_id)
return {
"fieldId": field_id,
"name": field_id,
"value": field_id,
"label": label,
"text": label,
"componentName": component_name,
"componentOption": "[]",
"required": bool(props.get("required", False)),
"props": field_props,
"componentProps": {
"defaultDataSource": copy.deepcopy(props.get("defaultDataSource") or {}),
"relateAppType": props.get("relateAppType", ""),
"relateOrderEnable": bool(props.get("relateOrderEnable", False)),
"relateOrderConfig": copy.deepcopy(props.get("relateOrderConfig") or []),
},
}
def _load_form_fields_from_schema(app_type: str, form_uuid: str, dry_run: bool = False) -> list[dict[str, Any]]:
schema = _load_form_schema(app_type, form_uuid, dry_run=dry_run)
if not schema:
return []
fields: list[dict[str, Any]] = []
seen: set[str] = set()
for item, parent in _iter_schema_components(schema):
field = _field_from_schema_node(item, parent)
if not field:
continue
field_id = str(field.get("fieldId") or "")
if field_id in seen:
continue
seen.add(field_id)
fields.append(field)
return fields
def _load_form_fields(app_type: str, form_uuid: str, dry_run: bool = False) -> list[dict[str, Any]]:
fields = _load_form_fields_from_schema(app_type, form_uuid, dry_run=dry_run)
if fields:
return fields
resp = _run_dws([
"yida", "form", "components",
"--app", app_type,
"--form", form_uuid,
"--format", "json",
], dry_run=dry_run)
if not resp or dry_run:
return []
fields = []
for item in _extract_records(resp):
field = _field_from_component(item)
if field:
fields.append(field)
return fields
def _collect_data_create_targets(flow: dict[str, Any], default_app: str) -> set[tuple[str, str]]:
targets: set[tuple[str, str]] = set()
for node in _iter_flow_nodes(flow.get("nodes") or []):
props = node.get("props") or {}
node_type = str(node.get("type") or "")
if node_type == "dataCreate":
form_uuid = props.get("formUuid")
elif node_type == "dataRetrieve":
form_uuid = props.get("sourceId")
else:
continue
if not isinstance(form_uuid, str) or not form_uuid:
continue
if node_type == "dataRetrieve" and not form_uuid.startswith("FORM-"):
continue
app_type = props.get("appType") if isinstance(props.get("appType"), str) else ""
targets.add((app_type or default_app, form_uuid))
return targets
def _build_data_create_metadata(flow: dict[str, Any], app_type: str, dry_run: bool = False) -> dict[tuple[str, str], dict[str, Any]]:
targets = _collect_data_create_targets(flow, app_type)
if not targets:
return {}
titles_by_app: dict[str, dict[str, str]] = {}
metadata: dict[tuple[str, str], dict[str, Any]] = {}
for target_app, form_uuid in sorted(targets):
if target_app not in titles_by_app:
titles_by_app[target_app] = _load_form_titles(target_app, dry_run=dry_run)
metadata[(target_app, form_uuid)] = {
"title": titles_by_app[target_app].get(form_uuid, form_uuid),
"fields": _load_form_fields(target_app, form_uuid, dry_run=dry_run),
}
return metadata
def _load_source_view(app_type: str, process_code: str, process_id: str, dry_run: bool = False) -> dict[str, Any] | None:
if not app_type or not process_code or not process_id or dry_run:
return None
resp = _run_dws([
"yida", "design", "process", "get",
"--app", app_type,
"--process-code", process_code,
"--process-id", process_id,
"--format", "json",
], dry_run=dry_run)
if not resp:
return None
for item in _iter_dicts(resp):
view_json = item.get("viewJson")
if isinstance(view_json, str) and view_json:
parsed = _loads_maybe(view_json)
if isinstance(parsed, dict):
return parsed
return None
def _extract_process_id(data: Any) -> str:
for item in _iter_dicts(data):
for key in ("processId", "id", "processVersionId", "result", "content"):
value = item.get(key)
if isinstance(value, (str, int)) and str(value):
return str(value)
return ""
def _load_optional_text(file_path: str | None, inline_value: str | None, label: str) -> str:
if file_path:
safe = _resolve_safe_path(file_path)
if not safe.exists():
raise ValueError(f"{label} 文件不存在: {safe}")
if safe.stat().st_size > MAX_FLOW_FILE_SIZE:
raise ValueError(f"{label} 文件过大 (限制 {MAX_FLOW_FILE_SIZE:,} 字节)")
return safe.read_text(encoding="utf-8")
return inline_value or ""
def _summarize_flow(flow: dict[str, Any]) -> list[str]:
nodes = list(_iter_flow_nodes(flow.get("nodes") or []))
counts: dict[str, int] = {}
for node in nodes:
node_type = str(node.get("type") or "unknown")
counts[node_type] = counts.get(node_type, 0) + 1
lines = [
f"节点数: {len(nodes)}",
"节点类型: " + ", ".join(f"{key}={counts[key]}" for key in sorted(counts)),
]
data_targets = []
card_rules = []
for node in nodes:
props = node.get("props") if isinstance(node.get("props"), dict) else {}
node_name = _i18n_text(node.get("name")) or str(node.get("nodeId") or "")
node_type = str(node.get("type") or "")
if node_type == "dataCreate":
data_targets.append(f"{node_name}->{props.get('appType') or '<当前应用>'}/{props.get('formUuid')}")
if node_type == "sendCard":
biz_id = props.get("bizId") if isinstance(props.get("bizId"), dict) else {}
card_rules.append(
f"{node_name}: page={props.get('cardPageCode')}, sendType={props.get('sendType')}, "
f"bizId={biz_id.get('value')}"
)
if data_targets:
lines.append("新增数据目标: " + "; ".join(data_targets))
if card_rules:
lines.append("卡片规则: " + "; ".join(card_rules))
return lines
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
description="宜搭流程定义保存/发布:新建草稿 -> 生成流程 schema -> 保存流程 -> 可选发布",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
ap.add_argument("--app", help="应用编码 appType--schema-only 时可不传")
ap.add_argument("--form", help="流程表单 formUuid;创建草稿时必填")
ap.add_argument("--process-code", required=True, help="流程 code,如 TPROC-XXX")
ap.add_argument("--source-process-id", help="源流程版本 id,用于 create-draft")
ap.add_argument("--draft-process-id", help="已有草稿 processId;传入后跳过 create-draft")
ap.add_argument("--flow-file", help="流程 schema / build_automation_flow 输入 JSON 文件")
ap.add_argument("--flow-json", help="流程 schema / build_automation_flow 输入内联 JSON")
ap.add_argument("--view-file", help="可选 viewJson 文件")
ap.add_argument("--view-json", help="可选内联 viewJson")
ap.add_argument("--publish", action="store_true", help="保存后发布流程")
ap.add_argument("--schema-only", action="store_true", help="只输出生成后的流程 schema,不调用 dws")
ap.add_argument("--yes", action="store_true", help="确认执行保存/发布")
ap.add_argument("--dry-run", action="store_true", help="打印将执行的命令,不调用 dws")
args = ap.parse_args(argv)
try:
flow_spec = _load_json(args.flow_file, args.flow_json, "flow")
flow = build_flow_schema(flow_spec, process_code=args.process_code)
if args.form:
flow.setdefault("props", {})["bindingForm"] = args.form
view_content = _load_optional_text(args.view_file, args.view_json, "view") if (args.view_file or args.view_json) else ""
except (ValueError, json.JSONDecodeError, FlowSchemaError) as exc:
print(f"错误: {exc}", file=sys.stderr)
return 1
flow_json = json.dumps(flow, ensure_ascii=False, separators=(",", ":"))
if args.schema_only:
print(json.dumps(flow, ensure_ascii=False, indent=2))
return 0
if not args.app:
print("错误: 保存/发布流程必须提供 --app", file=sys.stderr)
return 1
if not args.yes and not args.dry_run:
print("错误: 保存/发布流程是高影响操作,必须显式传 --yes", file=sys.stderr)
return 1
if not args.draft_process_id and (not args.form or not args.source_process_id):
print("错误: 未传 --draft-process-id 时,必须提供 --form 和 --source-process-id 创建草稿", file=sys.stderr)
return 1
if not view_content:
print("Step 0/4: 生成流程设计器 viewJson")
print(" [检查] 流程摘要")
for line in _summarize_flow(flow):
print(f" - {line}")
metadata = _build_data_create_metadata(flow, args.app, dry_run=args.dry_run)
source_view_id = args.source_process_id or args.draft_process_id or ""
source_view = _load_source_view(args.app, args.process_code, source_view_id, dry_run=args.dry_run)
if source_view:
view = merge_process_view_schema(source_view, flow, data_create_metadata=metadata)
else:
view = build_process_view_schema(flow, data_create_metadata=metadata)
view_content = json.dumps(view, ensure_ascii=False, separators=(",", ":"))
print(f" [OK] viewJson {len(view_content):,} 字节")
# Step 1: create or reuse draft
draft_process_id = args.draft_process_id or ""
if draft_process_id:
print(f"Step 1/4: 复用草稿 processId={draft_process_id}")
else:
print("Step 1/4: 新建流程草稿")
resp = _run_dws([
"yida", "design", "process", "create-draft",
"--app", args.app,
"--form", args.form,
"--process-id", args.source_process_id,
"--format", "json",
], dry_run=args.dry_run)
if args.dry_run:
draft_process_id = "<draft-process-id>"
else:
if not resp:
return 1
draft_process_id = _extract_process_id(resp)
if not draft_process_id:
print(" [FAIL] create-draft 返回中未找到 processId", file=sys.stderr)
return 1
print(f" [OK] 新草稿 processId={draft_process_id}")
print(f"Step 2/4: 生成流程 schema ({len(flow_json):,} 字节)")
# Step 3: save draft
print("Step 3/4: 保存流程草稿")
update_args = [
"yida", "design", "process", "update",
"--app", args.app,
"--process-code", args.process_code,
"--process-id", draft_process_id,
"--content", flow_json,
"--yes",
"--format", "json",
]
if view_content:
update_args.extend(["--view-content", view_content])
resp = _run_dws(update_args, dry_run=args.dry_run)
if not args.dry_run and not resp:
return 1
# Step 4: optional publish
if args.publish:
print("Step 4/4: 发布流程")
resp = _run_dws([
"yida", "design", "process", "publish",
"--app", args.app,
"--process-code", args.process_code,
"--process-id", draft_process_id,
"--yes",
"--format", "json",
], dry_run=args.dry_run)
if not args.dry_run and not resp:
return 1
else:
print("Step 4/4: 跳过发布(未传 --publish")
print(json.dumps({
"ok": True,
"app": args.app,
"processCode": args.process_code,
"draftProcessId": draft_process_id,
"schemaSize": len(flow_json),
"published": bool(args.publish),
"dryRun": bool(args.dry_run),
}, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())