first commit

This commit is contained in:
2026-09-02 11:44:52 +08:00
commit 0c8fa2653e
309 changed files with 57278 additions and 0 deletions
@@ -0,0 +1,589 @@
#!/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())