@josephyan/qingflow-app-builder-mcp 0.2.0-beta.11 → 0.2.0-beta.12

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/README.md CHANGED
@@ -3,13 +3,13 @@
3
3
  Install:
4
4
 
5
5
  ```bash
6
- npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.11
6
+ npm install @josephyan/qingflow-app-builder-mcp@0.2.0-beta.12
7
7
  ```
8
8
 
9
9
  Run:
10
10
 
11
11
  ```bash
12
- npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.11 qingflow-app-builder-mcp
12
+ npx -y -p @josephyan/qingflow-app-builder-mcp@0.2.0-beta.12 qingflow-app-builder-mcp
13
13
  ```
14
14
 
15
15
  Environment:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josephyan/qingflow-app-builder-mcp",
3
- "version": "0.2.0-beta.11",
3
+ "version": "0.2.0-beta.12",
4
4
  "description": "Builder MCP for Qingflow app/package/system design and staged solution workflows.",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "qingflow-mcp"
7
- version = "0.2.0b11"
7
+ version = "0.2.0b12"
8
8
  description = "User-authenticated MCP server for Qingflow"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -34,6 +34,7 @@ Note:
34
34
  - For flow presets, map natural language to canonical values before calling MCP:
35
35
  - “默认审批/基础审批/普通审批” -> `basic_approval`
36
36
  - “先填报再审批/提交后审批” -> `basic_fill_then_approve`
37
+ - For first-time flow or view work in a session, read `builder_tool_contract` before planning so keys, aliases, presets, and minimal examples come from MCP instead of memory.
37
38
  - For workflow assignees, prefer roles over explicit members:
38
39
  - use `role_search` first
39
40
  - use `member_search` only when the user explicitly names members or no stable role exists
@@ -69,25 +70,29 @@ For builder work:
69
70
 
70
71
  For view work, keep the order strict:
71
72
 
72
- 1. `app_read_fields`
73
- 2. `app_read_views_summary`
74
- 3. `app_views_plan`
75
- 4. `app_views_apply`
73
+ 1. `builder_tool_contract`
74
+ 2. `app_read_fields`
75
+ 3. `app_read_views_summary`
76
+ 4. `app_views_plan`
77
+ 5. `app_views_apply`
78
+ 6. `app_read_views_summary` again whenever `app_views_apply` returns `failed` or `partial_success`
76
79
 
77
80
  For flow work, keep the order strict:
78
81
 
79
- 1. `app_read_fields`
80
- 2. `app_read_flow_summary`
81
- 3. `role_search` or `member_search` if assignees need to come from the directory
82
- 4. `role_create` if the user wants a reusable role and no suitable role exists yet
83
- 5. Start from a canonical preset when possible
84
- 6. Use patch-style edits to that skeleton instead of freehand full-graph generation
85
- 7. Declare approver/fill/copy assignees explicitly:
82
+ 1. `builder_tool_contract`
83
+ 2. `app_read_fields`
84
+ 3. `app_read_flow_summary`
85
+ 4. `role_search` or `member_search` if assignees need to come from the directory
86
+ 5. `role_create` if the user wants a reusable role and no suitable role exists yet
87
+ 6. Start from a canonical preset when possible
88
+ 7. Use patch-style edits to that skeleton instead of freehand full-graph generation
89
+ 8. Declare approver/fill/copy assignees explicitly:
86
90
  - prefer `assignees.role_names`
87
91
  - support `assignees.member_names` / `assignees.member_emails` / `assignees.member_uids`
88
- 8. When a node must edit specific fields, declare `permissions.editable_fields`
89
- 9. `app_flow_plan`
90
- 10. `app_flow_apply`
92
+ 9. When a node must edit specific fields, declare `permissions.editable_fields`
93
+ 10. `app_flow_plan`
94
+ 11. `app_flow_apply`
95
+ 12. `app_read_flow_summary` after apply whenever the user asked for verification or apply returns `partial_success`
91
96
 
92
97
  In `prod`, keep `plan` and `apply` as separate phases unless the user explicitly asks for a direct live execution.
93
98
 
@@ -112,6 +117,8 @@ For additive work on existing systems:
112
117
  - `assignees.role_names`
113
118
  - `assignees.member_names`
114
119
  - `permissions.editable_fields`
120
+ - Reuse `app_flow_plan` output directly when it succeeds. Do not rewrite it into internal keys such as `role_entries` or `editable_que_ids`.
121
+ - Reuse `app_views_plan` output directly when it succeeds. Do not re-expand aliases such as `column_names`.
115
122
  - Do not guess role ids or member ids. Resolve them from the directory first.
116
123
  - `app_schema_apply` does not treat package attachment as success criteria; if package ownership matters, verify `tag_ids_after` and call `package_attach_app` explicitly.
117
124
  - `package_attach_app` is the source of truth for package ownership; do not assume app creation or publish implicitly attaches the app.
@@ -125,7 +132,11 @@ For additive work on existing systems:
125
132
  - `app_publish_verify` is the publish source of truth.
126
133
  - If readback mismatches the UI, compare `request_route` and do not assume the builder hit the same `qf_version` as the browser
127
134
  - Treat post-write readback as the source of truth, not just write status codes
135
+ - For views, a top-level `VIEW_APPLY_FAILED` does not prove all requested views failed. Read back the view list and verify which views actually landed.
128
136
  - In final user-facing summaries, distinguish clearly between:
137
+ - contract is visible / canonical shape is known
138
+ - plan succeeded
139
+ - apply landed and readback verified it
129
140
  - base template/skeleton applied
130
141
  - business-specific rules completed
131
142
  - remaining gaps or follow-up patches
@@ -41,6 +41,10 @@
41
41
  - Do not repeat create steps after `app_key` already exists
42
42
  - For backend rejects, keep the retry narrow: retry only the failed tool, not the whole chain
43
43
  - For `VALIDATION_ERROR`, do not keep guessing. Reuse `suggested_next_call`, `canonical_arguments`, `allowed_keys`, and `allowed_values` first.
44
+ - For flow work, do not replay internal keys from old logs or plan outputs. Public builder calls should stay on:
45
+ - `assignees.role_ids` / `assignees.member_uids` / `assignees.member_emails`
46
+ - `permissions.editable_fields`
47
+ - For view work, treat `columns` as the only canonical public key. `app_read_views_summary` and `app_views_plan/apply` should all be read and written in that shape.
44
48
  - If a view or flow write fails, report the smallest next action:
45
49
  - wrong key -> switch to canonical key
46
50
  - unsupported preset -> switch to allowed canonical preset
@@ -62,9 +62,9 @@ These execute normalized patches and publish by default unless `publish=false`.
62
62
  - Tidy layout:
63
63
  `app_read_layout_summary -> app_layout_plan -> app_layout_apply`
64
64
  - Add workflow:
65
- `app_read_fields -> app_read_flow_summary -> role_search/member_search -> app_flow_plan -> app_flow_apply`
65
+ `builder_tool_contract -> app_read_fields -> app_read_flow_summary -> role_search/member_search -> app_flow_plan -> app_flow_apply -> app_read_flow_summary`
66
66
  - Add views:
67
- `app_read_fields -> app_read_views_summary -> app_views_plan -> app_views_apply`
67
+ `builder_tool_contract -> app_read_fields -> app_read_views_summary -> app_views_plan -> app_views_apply -> app_read_views_summary`
68
68
 
69
69
  ## Avoid
70
70
 
@@ -73,6 +73,7 @@ These execute normalized patches and publish by default unless `publish=false`.
73
73
  - Do not create a new package without first asking the user to confirm package creation
74
74
  - Do not skip summary reads before flow or view work
75
75
  - Do not emit `column_names`; always use `columns`
76
+ - Do not reuse internal flow keys such as `role_entries` or `editable_que_ids` in public builder calls
76
77
  - Do not pass natural-language preset guesses such as `default_approval`; map them to canonical preset values first
77
78
  - Do not omit assignees on approval/fill/copy nodes
78
79
  - Do not guess role ids, member ids, or editable field ids; resolve names first
@@ -4,14 +4,16 @@ Use this when the app already exists and the task is only about workflow.
4
4
 
5
5
  ## Minimal sequence
6
6
 
7
- 1. `app_read_fields`
8
- 2. `app_read_flow_summary`
9
- 3. `role_search` or `member_search`
10
- 4. `role_create` if the user wants a reusable directory role and no good role exists
11
- 5. start from a canonical preset when possible
12
- 6. patch the skeleton instead of freehanding a full graph
13
- 7. `app_flow_plan`
14
- 8. `app_flow_apply`
7
+ 1. `builder_tool_contract(tool_name="app_flow_plan")`
8
+ 2. `app_read_fields`
9
+ 3. `app_read_flow_summary`
10
+ 4. `role_search` or `member_search`
11
+ 5. `role_create` if the user wants a reusable directory role and no good role exists
12
+ 6. start from a canonical preset when possible
13
+ 7. patch the skeleton instead of freehanding a full graph
14
+ 8. `app_flow_plan`
15
+ 9. `app_flow_apply`
16
+ 10. `app_read_flow_summary` when apply returns `partial_success` or the user asked for verification
15
17
 
16
18
  If you are unsure about presets or node shapes, call `builder_tool_contract(tool_name="app_flow_apply")` before guessing.
17
19
 
@@ -142,6 +144,8 @@ Only after that should you use explicit nodes:
142
144
  }
143
145
  ```
144
146
 
147
+ After `app_flow_plan` succeeds, prefer reusing its `suggested_next_call.arguments` directly. Do not rewrite the result into internal fields such as `role_entries` or `editable_que_ids`.
148
+
145
149
  ## Common failures
146
150
 
147
151
  ### `FLOW_ASSIGNEE_REQUIRED`
@@ -159,6 +163,13 @@ Preferred fix order:
159
163
 
160
164
  The workflow depends on fields that do not exist yet, usually `status`. Fix schema first.
161
165
 
166
+ Preferred recovery:
167
+
168
+ 1. use the returned `suggested_next_call`
169
+ 2. apply the minimal schema patch
170
+ 3. rerun `app_read_fields`
171
+ 4. rerun `app_flow_plan`
172
+
162
173
  ### `INVALID_FLOW_EDGE`
163
174
 
164
175
  One or more transitions reference unknown nodes or create an invalid graph.
@@ -193,6 +204,11 @@ Do not keep guessing preset names or node shapes. First:
193
204
  - `assignees.member_names`
194
205
  - `permissions.editable_fields`
195
206
 
207
+ Do not copy internal keys from old plan outputs or logs, including:
208
+
209
+ - `role_entries`
210
+ - `editable_que_ids`
211
+
196
212
  ## Notes
197
213
 
198
214
  - `mode=replace` is the only supported flow apply mode
@@ -4,10 +4,12 @@ Use this when the task is only about table, card, board, or gantt views.
4
4
 
5
5
  ## Minimal sequence
6
6
 
7
- 1. `app_read_fields`
8
- 2. `app_read_views_summary`
9
- 3. `app_views_plan`
10
- 4. `app_views_apply`
7
+ 1. `builder_tool_contract(tool_name="app_views_plan")`
8
+ 2. `app_read_fields`
9
+ 3. `app_read_views_summary`
10
+ 4. `app_views_plan`
11
+ 5. `app_views_apply`
12
+ 6. `app_read_views_summary` again whenever apply returns `failed` or `partial_success`
11
13
 
12
14
  If you are unsure about keys or view types, call `builder_tool_contract(tool_name="app_views_apply")` before guessing.
13
15
 
@@ -62,6 +64,8 @@ Apply it:
62
64
  }
63
65
  ```
64
66
 
67
+ After `app_views_plan` succeeds, prefer reusing its `suggested_next_call.arguments` directly. Do not rewrite aliases back into non-canonical keys such as `column_names`.
68
+
65
69
  Board example:
66
70
 
67
71
  ```json
@@ -146,12 +150,14 @@ Do not repeat `app_views_apply` with guessed keys. First:
146
150
 
147
151
  1. check `suggested_next_call`
148
152
  2. reuse `canonical_arguments` if present
149
- 3. if needed, call `builder_tool_contract`
150
- 4. retry only the minimal failed view patch
153
+ 3. call `app_read_views_summary` to see whether any requested views landed anyway
154
+ 4. if needed, call `builder_tool_contract`
155
+ 5. retry only the minimal failed view patch
151
156
 
152
157
  ## Notes
153
158
 
154
159
  - `fields` is accepted as an alias for `columns`, but skill examples should still use `columns`
155
160
  - `column_names` should not appear in skill examples
161
+ - `app_read_views_summary` should be treated as canonical readback and now returns `columns`
156
162
  - `filters` are ANDed together as one flat condition group
157
163
  - `app_views_apply` publishes by default
@@ -2,4 +2,4 @@ from __future__ import annotations
2
2
 
3
3
  __all__ = ["__version__"]
4
4
 
5
- __version__ = "0.2.0b11"
5
+ __version__ = "0.2.0b12"
@@ -679,6 +679,58 @@ class AiBuilderFacade:
679
679
  normalized_nodes.append(normalized_node)
680
680
  return normalized_nodes, issues
681
681
 
682
+ def _canonicalize_flow_nodes_for_public_output(self, nodes: list[dict[str, Any]]) -> list[dict[str, Any]]:
683
+ public_nodes: list[dict[str, Any]] = []
684
+ for node in nodes:
685
+ if not isinstance(node, dict):
686
+ continue
687
+ payload = deepcopy(node)
688
+ assignees = payload.get("assignees") if isinstance(payload.get("assignees"), dict) else {}
689
+ permissions = payload.get("permissions") if isinstance(payload.get("permissions"), dict) else {}
690
+ public_assignees: dict[str, Any] = {}
691
+ role_ids = [
692
+ role_id
693
+ for role_id in (
694
+ _coerce_positive_int(entry.get("roleId"))
695
+ for entry in (assignees.get("role_entries") or [])
696
+ if isinstance(entry, dict)
697
+ )
698
+ if role_id is not None
699
+ ]
700
+ member_uids = [
701
+ member_uid
702
+ for member_uid in (_coerce_positive_int(value) for value in (assignees.get("member_uids") or []))
703
+ if member_uid is not None
704
+ ]
705
+ if role_ids:
706
+ public_assignees["role_ids"] = role_ids
707
+ if member_uids:
708
+ public_assignees["member_uids"] = member_uids
709
+ if bool(assignees.get("include_sub_departs")):
710
+ public_assignees["include_sub_departs"] = True
711
+ public_permissions: dict[str, Any] = {}
712
+ editable_fields = [str(name) for name in (permissions.get("editable_fields") or []) if str(name or "").strip()]
713
+ if editable_fields:
714
+ public_permissions["editable_fields"] = editable_fields
715
+ config_payload = payload.get("config") if isinstance(payload.get("config"), dict) else {}
716
+ if isinstance(config_payload, dict):
717
+ config_payload = deepcopy(config_payload)
718
+ config_payload.pop("conditionFormatMatrix", None)
719
+ if public_assignees:
720
+ payload["assignees"] = public_assignees
721
+ else:
722
+ payload.pop("assignees", None)
723
+ if public_permissions:
724
+ payload["permissions"] = public_permissions
725
+ else:
726
+ payload.pop("permissions", None)
727
+ if config_payload:
728
+ payload["config"] = config_payload
729
+ else:
730
+ payload.pop("config", None)
731
+ public_nodes.append(payload)
732
+ return public_nodes
733
+
682
734
  def package_attach_app(
683
735
  self,
684
736
  *,
@@ -1287,7 +1339,8 @@ class AiBuilderFacade:
1287
1339
  if fields_result.get("status") == "failed":
1288
1340
  return fields_result
1289
1341
  current_fields = fields_result.get("fields", [])
1290
- nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
1342
+ normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
1343
+ public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
1291
1344
  if resolution_issues:
1292
1345
  first_issue = resolution_issues[0]
1293
1346
  suggested_call = None
@@ -1304,17 +1357,17 @@ class AiBuilderFacade:
1304
1357
  "app_key": request.app_key,
1305
1358
  "mode": str(request.mode or "replace"),
1306
1359
  "preset": request.preset.value if request.preset else None,
1307
- "nodes": nodes,
1360
+ "nodes": public_nodes,
1308
1361
  "transitions": transitions,
1309
1362
  },
1310
1363
  details={"issues": resolution_issues},
1311
1364
  suggested_next_call=suggested_call,
1312
1365
  )
1313
1366
  status_field_present = _infer_status_field_id(current_fields) is not None
1314
- node_types = {str(node.get("type") or "") for node in nodes}
1367
+ node_types = {str(node.get("type") or "") for node in normalized_nodes}
1315
1368
  assignee_required_nodes = [
1316
1369
  node.get("id")
1317
- for node in nodes
1370
+ for node in normalized_nodes
1318
1371
  if str(node.get("type") or "") in {"approve", "fill", "copy"}
1319
1372
  and not (
1320
1373
  (node.get("assignees") or {}).get("role_entries")
@@ -1329,7 +1382,7 @@ class AiBuilderFacade:
1329
1382
  "app_key": request.app_key,
1330
1383
  "mode": str(request.mode or "replace"),
1331
1384
  "preset": request.preset.value if request.preset else None,
1332
- "nodes": nodes,
1385
+ "nodes": public_nodes,
1333
1386
  "transitions": transitions,
1334
1387
  },
1335
1388
  details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
@@ -1342,7 +1395,7 @@ class AiBuilderFacade:
1342
1395
  normalized_args={
1343
1396
  "app_key": request.app_key,
1344
1397
  "mode": str(request.mode or "replace"),
1345
- "nodes": nodes,
1398
+ "nodes": public_nodes,
1346
1399
  "transitions": transitions,
1347
1400
  },
1348
1401
  details={"missing_dependencies": ["status field"]},
@@ -1359,12 +1412,12 @@ class AiBuilderFacade:
1359
1412
  },
1360
1413
  },
1361
1414
  )
1362
- workflow = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
1415
+ workflow = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
1363
1416
  if workflow.get("status") == "failed":
1364
1417
  workflow["normalized_args"] = {
1365
1418
  "app_key": request.app_key,
1366
1419
  "mode": str(request.mode or "replace"),
1367
- "nodes": nodes,
1420
+ "nodes": public_nodes,
1368
1421
  "transitions": transitions,
1369
1422
  }
1370
1423
  workflow["suggested_next_call"] = {
@@ -1373,7 +1426,7 @@ class AiBuilderFacade:
1373
1426
  "profile": profile,
1374
1427
  "app_key": request.app_key,
1375
1428
  "mode": "replace",
1376
- "nodes": nodes,
1429
+ "nodes": public_nodes,
1377
1430
  "transitions": transitions,
1378
1431
  },
1379
1432
  }
@@ -1381,7 +1434,7 @@ class AiBuilderFacade:
1381
1434
  normalized_args = {
1382
1435
  "app_key": request.app_key,
1383
1436
  "mode": str(request.mode or "replace"),
1384
- "nodes": nodes,
1437
+ "nodes": public_nodes,
1385
1438
  "transitions": transitions,
1386
1439
  }
1387
1440
  return {
@@ -2030,7 +2083,9 @@ class AiBuilderFacade:
2030
2083
  )
2031
2084
  entity = _entity_spec_from_app(base_info=base, schema=schema, views=None)
2032
2085
  current_fields = _parse_schema(schema)["fields"]
2033
- nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
2086
+ normalized_nodes, resolution_issues = self._normalize_flow_nodes(profile=profile, current_fields=current_fields, nodes=nodes)
2087
+ public_nodes = self._canonicalize_flow_nodes_for_public_output(normalized_nodes)
2088
+ normalized_args["nodes"] = public_nodes
2034
2089
  if resolution_issues:
2035
2090
  first_issue = resolution_issues[0]
2036
2091
  suggested_call = None
@@ -2049,7 +2104,7 @@ class AiBuilderFacade:
2049
2104
  )
2050
2105
  assignee_required_nodes = [
2051
2106
  node.get("id")
2052
- for node in nodes
2107
+ for node in normalized_nodes
2053
2108
  if str(node.get("type") or "") in {"approve", "fill", "copy"}
2054
2109
  and not (
2055
2110
  (node.get("assignees") or {}).get("role_entries")
@@ -2064,13 +2119,13 @@ class AiBuilderFacade:
2064
2119
  details={"node_ids": assignee_required_nodes, "policy": "prefer role assignees; explicit members are also supported"},
2065
2120
  suggested_next_call={"tool_name": "role_search", "arguments": {"profile": profile, "keyword": ""}},
2066
2121
  )
2067
- workflow_spec = _build_public_workflow_spec(nodes=nodes, transitions=transitions)
2122
+ workflow_spec = _build_public_workflow_spec(nodes=normalized_nodes, transitions=transitions)
2068
2123
  if workflow_spec.get("status") == "failed":
2069
2124
  workflow_spec["normalized_args"] = normalized_args
2070
2125
  workflow_spec.setdefault("request_id", None)
2071
2126
  workflow_spec["suggested_next_call"] = {"tool_name": "app_flow_plan", "arguments": {"profile": profile, **normalized_args}}
2072
2127
  return workflow_spec
2073
- desired_node_count = len([node for node in nodes if node.get("type") != "end"])
2128
+ desired_node_count = len([node for node in normalized_nodes if node.get("type") != "end"])
2074
2129
  current_workflow, _workflow_unavailable = self._load_workflow_result(profile=profile, app_key=app_key, tolerate_404=True)
2075
2130
  current_node_count = len(_summarize_workflow_nodes(current_workflow))
2076
2131
  if current_node_count == desired_node_count and desired_node_count > 0:
@@ -2124,7 +2179,7 @@ class AiBuilderFacade:
2124
2179
  arguments.setdefault("profile", profile)
2125
2180
  arguments.setdefault("app_key", app_key)
2126
2181
  arguments.setdefault("mode", mode)
2127
- arguments.setdefault("nodes", nodes)
2182
+ arguments.setdefault("nodes", public_nodes)
2128
2183
  arguments.setdefault("transitions", transitions)
2129
2184
  suggested_next_call["arguments"] = arguments
2130
2185
  failed["suggested_next_call"] = suggested_next_call
@@ -2216,14 +2271,17 @@ class AiBuilderFacade:
2216
2271
  if isinstance(field, dict) and str(field.get("name") or "")
2217
2272
  }
2218
2273
  removed: list[str] = []
2274
+ view_results: list[dict[str, Any]] = []
2219
2275
  for name in remove_views:
2220
2276
  key = existing_by_name.get(name)
2221
2277
  if key:
2222
2278
  self.views.view_delete(profile=profile, viewgraph_key=key)
2223
2279
  removed.append(name)
2224
2280
  existing_by_name.pop(name, None)
2281
+ view_results.append({"name": name, "type": None, "status": "removed"})
2225
2282
  created: list[str] = []
2226
2283
  updated: list[str] = []
2284
+ failed_views: list[dict[str, Any]] = []
2227
2285
  existing_view_list = [
2228
2286
  view
2229
2287
  for view in (existing_views if isinstance(existing_views, list) else [])
@@ -2301,6 +2359,7 @@ class AiBuilderFacade:
2301
2359
  )
2302
2360
  self.views.view_update(profile=profile, viewgraph_key=existing_key, payload=payload)
2303
2361
  updated.append(patch.name)
2362
+ view_results.append({"name": patch.name, "type": patch.type.value, "status": "updated"})
2304
2363
  else:
2305
2364
  template_key = _pick_view_template_key(existing_view_list, desired_type=patch.type.value)
2306
2365
  if patch.type.value == "table" and template_key:
@@ -2326,9 +2385,13 @@ class AiBuilderFacade:
2326
2385
  )
2327
2386
  self.views.view_create(profile=profile, payload=payload)
2328
2387
  created.append(patch.name)
2388
+ view_results.append({"name": patch.name, "type": patch.type.value, "status": "created"})
2329
2389
  except (QingflowApiError, RuntimeError) as error:
2330
2390
  api_error = _coerce_api_error(error)
2331
- if api_error.backend_code == 48104:
2391
+ should_retry_minimal = api_error.backend_code == 48104 or (
2392
+ patch.type.value == "table" and bool(patch.filters) and api_error.http_status is not None and api_error.http_status >= 500
2393
+ )
2394
+ if should_retry_minimal:
2332
2395
  try:
2333
2396
  if existing_key or created_key:
2334
2397
  target_key = created_key or existing_key or ""
@@ -2342,8 +2405,14 @@ class AiBuilderFacade:
2342
2405
  self.views.view_update(profile=profile, viewgraph_key=target_key, payload=fallback_payload)
2343
2406
  if existing_key:
2344
2407
  updated.append(patch.name)
2408
+ view_results.append(
2409
+ {"name": patch.name, "type": patch.type.value, "status": "updated", "fallback_applied": True}
2410
+ )
2345
2411
  else:
2346
2412
  created.append(patch.name)
2413
+ view_results.append(
2414
+ {"name": patch.name, "type": patch.type.value, "status": "created", "fallback_applied": True}
2415
+ )
2347
2416
  continue
2348
2417
  fallback_payload = _build_minimal_view_payload(
2349
2418
  app_key=app_key,
@@ -2354,6 +2423,7 @@ class AiBuilderFacade:
2354
2423
  )
2355
2424
  self.views.view_create(profile=profile, payload=fallback_payload)
2356
2425
  created.append(patch.name)
2426
+ view_results.append({"name": patch.name, "type": patch.type.value, "status": "created", "fallback_applied": True})
2357
2427
  continue
2358
2428
  except (QingflowApiError, RuntimeError) as fallback_error:
2359
2429
  api_error = _coerce_api_error(fallback_error)
@@ -2362,11 +2432,17 @@ class AiBuilderFacade:
2362
2432
  self.views.view_delete(profile=profile, viewgraph_key=created_key)
2363
2433
  except Exception:
2364
2434
  pass
2365
- return _failed_from_api_error(
2366
- "VIEW_APPLY_FAILED",
2367
- api_error,
2368
- normalized_args=normalized_args,
2369
- details={
2435
+ failure_entry = {
2436
+ "name": patch.name,
2437
+ "type": patch.type.value,
2438
+ "status": "failed",
2439
+ "error_code": "VIEW_APPLY_FAILED",
2440
+ "message": _public_error_message("VIEW_APPLY_FAILED", api_error),
2441
+ "request_id": api_error.request_id,
2442
+ "backend_code": api_error.backend_code,
2443
+ "http_status": None if api_error.http_status == 404 else api_error.http_status,
2444
+ "operation": "update" if existing_key or created_key else "create",
2445
+ "details": {
2370
2446
  "app_key": app_key,
2371
2447
  "view_name": patch.name,
2372
2448
  "view_type": patch.type.value,
@@ -2377,12 +2453,16 @@ class AiBuilderFacade:
2377
2453
  "end_field": patch.end_field,
2378
2454
  "title_field": patch.title_field,
2379
2455
  "operation": "update" if existing_key or created_key else "create",
2456
+ "transport_error": {
2457
+ "http_status": api_error.http_status,
2458
+ "backend_code": api_error.backend_code,
2459
+ "category": api_error.category,
2460
+ },
2380
2461
  },
2381
- suggested_next_call={
2382
- "tool_name": "app_views_plan",
2383
- "arguments": {"profile": profile, "app_key": app_key},
2384
- },
2385
- )
2462
+ }
2463
+ failed_views.append(failure_entry)
2464
+ view_results.append(failure_entry)
2465
+ continue
2386
2466
  try:
2387
2467
  verified_view_result, verified_views_unavailable = self._load_views_result(profile=profile, app_key=app_key, tolerate_404=True)
2388
2468
  except (QingflowApiError, RuntimeError) as error:
@@ -2401,6 +2481,63 @@ class AiBuilderFacade:
2401
2481
  }
2402
2482
  verified = (not verified_views_unavailable) and all(name in verified_names for name in created + updated) and all(name not in verified_names for name in removed)
2403
2483
  noop = not created and not updated and not removed
2484
+ if failed_views:
2485
+ successful_changes = bool(created or updated or removed)
2486
+ verification_by_view = []
2487
+ for item in view_results:
2488
+ if item.get("status") in {"created", "updated"}:
2489
+ verification_by_view.append(
2490
+ {
2491
+ "name": item.get("name"),
2492
+ "type": item.get("type"),
2493
+ "status": item.get("status"),
2494
+ "present_in_readback": None if verified_views_unavailable else item.get("name") in verified_names,
2495
+ }
2496
+ )
2497
+ elif item.get("status") == "removed":
2498
+ verification_by_view.append(
2499
+ {
2500
+ "name": item.get("name"),
2501
+ "type": item.get("type"),
2502
+ "status": "removed",
2503
+ "present_in_readback": None if verified_views_unavailable else item.get("name") not in verified_names,
2504
+ }
2505
+ )
2506
+ else:
2507
+ verification_by_view.append(
2508
+ {
2509
+ "name": item.get("name"),
2510
+ "type": item.get("type"),
2511
+ "status": "failed",
2512
+ "present_in_readback": None,
2513
+ "error_code": item.get("error_code"),
2514
+ }
2515
+ )
2516
+ first_failure = failed_views[0]
2517
+ response = {
2518
+ "status": "partial_success" if successful_changes else "failed",
2519
+ "error_code": "VIEW_APPLY_PARTIAL" if successful_changes else "VIEW_APPLY_FAILED",
2520
+ "recoverable": True,
2521
+ "message": "applied some view patches; at least one view failed" if successful_changes else "one or more view patches failed",
2522
+ "normalized_args": normalized_args,
2523
+ "missing_fields": [],
2524
+ "allowed_values": {"view_types": [member.value for member in PublicViewType], "view.filter.operator": [member.value for member in ViewFilterOperator]},
2525
+ "details": {"per_view_results": view_results},
2526
+ "request_id": first_failure.get("request_id"),
2527
+ "suggested_next_call": {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2528
+ "backend_code": first_failure.get("backend_code"),
2529
+ "http_status": first_failure.get("http_status"),
2530
+ "noop": noop,
2531
+ "verification": {
2532
+ "views_verified": verified,
2533
+ "views_read_unavailable": verified_views_unavailable,
2534
+ "by_view": verification_by_view,
2535
+ },
2536
+ "app_key": app_key,
2537
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": failed_views},
2538
+ "verified": verified,
2539
+ }
2540
+ return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
2404
2541
  response = {
2405
2542
  "status": "success" if verified else "partial_success",
2406
2543
  "error_code": None if not verified_views_unavailable else "VIEWS_READBACK_PENDING",
@@ -2413,9 +2550,9 @@ class AiBuilderFacade:
2413
2550
  "request_id": None,
2414
2551
  "suggested_next_call": None if not verified_views_unavailable else {"tool_name": "app_read_views_summary", "arguments": {"profile": profile, "app_key": app_key}},
2415
2552
  "noop": noop,
2416
- "verification": {"views_verified": verified, "views_read_unavailable": verified_views_unavailable},
2553
+ "verification": {"views_verified": verified, "views_read_unavailable": verified_views_unavailable, "by_view": view_results},
2417
2554
  "app_key": app_key,
2418
- "views_diff": {"created": created, "updated": updated, "removed": removed},
2555
+ "views_diff": {"created": created, "updated": updated, "removed": removed, "failed": []},
2419
2556
  "verified": verified,
2420
2557
  }
2421
2558
  return self._append_publish_result(profile=profile, app_key=app_key, publish=publish, response=response)
@@ -3694,13 +3831,20 @@ def _summarize_views(result: Any) -> list[dict[str, Any]]:
3694
3831
  for view in result:
3695
3832
  if not isinstance(view, dict):
3696
3833
  continue
3834
+ name = view.get("viewgraphName") or view.get("viewName") or view.get("title")
3835
+ view_key = view.get("viewgraphKey") or view.get("viewKey")
3836
+ view_type = _normalize_view_type_name(view.get("viewgraphType") or view.get("type"))
3837
+ columns = view.get("columnNames") or view.get("columns") or []
3838
+ group_by = view.get("groupBy") or view.get("group_by")
3839
+ if not any((name, view_key, view_type, columns, group_by)):
3840
+ continue
3697
3841
  items.append(
3698
3842
  {
3699
- "name": view.get("viewgraphName") or view.get("viewName") or view.get("title"),
3700
- "view_key": view.get("viewgraphKey") or view.get("viewKey"),
3701
- "type": view.get("viewgraphType") or view.get("type"),
3702
- "column_names": view.get("columnNames") or view.get("columns") or [],
3703
- "group_by": view.get("groupBy") or view.get("group_by"),
3843
+ "name": name,
3844
+ "view_key": view_key,
3845
+ "type": view_type,
3846
+ "columns": columns,
3847
+ "group_by": group_by,
3704
3848
  }
3705
3849
  )
3706
3850
  return items
@@ -4080,6 +4224,8 @@ def _pick_view_template_key(existing_views: list[dict[str, Any]], *, desired_typ
4080
4224
 
4081
4225
  def _normalize_view_type_name(value: Any) -> str:
4082
4226
  normalized = str(value or "").strip().lower()
4227
+ if not normalized:
4228
+ return ""
4083
4229
  if "gantt" in normalized:
4084
4230
  return "gantt"
4085
4231
  if "board" in normalized:
@@ -1319,6 +1319,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
1319
1319
  "node.type": [member.value for member in PublicFlowNodeType],
1320
1320
  "node.condition.operator": [member.value for member in FlowConditionOperator],
1321
1321
  },
1322
+ "dependency_hints": [
1323
+ "approval-style workflows require an explicit status field",
1324
+ "approve/fill/copy nodes require at least one assignee",
1325
+ ],
1322
1326
  "minimal_example": {
1323
1327
  "profile": "default",
1324
1328
  "app_key": "APP_KEY",
@@ -1396,6 +1400,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
1396
1400
  "node.type": [member.value for member in PublicFlowNodeType],
1397
1401
  "node.condition.operator": [member.value for member in FlowConditionOperator],
1398
1402
  },
1403
+ "dependency_hints": [
1404
+ "approval-style workflows require an explicit status field",
1405
+ "approve/fill/copy nodes require at least one assignee",
1406
+ ],
1399
1407
  "minimal_example": {
1400
1408
  "profile": "default",
1401
1409
  "app_key": "APP_KEY",
@@ -1516,6 +1524,10 @@ _BUILDER_TOOL_CONTRACTS: dict[str, JSONObject] = {
1516
1524
  "view.type": [member.value for member in PublicViewType],
1517
1525
  "view.filter.operator": [member.value for member in ViewFilterOperator],
1518
1526
  },
1527
+ "execution_notes": [
1528
+ "apply may return partial_success when some views land and others fail",
1529
+ "read back app_read_views_summary after any failed or partial view apply",
1530
+ ],
1519
1531
  "minimal_example": {
1520
1532
  "profile": "default",
1521
1533
  "app_key": "APP_KEY",