@josephyan/qingflow-cli 0.2.0-beta.55

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.
Files changed (79) hide show
  1. package/README.md +30 -0
  2. package/docs/local-agent-install.md +235 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +204 -0
  6. package/npm/scripts/postinstall.mjs +16 -0
  7. package/package.json +34 -0
  8. package/pyproject.toml +67 -0
  9. package/qingflow +15 -0
  10. package/src/qingflow_mcp/__init__.py +5 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +547 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +985 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +8243 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +15 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +78 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +184 -0
  21. package/src/qingflow_mcp/cli/commands/common.py +47 -0
  22. package/src/qingflow_mcp/cli/commands/imports.py +86 -0
  23. package/src/qingflow_mcp/cli/commands/record.py +202 -0
  24. package/src/qingflow_mcp/cli/commands/task.py +87 -0
  25. package/src/qingflow_mcp/cli/commands/workspace.py +33 -0
  26. package/src/qingflow_mcp/cli/context.py +48 -0
  27. package/src/qingflow_mcp/cli/formatters.py +269 -0
  28. package/src/qingflow_mcp/cli/json_io.py +50 -0
  29. package/src/qingflow_mcp/cli/main.py +147 -0
  30. package/src/qingflow_mcp/config.py +221 -0
  31. package/src/qingflow_mcp/errors.py +66 -0
  32. package/src/qingflow_mcp/import_store.py +121 -0
  33. package/src/qingflow_mcp/json_types.py +18 -0
  34. package/src/qingflow_mcp/list_type_labels.py +76 -0
  35. package/src/qingflow_mcp/server.py +211 -0
  36. package/src/qingflow_mcp/server_app_builder.py +387 -0
  37. package/src/qingflow_mcp/server_app_user.py +317 -0
  38. package/src/qingflow_mcp/session_store.py +289 -0
  39. package/src/qingflow_mcp/solution/__init__.py +6 -0
  40. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  41. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  42. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  43. package/src/qingflow_mcp/solution/compiler/form_compiler.py +466 -0
  44. package/src/qingflow_mcp/solution/compiler/icon_utils.py +113 -0
  45. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  46. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  47. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  48. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  49. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  50. package/src/qingflow_mcp/solution/design_session.py +222 -0
  51. package/src/qingflow_mcp/solution/design_store.py +100 -0
  52. package/src/qingflow_mcp/solution/executor.py +2339 -0
  53. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  54. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  55. package/src/qingflow_mcp/solution/run_store.py +244 -0
  56. package/src/qingflow_mcp/solution/spec_models.py +853 -0
  57. package/src/qingflow_mcp/tools/__init__.py +1 -0
  58. package/src/qingflow_mcp/tools/ai_builder_tools.py +2063 -0
  59. package/src/qingflow_mcp/tools/app_tools.py +850 -0
  60. package/src/qingflow_mcp/tools/approval_tools.py +833 -0
  61. package/src/qingflow_mcp/tools/auth_tools.py +697 -0
  62. package/src/qingflow_mcp/tools/base.py +81 -0
  63. package/src/qingflow_mcp/tools/code_block_tools.py +679 -0
  64. package/src/qingflow_mcp/tools/directory_tools.py +648 -0
  65. package/src/qingflow_mcp/tools/feedback_tools.py +230 -0
  66. package/src/qingflow_mcp/tools/file_tools.py +385 -0
  67. package/src/qingflow_mcp/tools/import_tools.py +1971 -0
  68. package/src/qingflow_mcp/tools/navigation_tools.py +177 -0
  69. package/src/qingflow_mcp/tools/package_tools.py +240 -0
  70. package/src/qingflow_mcp/tools/portal_tools.py +131 -0
  71. package/src/qingflow_mcp/tools/qingbi_report_tools.py +269 -0
  72. package/src/qingflow_mcp/tools/record_tools.py +12739 -0
  73. package/src/qingflow_mcp/tools/role_tools.py +94 -0
  74. package/src/qingflow_mcp/tools/solution_tools.py +3887 -0
  75. package/src/qingflow_mcp/tools/task_context_tools.py +1423 -0
  76. package/src/qingflow_mcp/tools/task_tools.py +843 -0
  77. package/src/qingflow_mcp/tools/view_tools.py +280 -0
  78. package/src/qingflow_mcp/tools/workflow_tools.py +312 -0
  79. package/src/qingflow_mcp/tools/workspace_tools.py +219 -0
@@ -0,0 +1,833 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ from mcp.server.fastmcp import FastMCP
6
+
7
+ from ..config import DEFAULT_PROFILE
8
+ from ..errors import QingflowApiError, raise_tool_error
9
+ from ..json_types import JSONObject
10
+ from ..list_type_labels import get_record_list_type_label
11
+ from .base import ToolBase
12
+
13
+
14
+ class ApprovalTools(ToolBase):
15
+ def __init__(self, sessions, backend) -> None: # type: ignore[no-untyped-def]
16
+ super().__init__(sessions, backend)
17
+ self._form_id_cache: dict[str, int] = {}
18
+
19
+ def register(self, mcp: FastMCP) -> None:
20
+ @mcp.tool()
21
+ def record_comment_write(
22
+ profile: str = DEFAULT_PROFILE,
23
+ app_key: str = "",
24
+ record_id: int = 0,
25
+ payload: dict[str, Any] | None = None,
26
+ ) -> dict[str, Any]:
27
+ return self.record_comment_write(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
28
+
29
+ @mcp.tool()
30
+ def record_comment_list(
31
+ profile: str = DEFAULT_PROFILE,
32
+ app_key: str = "",
33
+ record_id: int = 0,
34
+ page_size: int = 20,
35
+ list_type: int | None = None,
36
+ page_num: int | None = 1,
37
+ ) -> dict[str, Any]:
38
+ return self.record_comment_list(
39
+ profile=profile,
40
+ app_key=app_key,
41
+ apply_id=record_id,
42
+ page_size=page_size,
43
+ list_type=list_type,
44
+ page_num=page_num,
45
+ )
46
+
47
+ @mcp.tool()
48
+ def record_comment_mentions(
49
+ profile: str = DEFAULT_PROFILE,
50
+ app_key: str = "",
51
+ record_id: int = 0,
52
+ page_size: int = 20,
53
+ page_num: int = 1,
54
+ list_type: int | None = None,
55
+ keyword: str | None = None,
56
+ ) -> dict[str, Any]:
57
+ return self.record_comment_mentions(
58
+ profile=profile,
59
+ app_key=app_key,
60
+ record_id=record_id,
61
+ page_size=page_size,
62
+ page_num=page_num,
63
+ list_type=list_type,
64
+ keyword=keyword,
65
+ )
66
+
67
+ @mcp.tool()
68
+ def record_comment_mark_read(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0) -> dict[str, Any]:
69
+ return self.record_comment_mark_read(profile=profile, app_key=app_key, apply_id=record_id)
70
+
71
+ @mcp.tool(description=self._high_risk_tool_description(operation="approve", target="workflow task"))
72
+ def task_approve(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
73
+ return self.task_approve(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
74
+
75
+ @mcp.tool(description=self._high_risk_tool_description(operation="reject", target="workflow task"))
76
+ def task_reject(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
77
+ return self.task_reject(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
78
+
79
+ @mcp.tool()
80
+ def task_rollback_candidates(
81
+ profile: str = DEFAULT_PROFILE,
82
+ app_key: str = "",
83
+ record_id: int = 0,
84
+ workflow_node_id: int = 0,
85
+ ) -> dict[str, Any]:
86
+ return self.task_rollback_candidates(profile=profile, app_key=app_key, record_id=record_id, workflow_node_id=workflow_node_id)
87
+
88
+ @mcp.tool()
89
+ def task_rollback(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
90
+ return self.task_rollback(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
91
+
92
+ @mcp.tool()
93
+ def task_transfer(profile: str = DEFAULT_PROFILE, app_key: str = "", record_id: int = 0, payload: dict[str, Any] | None = None) -> dict[str, Any]:
94
+ return self.task_transfer(profile=profile, app_key=app_key, record_id=record_id, payload=payload or {})
95
+
96
+ @mcp.tool()
97
+ def task_transfer_candidates(
98
+ profile: str = DEFAULT_PROFILE,
99
+ app_key: str = "",
100
+ record_id: int = 0,
101
+ page_size: int = 20,
102
+ page_num: int = 1,
103
+ workflow_node_id: int = 0,
104
+ keyword: str | None = None,
105
+ ) -> dict[str, Any]:
106
+ return self.task_transfer_candidates(
107
+ profile=profile,
108
+ app_key=app_key,
109
+ record_id=record_id,
110
+ page_size=page_size,
111
+ page_num=page_num,
112
+ workflow_node_id=workflow_node_id,
113
+ keyword=keyword,
114
+ )
115
+
116
+ def record_comment_write(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
117
+ raw = self.record_comment_add(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
118
+ return self._public_action_response(
119
+ raw,
120
+ action="record_comment_write",
121
+ resource={"app_key": app_key, "record_id": record_id},
122
+ selection={},
123
+ )
124
+
125
+ def record_comment_mentions(
126
+ self,
127
+ *,
128
+ profile: str,
129
+ app_key: str,
130
+ record_id: int,
131
+ page_size: int = 20,
132
+ page_num: int = 1,
133
+ list_type: int | None = None,
134
+ keyword: str | None = None,
135
+ ) -> dict[str, Any]:
136
+ raw = self.record_comment_mention_candidates(
137
+ profile=profile,
138
+ app_key=app_key,
139
+ apply_id=record_id,
140
+ page_size=page_size,
141
+ page_num=page_num,
142
+ list_type=list_type,
143
+ keyword=keyword,
144
+ )
145
+ items = _approval_page_items(raw.get("page"))
146
+ return self._public_page_response(
147
+ raw,
148
+ items=items,
149
+ pagination={
150
+ "page": page_num,
151
+ "page_size": page_size,
152
+ "returned_items": len(items),
153
+ "page_amount": _approval_page_amount(raw.get("page")),
154
+ "reported_total": _approval_page_total(raw.get("page")),
155
+ },
156
+ selection={"app_key": app_key, "record_id": record_id, "list_type": list_type, "keyword": keyword},
157
+ )
158
+
159
+ def task_approve(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
160
+ raw = self.record_approve(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
161
+ return self._public_action_response(
162
+ raw,
163
+ action="task_approve",
164
+ resource={"app_key": app_key, "record_id": record_id},
165
+ selection={},
166
+ human_review=True,
167
+ )
168
+
169
+ def task_reject(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
170
+ raw = self.record_reject(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
171
+ return self._public_action_response(
172
+ raw,
173
+ action="task_reject",
174
+ resource={"app_key": app_key, "record_id": record_id},
175
+ selection={},
176
+ human_review=True,
177
+ )
178
+
179
+ def task_rollback_candidates(self, *, profile: str, app_key: str, record_id: int, workflow_node_id: int) -> dict[str, Any]:
180
+ raw = self.record_rollback_candidates(profile=profile, app_key=app_key, apply_id=record_id, audit_node_id=workflow_node_id)
181
+ items = _approval_page_items(raw.get("result"))
182
+ return self._public_page_response(
183
+ raw,
184
+ items=items,
185
+ pagination={"returned_items": len(items)},
186
+ selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id},
187
+ )
188
+
189
+ def task_rollback(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
190
+ raw = self.record_rollback(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
191
+ return self._public_action_response(
192
+ raw,
193
+ action="task_rollback",
194
+ resource={"app_key": app_key, "record_id": record_id},
195
+ selection={},
196
+ human_review=True,
197
+ )
198
+
199
+ def task_transfer(self, *, profile: str, app_key: str, record_id: int, payload: dict[str, Any]) -> dict[str, Any]:
200
+ raw = self.record_transfer(profile=profile, app_key=app_key, apply_id=record_id, payload=payload)
201
+ return self._public_action_response(
202
+ raw,
203
+ action="task_transfer",
204
+ resource={"app_key": app_key, "record_id": record_id},
205
+ selection={},
206
+ human_review=True,
207
+ )
208
+
209
+ def task_transfer_candidates(
210
+ self,
211
+ *,
212
+ profile: str,
213
+ app_key: str,
214
+ record_id: int,
215
+ page_size: int = 20,
216
+ page_num: int = 1,
217
+ workflow_node_id: int = 0,
218
+ keyword: str | None = None,
219
+ ) -> dict[str, Any]:
220
+ raw = self.record_transfer_candidates(
221
+ profile=profile,
222
+ app_key=app_key,
223
+ apply_id=record_id,
224
+ page_size=page_size,
225
+ page_num=page_num,
226
+ audit_node_id=workflow_node_id,
227
+ keyword=keyword,
228
+ )
229
+ items = _approval_page_items(raw.get("page"))
230
+ return self._public_page_response(
231
+ raw,
232
+ items=items,
233
+ pagination={
234
+ "page": page_num,
235
+ "page_size": page_size,
236
+ "returned_items": len(items),
237
+ "page_amount": _approval_page_amount(raw.get("page")),
238
+ "reported_total": _approval_page_total(raw.get("page")),
239
+ },
240
+ selection={"app_key": app_key, "record_id": record_id, "workflow_node_id": workflow_node_id, "keyword": keyword},
241
+ )
242
+
243
+ def record_comment_add(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
244
+ self._require_app_and_apply(app_key, apply_id)
245
+ self._validate_comment_payload(payload)
246
+
247
+ def runner(session_profile, context):
248
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/comment", json_body=payload)
249
+ return {
250
+ "profile": profile,
251
+ "ws_id": session_profile.selected_ws_id,
252
+ "app_key": app_key,
253
+ "apply_id": apply_id,
254
+ "result": result,
255
+ "request_route": self._request_route_payload(context),
256
+ }
257
+
258
+ return self._run(profile, runner)
259
+
260
+ def record_comment_list(self, *, profile: str, app_key: str, apply_id: int, page_size: int = 20, list_type: int | None = None, page_num: int | None = 1) -> dict[str, Any]:
261
+ self._require_app_and_apply(app_key, apply_id)
262
+
263
+ def runner(session_profile, context):
264
+ params: dict[str, Any] = {"pageSize": page_size}
265
+ if list_type is not None:
266
+ params["listType"] = list_type
267
+ if page_num is not None:
268
+ params["pageNum"] = page_num
269
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment", params=params)
270
+ return {
271
+ "profile": profile,
272
+ "ws_id": session_profile.selected_ws_id,
273
+ "app_key": app_key,
274
+ "apply_id": apply_id,
275
+ "list_type": list_type,
276
+ "list_type_label": get_record_list_type_label(list_type),
277
+ "page": result,
278
+ "request_route": self._request_route_payload(context),
279
+ }
280
+
281
+ raw = self._run(profile, runner)
282
+ items = _approval_page_items(raw.get("page"))
283
+ return self._public_page_response(
284
+ raw,
285
+ items=items,
286
+ pagination={
287
+ "page": page_num,
288
+ "page_size": page_size,
289
+ "returned_items": len(items),
290
+ "page_amount": _approval_page_amount(raw.get("page")),
291
+ "reported_total": _approval_page_total(raw.get("page")),
292
+ },
293
+ selection={"app_key": app_key, "record_id": apply_id, "list_type": list_type},
294
+ )
295
+
296
+ def record_comment_mention_candidates(
297
+ self,
298
+ *,
299
+ profile: str,
300
+ app_key: str,
301
+ apply_id: int,
302
+ page_size: int = 20,
303
+ page_num: int = 1,
304
+ list_type: int | None = None,
305
+ keyword: str | None = None,
306
+ ) -> dict[str, Any]:
307
+ self._require_app_and_apply(app_key, apply_id)
308
+
309
+ def runner(session_profile, context):
310
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num}
311
+ if list_type is not None:
312
+ params["listType"] = list_type
313
+ if keyword:
314
+ params["keyword"] = keyword
315
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment/member", params=params)
316
+ return {
317
+ "profile": profile,
318
+ "ws_id": session_profile.selected_ws_id,
319
+ "app_key": app_key,
320
+ "apply_id": apply_id,
321
+ "list_type": list_type,
322
+ "list_type_label": get_record_list_type_label(list_type),
323
+ "page": result,
324
+ "request_route": self._request_route_payload(context),
325
+ }
326
+
327
+ return self._run(profile, runner)
328
+
329
+ def record_comment_mark_read(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
330
+ self._require_app_and_apply(app_key, apply_id)
331
+
332
+ def runner(session_profile, context):
333
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/comment/read")
334
+ return {
335
+ "profile": profile,
336
+ "ws_id": session_profile.selected_ws_id,
337
+ "app_key": app_key,
338
+ "apply_id": apply_id,
339
+ "result": result,
340
+ "request_route": self._request_route_payload(context),
341
+ }
342
+
343
+ raw = self._run(profile, runner)
344
+ return self._public_action_response(
345
+ raw,
346
+ action="record_comment_mark_read",
347
+ resource={"app_key": app_key, "record_id": apply_id},
348
+ selection={},
349
+ )
350
+
351
+ def record_comment_stats(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
352
+ self._require_app_and_apply(app_key, apply_id)
353
+
354
+ def runner(session_profile, context):
355
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/comment/statistic")
356
+ return {
357
+ "profile": profile,
358
+ "ws_id": session_profile.selected_ws_id,
359
+ "app_key": app_key,
360
+ "apply_id": apply_id,
361
+ "result": result,
362
+ "request_route": self._request_route_payload(context),
363
+ }
364
+
365
+ return self._run(profile, runner)
366
+
367
+ def record_approve(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
368
+ self._require_app_and_apply(app_key, apply_id)
369
+ body = self._require_dict(payload)
370
+
371
+ def runner(session_profile, context):
372
+ approval_body = self._normalize_approval_payload(profile, context, app_key, apply_id, body)
373
+ result = self.backend.request("POST", context, "/workflow/engine/approval/approve", json_body=approval_body)
374
+ return {
375
+ "profile": profile,
376
+ "ws_id": session_profile.selected_ws_id,
377
+ "app_key": app_key,
378
+ "apply_id": apply_id,
379
+ "form_id": approval_body["formId"],
380
+ "node_id": approval_body["nodeId"],
381
+ "result": result,
382
+ "request_route": self._request_route_payload(context),
383
+ }
384
+
385
+ return self._run(profile, runner)
386
+
387
+ def record_reject(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
388
+ self._require_app_and_apply(app_key, apply_id)
389
+ body = self._require_dict(payload)
390
+
391
+ def runner(session_profile, context):
392
+ approval_body = self._normalize_approval_payload(profile, context, app_key, apply_id, body)
393
+ result = self.backend.request("POST", context, "/workflow/engine/approval/reject", json_body=approval_body)
394
+ return {
395
+ "profile": profile,
396
+ "ws_id": session_profile.selected_ws_id,
397
+ "app_key": app_key,
398
+ "apply_id": apply_id,
399
+ "form_id": approval_body["formId"],
400
+ "node_id": approval_body["nodeId"],
401
+ "result": result,
402
+ "request_route": self._request_route_payload(context),
403
+ }
404
+
405
+ return self._run(profile, runner)
406
+
407
+ def record_rollback_candidates(self, *, profile: str, app_key: str, apply_id: int, audit_node_id: int) -> dict[str, Any]:
408
+ self._require_app_and_apply(app_key, apply_id)
409
+ if audit_node_id <= 0:
410
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
411
+
412
+ def runner(session_profile, context):
413
+ result = self.backend.request(
414
+ "GET",
415
+ context,
416
+ f"/app/{app_key}/apply/{apply_id}/revertNode",
417
+ params={"auditNodeId": audit_node_id},
418
+ )
419
+ return {
420
+ "profile": profile,
421
+ "ws_id": session_profile.selected_ws_id,
422
+ "app_key": app_key,
423
+ "apply_id": apply_id,
424
+ "result": result,
425
+ "request_route": self._request_route_payload(context),
426
+ }
427
+
428
+ return self._run(profile, runner)
429
+
430
+ def record_rollback(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
431
+ self._require_app_and_apply(app_key, apply_id)
432
+ body = self._require_dict(payload)
433
+ self._validate_audit_payload(body)
434
+
435
+ def runner(session_profile, context):
436
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/rollback", json_body=body)
437
+ return {
438
+ "profile": profile,
439
+ "ws_id": session_profile.selected_ws_id,
440
+ "app_key": app_key,
441
+ "apply_id": apply_id,
442
+ "result": result,
443
+ "request_route": self._request_route_payload(context),
444
+ }
445
+
446
+ return self._run(profile, runner)
447
+
448
+ def record_transfer(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
449
+ self._require_app_and_apply(app_key, apply_id)
450
+ body = self._require_dict(payload)
451
+ self._validate_audit_payload(body, require_uid=True)
452
+
453
+ def runner(session_profile, context):
454
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/transfer", json_body=body)
455
+ return {
456
+ "profile": profile,
457
+ "ws_id": session_profile.selected_ws_id,
458
+ "app_key": app_key,
459
+ "apply_id": apply_id,
460
+ "result": result,
461
+ "request_route": self._request_route_payload(context),
462
+ }
463
+
464
+ return self._run(profile, runner)
465
+
466
+ def record_transfer_candidates(
467
+ self,
468
+ *,
469
+ profile: str,
470
+ app_key: str,
471
+ apply_id: int,
472
+ page_size: int = 20,
473
+ page_num: int = 1,
474
+ audit_node_id: int = 0,
475
+ keyword: str | None = None,
476
+ ) -> dict[str, Any]:
477
+ self._require_app_and_apply(app_key, apply_id)
478
+ if audit_node_id <= 0:
479
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
480
+
481
+ def runner(session_profile, context):
482
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num, "auditNodeId": audit_node_id}
483
+ if keyword:
484
+ params["keyword"] = keyword
485
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/transfer/member", params=params)
486
+ return {
487
+ "profile": profile,
488
+ "ws_id": session_profile.selected_ws_id,
489
+ "app_key": app_key,
490
+ "apply_id": apply_id,
491
+ "page": result,
492
+ "request_route": self._request_route_payload(context),
493
+ }
494
+
495
+ return self._run(profile, runner)
496
+
497
+ def record_reassign_get(self, *, profile: str, app_key: str, apply_id: int) -> dict[str, Any]:
498
+ self._require_app_and_apply(app_key, apply_id)
499
+
500
+ def runner(session_profile, context):
501
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/reassign")
502
+ return {
503
+ "profile": profile,
504
+ "ws_id": session_profile.selected_ws_id,
505
+ "app_key": app_key,
506
+ "apply_id": apply_id,
507
+ "result": result,
508
+ "request_route": self._request_route_payload(context),
509
+ }
510
+
511
+ return self._run(profile, runner)
512
+
513
+ def record_reassign(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
514
+ self._require_app_and_apply(app_key, apply_id)
515
+ body = self._require_dict(payload)
516
+
517
+ def runner(session_profile, context):
518
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/reassign", json_body=body)
519
+ return {
520
+ "profile": profile,
521
+ "ws_id": session_profile.selected_ws_id,
522
+ "app_key": app_key,
523
+ "apply_id": apply_id,
524
+ "result": result,
525
+ "request_route": self._request_route_payload(context),
526
+ }
527
+
528
+ return self._run(profile, runner)
529
+
530
+ def record_countersign_candidates(
531
+ self,
532
+ *,
533
+ profile: str,
534
+ app_key: str,
535
+ apply_id: int,
536
+ page_size: int = 20,
537
+ page_num: int = 1,
538
+ audit_node_id: int = 0,
539
+ search_key: str | None = None,
540
+ ) -> dict[str, Any]:
541
+ self._require_app_and_apply(app_key, apply_id)
542
+ if audit_node_id <= 0:
543
+ raise_tool_error(QingflowApiError.config_error("audit_node_id must be positive"))
544
+
545
+ def runner(session_profile, context):
546
+ params: dict[str, Any] = {"pageSize": page_size, "pageNum": page_num, "auditNodeId": audit_node_id}
547
+ if search_key:
548
+ params["searchKey"] = search_key
549
+ result = self.backend.request("GET", context, f"/app/{app_key}/apply/{apply_id}/countersign/member", params=params)
550
+ return {
551
+ "profile": profile,
552
+ "ws_id": session_profile.selected_ws_id,
553
+ "app_key": app_key,
554
+ "apply_id": apply_id,
555
+ "page": result,
556
+ "request_route": self._request_route_payload(context),
557
+ }
558
+
559
+ return self._run(profile, runner)
560
+
561
+ def record_countersign(self, *, profile: str, app_key: str, apply_id: int, payload: dict[str, Any]) -> dict[str, Any]:
562
+ self._require_app_and_apply(app_key, apply_id)
563
+ body = self._require_dict(payload)
564
+ self._validate_countersign_payload(body)
565
+
566
+ def runner(session_profile, context):
567
+ result = self.backend.request("POST", context, f"/app/{app_key}/apply/{apply_id}/countersign", json_body=body)
568
+ return {
569
+ "profile": profile,
570
+ "ws_id": session_profile.selected_ws_id,
571
+ "app_key": app_key,
572
+ "apply_id": apply_id,
573
+ "result": result,
574
+ "request_route": self._request_route_payload(context),
575
+ }
576
+
577
+ return self._run(profile, runner)
578
+
579
+ def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
580
+ describe_route = getattr(self.backend, "describe_route", None)
581
+ if callable(describe_route):
582
+ payload = describe_route(context)
583
+ if isinstance(payload, dict):
584
+ return payload
585
+ return {
586
+ "base_url": getattr(context, "base_url", None),
587
+ "qf_version": getattr(context, "qf_version", None),
588
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
589
+ }
590
+
591
+ def _require_app_and_apply(self, app_key: str, apply_id: int) -> None:
592
+ if not app_key:
593
+ raise_tool_error(QingflowApiError.config_error("app_key is required"))
594
+ if apply_id <= 0:
595
+ raise_tool_error(QingflowApiError.config_error("apply_id must be positive"))
596
+
597
+ def _validate_comment_payload(self, payload: dict[str, Any]) -> None:
598
+ comment_detail = payload.get("commentDetail")
599
+ if not isinstance(comment_detail, dict):
600
+ raise_tool_error(QingflowApiError.config_error("payload.commentDetail must be an object"))
601
+ if not comment_detail.get("commentMsg"):
602
+ raise_tool_error(QingflowApiError.config_error("payload.commentDetail.commentMsg is required"))
603
+
604
+ def _normalize_approval_payload(self, profile: str, context, app_key: str, apply_id: int, payload: dict[str, Any]) -> JSONObject: # type: ignore[no-untyped-def]
605
+ body: JSONObject = dict(payload)
606
+ self._normalize_alias(body, "auditFeedback", "audit_feedback")
607
+ self._normalize_alias(body, "uploadFileSize", "upload_file_size")
608
+ self._normalize_alias(body, "handSignImageUrl", "hand_sign_image_url")
609
+ self._normalize_alias(body, "beingSaveSignature", "being_save_signature")
610
+ self._normalize_alias(body, "applyId", "apply_id")
611
+ self._normalize_alias(body, "formId", "form_id")
612
+
613
+ node_id = self._extract_node_id(body)
614
+ body["nodeId"] = self._resolve_actionable_node_id(context, app_key, apply_id, node_id)
615
+ body["applyId"] = self._match_or_fill_int(body, field_name="applyId", expected_value=apply_id)
616
+ body["formId"] = self._resolve_form_id(profile, context, app_key, explicit_form_id=body.get("formId"))
617
+ if body.get("answers") is None:
618
+ body["answers"] = self._fetch_current_todo_answers(context, app_key, apply_id, body["nodeId"])
619
+
620
+ self._validate_approval_payload(body)
621
+ return body
622
+
623
+ def _extract_node_id(self, payload: JSONObject) -> int:
624
+ node_id = payload.get("nodeId")
625
+ audit_node_id = payload.pop("auditNodeId", None)
626
+ if node_id is None:
627
+ node_id = audit_node_id
628
+ elif audit_node_id is not None and node_id != audit_node_id:
629
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId and payload.auditNodeId must match when both are provided"))
630
+ if not isinstance(node_id, int) or node_id <= 0:
631
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId or payload.auditNodeId must be a positive integer"))
632
+ return node_id
633
+
634
+ def _resolve_form_id(self, profile: str, context, app_key: str, *, explicit_form_id: Any | None) -> int: # type: ignore[no-untyped-def]
635
+ if explicit_form_id is not None:
636
+ if not isinstance(explicit_form_id, int) or explicit_form_id <= 0:
637
+ raise_tool_error(QingflowApiError.config_error("payload.formId must be a positive integer"))
638
+ form_id = self._get_form_id(profile, context, app_key)
639
+ if form_id != explicit_form_id:
640
+ raise_tool_error(
641
+ QingflowApiError.config_error(
642
+ f"payload.formId={explicit_form_id} does not match app_key '{app_key}' formId={form_id}"
643
+ )
644
+ )
645
+ return explicit_form_id
646
+ return self._get_form_id(profile, context, app_key)
647
+
648
+ def _get_form_id(self, profile: str, context, app_key: str) -> int: # type: ignore[no-untyped-def]
649
+ cache_key = f"{profile}:{app_key}"
650
+ cached = self._form_id_cache.get(cache_key)
651
+ if cached is not None:
652
+ return cached
653
+ result = self.backend.request("GET", context, f"/app/{app_key}/baseInfo")
654
+ form_id = result.get("formId") if isinstance(result, dict) else None
655
+ if not isinstance(form_id, int) or form_id <= 0:
656
+ raise_tool_error(QingflowApiError.config_error(f"cannot resolve formId for app_key '{app_key}'"))
657
+ self._form_id_cache[cache_key] = form_id
658
+ return form_id
659
+
660
+ def _match_or_fill_int(self, payload: JSONObject, *, field_name: str, expected_value: int) -> int:
661
+ current = payload.get(field_name)
662
+ if current is None:
663
+ return expected_value
664
+ if not isinstance(current, int) or current <= 0:
665
+ raise_tool_error(QingflowApiError.config_error(f"payload.{field_name} must be a positive integer"))
666
+ if current != expected_value:
667
+ raise_tool_error(QingflowApiError.config_error(f"payload.{field_name}={current} does not match apply_id={expected_value}"))
668
+ return current
669
+
670
+ def _normalize_alias(self, payload: JSONObject, canonical_key: str, alias_key: str) -> None:
671
+ alias_value = payload.pop(alias_key, None)
672
+ if canonical_key not in payload and alias_value is not None:
673
+ payload[canonical_key] = alias_value
674
+ elif alias_value is not None and payload.get(canonical_key) != alias_value:
675
+ raise_tool_error(QingflowApiError.config_error(f"payload.{canonical_key} and payload.{alias_key} must match when both are provided"))
676
+
677
+ def _resolve_actionable_node_id(self, context, app_key: str, apply_id: int, node_id: int) -> int: # type: ignore[no-untyped-def]
678
+ infos = self.backend.request(
679
+ "GET",
680
+ context,
681
+ f"/app/{app_key}/apply/{apply_id}/auditInfo",
682
+ params={"type": 1},
683
+ )
684
+ if not isinstance(infos, list) or not infos:
685
+ raise_tool_error(
686
+ QingflowApiError.config_error(
687
+ f"apply_id={apply_id} is not currently actionable for the logged-in user in todo list"
688
+ )
689
+ )
690
+ actionable_node_ids = {
691
+ candidate
692
+ for item in infos
693
+ if isinstance(item, dict)
694
+ for candidate in (item.get("auditNodeId"), item.get("nodeId"))
695
+ if isinstance(candidate, int) and candidate > 0
696
+ }
697
+ if node_id not in actionable_node_ids:
698
+ raise_tool_error(
699
+ QingflowApiError.config_error(
700
+ f"payload.nodeId={node_id} is not an actionable todo node for apply_id={apply_id}"
701
+ )
702
+ )
703
+ return node_id
704
+
705
+ def _fetch_current_todo_answers(self, context, app_key: str, apply_id: int, node_id: int) -> list[dict[str, Any]]: # type: ignore[no-untyped-def]
706
+ detail = self.backend.request(
707
+ "GET",
708
+ context,
709
+ f"/app/{app_key}/apply/{apply_id}",
710
+ params={"role": 3, "listType": 1, "auditNodeId": node_id},
711
+ )
712
+ answers = detail.get("answers") if isinstance(detail, dict) else None
713
+ if not isinstance(answers, list):
714
+ raise_tool_error(
715
+ QingflowApiError.config_error(
716
+ f"cannot resolve current answers for apply_id={apply_id} nodeId={node_id}"
717
+ )
718
+ )
719
+ normalized_answers: list[dict[str, Any]] = []
720
+ for item in answers:
721
+ if isinstance(item, dict):
722
+ normalized_answers.append(dict(item))
723
+ return normalized_answers
724
+
725
+ def _validate_approval_payload(self, payload: dict[str, Any]) -> None:
726
+ self._reject_unsupported_fields(payload)
727
+ if not isinstance(payload.get("formId"), int) or payload["formId"] <= 0:
728
+ raise_tool_error(QingflowApiError.config_error("payload.formId must be a positive integer"))
729
+ if not isinstance(payload.get("applyId"), int) or payload["applyId"] <= 0:
730
+ raise_tool_error(QingflowApiError.config_error("payload.applyId must be a positive integer"))
731
+ if not isinstance(payload.get("nodeId"), int) or payload["nodeId"] <= 0:
732
+ raise_tool_error(QingflowApiError.config_error("payload.nodeId must be a positive integer"))
733
+ answers = payload.get("answers")
734
+ if answers is not None and not isinstance(answers, list):
735
+ raise_tool_error(QingflowApiError.config_error("payload.answers must be an array when provided"))
736
+
737
+ def _validate_audit_payload(self, payload: dict[str, Any], *, require_uid: bool = False) -> None:
738
+ self._reject_unsupported_fields(payload)
739
+ if require_uid and not payload.get("uid"):
740
+ raise_tool_error(QingflowApiError.config_error("payload.uid is required"))
741
+
742
+ def _validate_countersign_payload(self, payload: dict[str, Any]) -> None:
743
+ self._reject_unsupported_fields(payload)
744
+ members = payload.get("countersignMembers")
745
+ if not isinstance(members, list) or not members:
746
+ raise_tool_error(QingflowApiError.config_error("payload.countersignMembers must be a non-empty array"))
747
+
748
+ def _reject_unsupported_fields(self, payload: dict[str, Any]) -> None:
749
+ if payload.get("handSignImageUrl"):
750
+ raise_tool_error(QingflowApiError.not_supported("NOT_SUPPORTED_IN_V1: handSignImageUrl is not supported"))
751
+
752
+ def _public_page_response(
753
+ self,
754
+ raw: dict[str, Any],
755
+ *,
756
+ items: list[dict[str, Any]],
757
+ pagination: dict[str, Any],
758
+ selection: dict[str, Any],
759
+ ) -> dict[str, Any]:
760
+ response = dict(raw)
761
+ response["ok"] = bool(raw.get("ok", True))
762
+ response["warnings"] = []
763
+ response["output_profile"] = "normal"
764
+ response["data"] = {
765
+ "items": items,
766
+ "pagination": pagination,
767
+ "selection": selection,
768
+ }
769
+ return response
770
+
771
+ def _public_action_response(
772
+ self,
773
+ raw: dict[str, Any],
774
+ *,
775
+ action: str,
776
+ resource: dict[str, Any],
777
+ selection: dict[str, Any],
778
+ human_review: bool = False,
779
+ ) -> dict[str, Any]:
780
+ response = dict(raw)
781
+ response["ok"] = bool(raw.get("ok", True))
782
+ response["warnings"] = []
783
+ response["output_profile"] = "normal"
784
+ response["data"] = {
785
+ "action": action,
786
+ "resource": resource,
787
+ "selection": selection,
788
+ "result": raw.get("result"),
789
+ "human_review": human_review,
790
+ }
791
+ return response
792
+
793
+ def _request_route_payload(self, context) -> JSONObject: # type: ignore[no-untyped-def]
794
+ describe_route = getattr(self.backend, "describe_route", None)
795
+ if callable(describe_route):
796
+ payload = describe_route(context)
797
+ if isinstance(payload, dict):
798
+ return payload
799
+ return {
800
+ "base_url": context.base_url,
801
+ "qf_version": context.qf_version,
802
+ "qf_version_source": getattr(context, "qf_version_source", None) or ("context" if getattr(context, "qf_version", None) else "unknown"),
803
+ }
804
+
805
+
806
+ def _approval_page_items(payload: Any) -> list[dict[str, Any]]:
807
+ if isinstance(payload, list):
808
+ return [item for item in payload if isinstance(item, dict)]
809
+ if not isinstance(payload, dict):
810
+ return []
811
+ for key in ("list", "items", "rows", "result"):
812
+ value = payload.get(key)
813
+ if isinstance(value, list):
814
+ return [item for item in value if isinstance(item, dict)]
815
+ for container_key in ("data", "page"):
816
+ nested = payload.get(container_key)
817
+ if isinstance(nested, dict):
818
+ nested_items = _approval_page_items(nested)
819
+ if nested_items:
820
+ return nested_items
821
+ return []
822
+
823
+
824
+ def _approval_page_amount(payload: Any) -> Any:
825
+ if isinstance(payload, dict):
826
+ return payload.get("pageAmount", payload.get("page_amount"))
827
+ return None
828
+
829
+
830
+ def _approval_page_total(payload: Any) -> Any:
831
+ if isinstance(payload, dict):
832
+ return payload.get("total", payload.get("count"))
833
+ return None