@josephyan/qingflow-cli 0.2.0-beta.1000

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 (92) hide show
  1. package/README.md +31 -0
  2. package/docs/local-agent-install.md +309 -0
  3. package/entry_point.py +13 -0
  4. package/npm/bin/qingflow.mjs +5 -0
  5. package/npm/lib/runtime.mjs +346 -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 +37 -0
  11. package/src/qingflow_mcp/__main__.py +5 -0
  12. package/src/qingflow_mcp/backend_client.py +649 -0
  13. package/src/qingflow_mcp/builder_facade/__init__.py +3 -0
  14. package/src/qingflow_mcp/builder_facade/models.py +1846 -0
  15. package/src/qingflow_mcp/builder_facade/service.py +16502 -0
  16. package/src/qingflow_mcp/cli/__init__.py +1 -0
  17. package/src/qingflow_mcp/cli/commands/__init__.py +18 -0
  18. package/src/qingflow_mcp/cli/commands/app.py +40 -0
  19. package/src/qingflow_mcp/cli/commands/auth.py +112 -0
  20. package/src/qingflow_mcp/cli/commands/builder.py +539 -0
  21. package/src/qingflow_mcp/cli/commands/chart.py +18 -0
  22. package/src/qingflow_mcp/cli/commands/common.py +62 -0
  23. package/src/qingflow_mcp/cli/commands/imports.py +96 -0
  24. package/src/qingflow_mcp/cli/commands/portal.py +25 -0
  25. package/src/qingflow_mcp/cli/commands/record.py +331 -0
  26. package/src/qingflow_mcp/cli/commands/repo.py +80 -0
  27. package/src/qingflow_mcp/cli/commands/task.py +141 -0
  28. package/src/qingflow_mcp/cli/commands/view.py +18 -0
  29. package/src/qingflow_mcp/cli/commands/workspace.py +110 -0
  30. package/src/qingflow_mcp/cli/context.py +60 -0
  31. package/src/qingflow_mcp/cli/formatters.py +573 -0
  32. package/src/qingflow_mcp/cli/json_io.py +50 -0
  33. package/src/qingflow_mcp/cli/main.py +186 -0
  34. package/src/qingflow_mcp/cli/qingflow_login.py +116 -0
  35. package/src/qingflow_mcp/cli/terminal_ui.py +173 -0
  36. package/src/qingflow_mcp/config.py +407 -0
  37. package/src/qingflow_mcp/errors.py +66 -0
  38. package/src/qingflow_mcp/id_utils.py +49 -0
  39. package/src/qingflow_mcp/import_store.py +121 -0
  40. package/src/qingflow_mcp/json_types.py +18 -0
  41. package/src/qingflow_mcp/list_type_labels.py +76 -0
  42. package/src/qingflow_mcp/public_surface.py +243 -0
  43. package/src/qingflow_mcp/repository_store.py +71 -0
  44. package/src/qingflow_mcp/response_trim.py +841 -0
  45. package/src/qingflow_mcp/server.py +216 -0
  46. package/src/qingflow_mcp/server_app_builder.py +543 -0
  47. package/src/qingflow_mcp/server_app_user.py +386 -0
  48. package/src/qingflow_mcp/session_store.py +369 -0
  49. package/src/qingflow_mcp/solution/__init__.py +6 -0
  50. package/src/qingflow_mcp/solution/build_assembly_store.py +181 -0
  51. package/src/qingflow_mcp/solution/compiler/__init__.py +282 -0
  52. package/src/qingflow_mcp/solution/compiler/chart_compiler.py +96 -0
  53. package/src/qingflow_mcp/solution/compiler/form_compiler.py +495 -0
  54. package/src/qingflow_mcp/solution/compiler/icon_utils.py +187 -0
  55. package/src/qingflow_mcp/solution/compiler/navigation_compiler.py +57 -0
  56. package/src/qingflow_mcp/solution/compiler/package_compiler.py +19 -0
  57. package/src/qingflow_mcp/solution/compiler/portal_compiler.py +60 -0
  58. package/src/qingflow_mcp/solution/compiler/view_compiler.py +51 -0
  59. package/src/qingflow_mcp/solution/compiler/workflow_compiler.py +173 -0
  60. package/src/qingflow_mcp/solution/design_session.py +222 -0
  61. package/src/qingflow_mcp/solution/design_store.py +100 -0
  62. package/src/qingflow_mcp/solution/executor.py +2398 -0
  63. package/src/qingflow_mcp/solution/normalizer.py +23 -0
  64. package/src/qingflow_mcp/solution/requirements_builder.py +536 -0
  65. package/src/qingflow_mcp/solution/run_store.py +244 -0
  66. package/src/qingflow_mcp/solution/spec_models.py +855 -0
  67. package/src/qingflow_mcp/tools/__init__.py +1 -0
  68. package/src/qingflow_mcp/tools/ai_builder_tools.py +3449 -0
  69. package/src/qingflow_mcp/tools/app_tools.py +926 -0
  70. package/src/qingflow_mcp/tools/approval_tools.py +1062 -0
  71. package/src/qingflow_mcp/tools/auth_tools.py +1133 -0
  72. package/src/qingflow_mcp/tools/base.py +281 -0
  73. package/src/qingflow_mcp/tools/code_block_tools.py +777 -0
  74. package/src/qingflow_mcp/tools/custom_button_tools.py +202 -0
  75. package/src/qingflow_mcp/tools/directory_tools.py +675 -0
  76. package/src/qingflow_mcp/tools/feedback_tools.py +238 -0
  77. package/src/qingflow_mcp/tools/file_tools.py +409 -0
  78. package/src/qingflow_mcp/tools/import_tools.py +2223 -0
  79. package/src/qingflow_mcp/tools/navigation_tools.py +210 -0
  80. package/src/qingflow_mcp/tools/package_tools.py +326 -0
  81. package/src/qingflow_mcp/tools/portal_tools.py +158 -0
  82. package/src/qingflow_mcp/tools/qingbi_report_tools.py +374 -0
  83. package/src/qingflow_mcp/tools/record_tools.py +14291 -0
  84. package/src/qingflow_mcp/tools/repository_dev_tools.py +552 -0
  85. package/src/qingflow_mcp/tools/resource_read_tools.py +503 -0
  86. package/src/qingflow_mcp/tools/role_tools.py +112 -0
  87. package/src/qingflow_mcp/tools/solution_tools.py +4054 -0
  88. package/src/qingflow_mcp/tools/task_context_tools.py +2986 -0
  89. package/src/qingflow_mcp/tools/task_tools.py +889 -0
  90. package/src/qingflow_mcp/tools/view_tools.py +335 -0
  91. package/src/qingflow_mcp/tools/workflow_tools.py +376 -0
  92. package/src/qingflow_mcp/tools/workspace_tools.py +266 -0
@@ -0,0 +1,855 @@
1
+ from __future__ import annotations
2
+
3
+ from enum import Enum
4
+ from typing import Any
5
+
6
+ from pydantic import BaseModel, ConfigDict, Field, model_validator
7
+
8
+
9
+ class StrictModel(BaseModel):
10
+ model_config = ConfigDict(extra="forbid", populate_by_name=True)
11
+
12
+
13
+ class EntityKind(str, Enum):
14
+ master = "master"
15
+ transaction = "transaction"
16
+ activity = "activity"
17
+ bridge = "bridge"
18
+
19
+
20
+ class FieldType(str, Enum):
21
+ text = "text"
22
+ long_text = "long_text"
23
+ number = "number"
24
+ amount = "amount"
25
+ date = "date"
26
+ datetime = "datetime"
27
+ member = "member"
28
+ department = "department"
29
+ single_select = "single_select"
30
+ multi_select = "multi_select"
31
+ phone = "phone"
32
+ email = "email"
33
+ address = "address"
34
+ attachment = "attachment"
35
+ boolean = "boolean"
36
+ q_linker = "q_linker"
37
+ code_block = "code_block"
38
+ relation = "relation"
39
+ subtable = "subtable"
40
+
41
+
42
+ class WorkflowNodeType(str, Enum):
43
+ start = "start"
44
+ branch = "branch"
45
+ audit = "audit"
46
+ fill = "fill"
47
+ copy = "copy"
48
+ webhook = "webhook"
49
+ qsource = "qsource"
50
+ condition = "condition"
51
+
52
+
53
+ class ViewType(str, Enum):
54
+ table = "table"
55
+ card = "card"
56
+ board = "board"
57
+ gantt = "gantt"
58
+ hierarchy = "hierarchy"
59
+
60
+
61
+ class ChartType(str, Enum):
62
+ bar = "bar"
63
+ line = "line"
64
+ pie = "pie"
65
+ summary = "summary"
66
+ data = "data"
67
+ target = "target"
68
+ funnel = "funnel"
69
+
70
+
71
+ class RequirementPriority(str, Enum):
72
+ must = "must"
73
+ should = "should"
74
+ could = "could"
75
+
76
+
77
+ class MetricAggregate(str, Enum):
78
+ count = "count"
79
+ sum = "sum"
80
+ avg = "avg"
81
+ distinct_count = "distinct_count"
82
+
83
+
84
+ class PortalSourceType(str, Enum):
85
+ chart = "chart"
86
+ grid = "grid"
87
+ view = "view"
88
+ filter = "filter"
89
+ text = "text"
90
+ link = "link"
91
+
92
+
93
+ class NavigationTargetType(str, Enum):
94
+ package = "package"
95
+ app = "app"
96
+ chart = "chart"
97
+ portal = "portal"
98
+ view = "view"
99
+ custom_url = "custom_url"
100
+
101
+
102
+ class PackageSpec(StrictModel):
103
+ enabled: bool = True
104
+ name: str | None = None
105
+ icon: str | None = None
106
+ color: str | None = None
107
+ ordinal: int = 1
108
+
109
+
110
+ class BusinessContextSpec(StrictModel):
111
+ industry: str | None = None
112
+ subdomain: str | None = None
113
+ scenario: str | None = None
114
+ target_users: list[str] = Field(default_factory=list)
115
+ core_goals: list[str] = Field(default_factory=list)
116
+ terminology: dict[str, str] = Field(default_factory=dict)
117
+ non_goals: list[str] = Field(default_factory=list)
118
+
119
+
120
+ class FieldSpec(StrictModel):
121
+ field_id: str
122
+ label: str
123
+ type: FieldType
124
+ required: bool = False
125
+ unique: bool = False
126
+ description: str | None = None
127
+ options: list[str] = Field(default_factory=list)
128
+ target_entity_id: str | None = None
129
+ target_field_id: str | None = None
130
+ subfields: list["FieldSpec"] = Field(default_factory=list)
131
+ config: dict[str, Any] = Field(default_factory=dict)
132
+
133
+ @model_validator(mode="after")
134
+ def validate_field_shape(self) -> "FieldSpec":
135
+ if self.type == FieldType.relation and not self.target_entity_id:
136
+ raise ValueError("relation field must declare target_entity_id")
137
+ if self.type != FieldType.relation and self.target_entity_id:
138
+ raise ValueError("target_entity_id is only allowed for relation fields")
139
+ if self.type == FieldType.subtable and not self.subfields:
140
+ raise ValueError("subtable field must declare subfields")
141
+ if self.type != FieldType.subtable and self.subfields:
142
+ raise ValueError("subfields are only allowed for subtable fields")
143
+ return self
144
+
145
+
146
+ class FormLayoutRowSpec(StrictModel):
147
+ field_ids: list[str] = Field(default_factory=list)
148
+ config: dict[str, Any] = Field(default_factory=dict)
149
+
150
+ @model_validator(mode="after")
151
+ def validate_row(self) -> "FormLayoutRowSpec":
152
+ if not self.field_ids:
153
+ raise ValueError("form layout row must include at least one field_id")
154
+ return self
155
+
156
+
157
+ class FormLayoutSectionSpec(StrictModel):
158
+ section_id: str
159
+ title: str
160
+ rows: list[FormLayoutRowSpec] = Field(default_factory=list)
161
+ config: dict[str, Any] = Field(default_factory=dict)
162
+
163
+ @model_validator(mode="after")
164
+ def validate_section(self) -> "FormLayoutSectionSpec":
165
+ if not self.rows:
166
+ raise ValueError("form layout section must include at least one row")
167
+ return self
168
+
169
+
170
+ class FormLayoutSpec(StrictModel):
171
+ rows: list[FormLayoutRowSpec] = Field(default_factory=list)
172
+ sections: list[FormLayoutSectionSpec] = Field(default_factory=list)
173
+
174
+ @model_validator(mode="after")
175
+ def validate_layout(self) -> "FormLayoutSpec":
176
+ if not self.rows and not self.sections:
177
+ raise ValueError("form_layout must include rows or sections")
178
+ return self
179
+
180
+
181
+ class RelationSpec(StrictModel):
182
+ relation_id: str
183
+ field_id: str
184
+ target_entity_id: str
185
+ target_field_id: str | None = None
186
+ relation_type: str = "many_to_one"
187
+ config: dict[str, Any] = Field(default_factory=dict)
188
+
189
+
190
+ class LifecycleStageSpec(StrictModel):
191
+ stage_id: str
192
+ name: str
193
+ terminal: bool = False
194
+ color: str | None = None
195
+ probability: float | None = None
196
+
197
+
198
+ class WorkflowNodeSpec(StrictModel):
199
+ node_id: str
200
+ name: str
201
+ node_type: WorkflowNodeType
202
+ parent_node_id: str | None = None
203
+ branch_parent_id: str | None = None
204
+ branch_index: int | None = None
205
+ assignees: dict[str, Any] = Field(default_factory=dict)
206
+ config: dict[str, Any] = Field(default_factory=dict)
207
+
208
+ @model_validator(mode="after")
209
+ def validate_branch_shape(self) -> "WorkflowNodeSpec":
210
+ if self.branch_index is not None and self.branch_index <= 0:
211
+ raise ValueError("branch_index must be positive")
212
+ if self.branch_parent_id and self.branch_index is None:
213
+ raise ValueError("branch_index is required when branch_parent_id is set")
214
+ if self.branch_index is not None and not self.branch_parent_id:
215
+ raise ValueError("branch_parent_id is required when branch_index is set")
216
+ if self.node_type == WorkflowNodeType.branch and self.branch_parent_id:
217
+ raise ValueError("branch node cannot declare branch_parent_id")
218
+ if self.node_type == WorkflowNodeType.branch and self.branch_index is not None:
219
+ raise ValueError("branch node cannot declare branch_index")
220
+ return self
221
+
222
+
223
+ class WorkflowSpec(StrictModel):
224
+ enabled: bool = True
225
+ global_settings: dict[str, Any] = Field(default_factory=dict)
226
+ nodes: list[WorkflowNodeSpec] = Field(default_factory=list)
227
+
228
+
229
+ class RoleEntityScopeSpec(StrictModel):
230
+ entity_id: str
231
+ can_create: bool = True
232
+ can_edit: bool = True
233
+ can_delete: bool = False
234
+ can_approve: bool = False
235
+ visible_field_ids: list[str] = Field(default_factory=list)
236
+ config: dict[str, Any] = Field(default_factory=dict)
237
+
238
+
239
+ class RoleSpec(StrictModel):
240
+ role_id: str
241
+ name: str
242
+ description: str | None = None
243
+ icon: str | None = None
244
+ users: list[int] = Field(default_factory=list)
245
+ entity_scopes: list[RoleEntityScopeSpec] = Field(default_factory=list)
246
+ config: dict[str, Any] = Field(default_factory=dict)
247
+
248
+
249
+ class RequirementSpec(StrictModel):
250
+ requirement_id: str
251
+ title: str
252
+ summary: str | None = None
253
+ priority: RequirementPriority = RequirementPriority.must
254
+ entity_ids: list[str] = Field(default_factory=list)
255
+ acceptance_criteria: list[str] = Field(default_factory=list)
256
+ tags: list[str] = Field(default_factory=list)
257
+ config: dict[str, Any] = Field(default_factory=dict)
258
+
259
+
260
+ class ViewSpec(StrictModel):
261
+ view_id: str
262
+ name: str
263
+ type: ViewType
264
+ field_ids: list[str] = Field(default_factory=list)
265
+ group_by_field_id: str | None = None
266
+ sort: list[dict[str, Any]] = Field(default_factory=list)
267
+ filters: list[dict[str, Any]] = Field(default_factory=list)
268
+ column_widths: dict[str, int] = Field(default_factory=dict)
269
+ member_config: dict[str, Any] = Field(default_factory=dict)
270
+ apply_config: dict[str, Any] = Field(default_factory=dict)
271
+ config: dict[str, Any] = Field(default_factory=dict)
272
+ being_default: bool = False
273
+
274
+
275
+ class ChartSpec(StrictModel):
276
+ chart_id: str
277
+ name: str
278
+ chart_type: ChartType
279
+ dimension_field_ids: list[str] = Field(default_factory=list)
280
+ indicator_field_ids: list[str] = Field(default_factory=list)
281
+ filters: list[dict[str, Any]] = Field(default_factory=list)
282
+ question_config: list[dict[str, Any]] = Field(default_factory=list)
283
+ user_config: list[dict[str, Any]] = Field(default_factory=list)
284
+ config: dict[str, Any] = Field(default_factory=dict)
285
+
286
+
287
+ class SampleRecordSpec(StrictModel):
288
+ record_id: str | None = None
289
+ values: dict[str, Any] = Field(default_factory=dict)
290
+ submit_type: int = 1
291
+
292
+ @model_validator(mode="after")
293
+ def validate_values(self) -> "SampleRecordSpec":
294
+ if not self.values:
295
+ raise ValueError("sample record must include values")
296
+ if self.submit_type not in (0, 1):
297
+ raise ValueError("sample record submit_type must be 0 or 1")
298
+ return self
299
+
300
+
301
+ class MetricSpec(StrictModel):
302
+ metric_id: str
303
+ name: str
304
+ entity_id: str
305
+ aggregate: MetricAggregate = MetricAggregate.count
306
+ field_id: str | None = None
307
+ group_by_field_id: str | None = None
308
+ time_field_id: str | None = None
309
+ visualization_preference: ChartType | None = None
310
+ target_value: float | None = None
311
+ description: str | None = None
312
+ config: dict[str, Any] = Field(default_factory=dict)
313
+
314
+
315
+ class PortalSectionSpec(StrictModel):
316
+ section_id: str
317
+ title: str
318
+ source_type: PortalSourceType
319
+ entity_id: str | None = None
320
+ chart_id: str | None = None
321
+ view_id: str | None = None
322
+ text: str | None = None
323
+ url: str | None = None
324
+ config: dict[str, Any] = Field(default_factory=dict)
325
+
326
+
327
+ class PortalSpec(StrictModel):
328
+ enabled: bool = True
329
+ name: str | None = None
330
+ icon: str | None = None
331
+ color: str | None = None
332
+ sections: list[PortalSectionSpec] = Field(default_factory=list)
333
+ config: dict[str, Any] = Field(default_factory=dict)
334
+
335
+
336
+ class NavigationItemSpec(StrictModel):
337
+ item_id: str
338
+ title: str
339
+ target_type: NavigationTargetType
340
+ entity_id: str | None = None
341
+ chart_id: str | None = None
342
+ view_id: str | None = None
343
+ url: str | None = None
344
+ icon: str | None = None
345
+ visible: bool = True
346
+ children: list["NavigationItemSpec"] = Field(default_factory=list)
347
+ config: dict[str, Any] = Field(default_factory=dict)
348
+
349
+
350
+ class NavigationSpec(StrictModel):
351
+ enabled: bool = True
352
+ items: list[NavigationItemSpec] = Field(default_factory=list)
353
+
354
+
355
+ class EntitySpec(StrictModel):
356
+ entity_id: str
357
+ display_name: str
358
+ plural_name: str | None = None
359
+ kind: EntityKind
360
+ aliases: list[str] = Field(default_factory=list)
361
+ business_purpose: str | None = None
362
+ description: str | None = None
363
+ fields: list[FieldSpec] = Field(default_factory=list)
364
+ form_layout: FormLayoutSpec | None = None
365
+ relations: list[RelationSpec] = Field(default_factory=list)
366
+ workflow: WorkflowSpec | None = None
367
+ views: list[ViewSpec] = Field(default_factory=list)
368
+ charts: list[ChartSpec] = Field(default_factory=list)
369
+ sample_records: list[SampleRecordSpec] = Field(default_factory=list)
370
+ title_field_id: str | None = None
371
+ status_field_id: str | None = None
372
+ owner_field_id: str | None = None
373
+ start_field_id: str | None = None
374
+ end_field_id: str | None = None
375
+ lifecycle_stages: list[LifecycleStageSpec] = Field(default_factory=list)
376
+ acceptance_criteria: list[str] = Field(default_factory=list)
377
+ portal_exposure: bool = True
378
+ navigation_exposure: bool = True
379
+ icon: str | None = None
380
+ color: str | None = None
381
+ ordinal: int | None = None
382
+ metadata: dict[str, Any] = Field(default_factory=dict)
383
+
384
+ @model_validator(mode="after")
385
+ def validate_entity_uniqueness(self) -> "EntitySpec":
386
+ field_ids = [field.field_id for field in self.fields]
387
+ if len(field_ids) != len(set(field_ids)):
388
+ raise ValueError(f"entity '{self.entity_id}' has duplicate field_id values")
389
+ field_labels = [field.label for field in self.fields]
390
+ if len(field_labels) != len(set(field_labels)):
391
+ raise ValueError(f"entity '{self.entity_id}' has duplicate field labels")
392
+ relation_ids = [relation.relation_id for relation in self.relations]
393
+ if len(relation_ids) != len(set(relation_ids)):
394
+ raise ValueError(f"entity '{self.entity_id}' has duplicate relation_id values")
395
+ view_ids = [view.view_id for view in self.views]
396
+ if len(view_ids) != len(set(view_ids)):
397
+ raise ValueError(f"entity '{self.entity_id}' has duplicate view_id values")
398
+ chart_ids = [chart.chart_id for chart in self.charts]
399
+ if len(chart_ids) != len(set(chart_ids)):
400
+ raise ValueError(f"entity '{self.entity_id}' has duplicate chart_id values")
401
+ node_ids = [node.node_id for node in self.workflow.nodes] if self.workflow else []
402
+ if len(node_ids) != len(set(node_ids)):
403
+ raise ValueError(f"entity '{self.entity_id}' has duplicate workflow node_id values")
404
+ stage_ids = [stage.stage_id for stage in self.lifecycle_stages]
405
+ if len(stage_ids) != len(set(stage_ids)):
406
+ raise ValueError(f"entity '{self.entity_id}' has duplicate lifecycle stage_id values")
407
+ field_map = {field.field_id: field for field in self.fields}
408
+ for index, sample_record in enumerate(self.sample_records, start=1):
409
+ record_name = sample_record.record_id or f"sample#{index}"
410
+ for field_id, raw_value in sample_record.values.items():
411
+ field = field_map.get(field_id)
412
+ if field is None:
413
+ raise ValueError(f"entity '{self.entity_id}' sample record '{record_name}' references unknown field '{field_id}'")
414
+ _validate_sample_value(self.entity_id, record_name, field, raw_value)
415
+ return self
416
+
417
+
418
+ class PublishPolicy(StrictModel):
419
+ apps: bool = True
420
+ portal: bool = True
421
+ navigation: bool = True
422
+
423
+
424
+ class Preferences(StrictModel):
425
+ multi_app: bool | None = None
426
+ create_package: bool = True
427
+ create_portal: bool = True
428
+ create_navigation: bool = True
429
+ naming_style: str = "title"
430
+
431
+
432
+ class SolutionSpec(StrictModel):
433
+ solution_name: str
434
+ summary: str | None = None
435
+ business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
436
+ package: PackageSpec = Field(default_factory=PackageSpec)
437
+ entities: list[EntitySpec]
438
+ roles: list[RoleSpec] = Field(default_factory=list)
439
+ requirements: list[RequirementSpec] = Field(default_factory=list)
440
+ success_metrics: list[MetricSpec] = Field(default_factory=list)
441
+ portal: PortalSpec = Field(default_factory=PortalSpec)
442
+ navigation: NavigationSpec = Field(default_factory=NavigationSpec)
443
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
444
+ preferences: Preferences = Field(default_factory=Preferences)
445
+ assumptions: list[str] = Field(default_factory=list)
446
+ constraints: list[str] = Field(default_factory=list)
447
+ metadata: dict[str, Any] = Field(default_factory=dict)
448
+
449
+ @model_validator(mode="after")
450
+ def validate_cross_references(self) -> "SolutionSpec":
451
+ entity_map = {entity.entity_id: entity for entity in self.entities}
452
+ role_ids = {role.role_id for role in self.roles}
453
+ if len(entity_map) != len(self.entities):
454
+ raise ValueError("duplicate entity_id values are not allowed")
455
+ for entity in self.entities:
456
+ field_ids = self._effective_field_ids(entity)
457
+ title_field_id = entity.title_field_id or "title"
458
+ status_field_id = entity.status_field_id or "status"
459
+ if entity.relations:
460
+ raise ValueError(
461
+ f"entity '{entity.entity_id}' uses deprecated data relations; use relation fields (reference fields) instead"
462
+ )
463
+ if entity.kind in (EntityKind.master, EntityKind.transaction) and title_field_id not in field_ids:
464
+ raise ValueError(
465
+ f"entity '{entity.entity_id}' must declare title field '{title_field_id}' explicitly for AI-authored DSL"
466
+ )
467
+ if ((entity.workflow and entity.workflow.enabled) or entity.lifecycle_stages) and status_field_id not in field_ids:
468
+ raise ValueError(
469
+ f"entity '{entity.entity_id}' must declare status field '{status_field_id}' explicitly for AI-authored DSL"
470
+ )
471
+ for field in entity.fields:
472
+ if field.type == FieldType.relation and field.target_entity_id not in entity_map:
473
+ raise ValueError(f"entity '{entity.entity_id}' relation field '{field.field_id}' targets unknown entity '{field.target_entity_id}'")
474
+ if field.type == FieldType.relation and field.target_entity_id in entity_map:
475
+ target_entity = entity_map[field.target_entity_id]
476
+ target_field_ids = self._effective_field_ids(target_entity)
477
+ target_field_id = field.target_field_id or target_entity.title_field_id or "title"
478
+ if target_field_id not in target_field_ids:
479
+ raise ValueError(
480
+ f"entity '{entity.entity_id}' relation field '{field.field_id}' targets unknown field '{target_field_id}' on entity '{field.target_entity_id}'"
481
+ )
482
+ if field.type == FieldType.subtable:
483
+ sub_ids = [subfield.field_id for subfield in field.subfields]
484
+ if len(sub_ids) != len(set(sub_ids)):
485
+ raise ValueError(f"entity '{entity.entity_id}' subtable field '{field.field_id}' has duplicate subfield ids")
486
+ self._validate_special_field_ref(entity.entity_id, "title_field_id", entity.title_field_id, field_ids)
487
+ self._validate_special_field_ref(entity.entity_id, "status_field_id", entity.status_field_id, field_ids)
488
+ self._validate_special_field_ref(entity.entity_id, "owner_field_id", entity.owner_field_id, field_ids)
489
+ self._validate_special_field_ref(entity.entity_id, "start_field_id", entity.start_field_id, field_ids)
490
+ self._validate_special_field_ref(entity.entity_id, "end_field_id", entity.end_field_id, field_ids)
491
+ for relation in entity.relations:
492
+ if relation.field_id not in field_ids:
493
+ raise ValueError(f"entity '{entity.entity_id}' relation '{relation.relation_id}' references unknown field '{relation.field_id}'")
494
+ if relation.target_entity_id not in entity_map:
495
+ raise ValueError(f"entity '{entity.entity_id}' relation '{relation.relation_id}' targets unknown entity '{relation.target_entity_id}'")
496
+ for view in entity.views:
497
+ self._validate_field_refs(entity.entity_id, view.view_id, view.field_ids, field_ids, "view")
498
+ if view.group_by_field_id and view.group_by_field_id not in field_ids:
499
+ raise ValueError(f"entity '{entity.entity_id}' view '{view.view_id}' references unknown field '{view.group_by_field_id}'")
500
+ for field_id in view.column_widths:
501
+ if field_id not in field_ids:
502
+ raise ValueError(f"entity '{entity.entity_id}' view '{view.view_id}' references unknown field '{field_id}'")
503
+ if entity.form_layout:
504
+ layout_field_ids: list[str] = []
505
+ for row in entity.form_layout.rows:
506
+ self._validate_field_refs(entity.entity_id, "form_layout", row.field_ids, field_ids, "form_layout")
507
+ layout_field_ids.extend(row.field_ids)
508
+ for section in entity.form_layout.sections:
509
+ for row in section.rows:
510
+ self._validate_field_refs(entity.entity_id, f"form_layout.{section.section_id}", row.field_ids, field_ids, "form_layout")
511
+ layout_field_ids.extend(row.field_ids)
512
+ duplicated_layout_fields = {field_id for field_id in layout_field_ids if layout_field_ids.count(field_id) > 1}
513
+ if duplicated_layout_fields:
514
+ duplicates = ", ".join(sorted(duplicated_layout_fields))
515
+ raise ValueError(f"entity '{entity.entity_id}' form_layout references duplicated fields: {duplicates}")
516
+ for chart in entity.charts:
517
+ self._validate_field_refs(entity.entity_id, chart.chart_id, chart.dimension_field_ids, field_ids, "chart")
518
+ self._validate_field_refs(entity.entity_id, chart.chart_id, chart.indicator_field_ids, field_ids, "chart")
519
+ if entity.workflow:
520
+ node_map = {node.node_id: node for node in entity.workflow.nodes}
521
+ node_ids = set(node_map)
522
+ for node in entity.workflow.nodes:
523
+ if node.parent_node_id and node.parent_node_id not in node_ids:
524
+ raise ValueError(
525
+ f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown parent '{node.parent_node_id}'"
526
+ )
527
+ if node.branch_parent_id and node.branch_parent_id not in node_ids:
528
+ raise ValueError(
529
+ f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown branch parent '{node.branch_parent_id}'"
530
+ )
531
+ if node.branch_parent_id:
532
+ branch_parent = node_map[node.branch_parent_id]
533
+ if branch_parent.node_type != WorkflowNodeType.branch:
534
+ raise ValueError(
535
+ f"entity '{entity.entity_id}' workflow node '{node.node_id}' must reference a branch node in branch_parent_id"
536
+ )
537
+ for role_ref in node.assignees.get("role_refs", []):
538
+ if role_ref not in role_ids:
539
+ raise ValueError(
540
+ f"entity '{entity.entity_id}' workflow node '{node.node_id}' references unknown role '{role_ref}'"
541
+ )
542
+ for role in self.roles:
543
+ for scope in role.entity_scopes:
544
+ if scope.entity_id not in entity_map:
545
+ raise ValueError(f"role '{role.role_id}' references unknown entity '{scope.entity_id}'")
546
+ valid_field_ids = self._effective_field_ids(entity_map[scope.entity_id])
547
+ self._validate_field_refs(scope.entity_id, role.role_id, scope.visible_field_ids, valid_field_ids, "role")
548
+ for requirement in self.requirements:
549
+ for entity_id in requirement.entity_ids:
550
+ if entity_id not in entity_map:
551
+ raise ValueError(f"requirement '{requirement.requirement_id}' references unknown entity '{entity_id}'")
552
+ for metric in self.success_metrics:
553
+ if metric.entity_id not in entity_map:
554
+ raise ValueError(f"metric '{metric.metric_id}' references unknown entity '{metric.entity_id}'")
555
+ valid_field_ids = self._effective_field_ids(entity_map[metric.entity_id])
556
+ self._validate_special_field_ref(metric.entity_id, "field_id", metric.field_id, valid_field_ids)
557
+ self._validate_special_field_ref(metric.entity_id, "group_by_field_id", metric.group_by_field_id, valid_field_ids)
558
+ self._validate_special_field_ref(metric.entity_id, "time_field_id", metric.time_field_id, valid_field_ids)
559
+ for section in self.portal.sections:
560
+ self._validate_portal_ref(section, entity_map)
561
+ for item in self.navigation.items:
562
+ self._validate_navigation_ref(item, entity_map)
563
+ return self
564
+
565
+ @staticmethod
566
+ def _validate_field_refs(entity_id: str, resource_id: str, refs: list[str], valid_field_ids: set[str], resource_type: str) -> None:
567
+ for field_id in refs:
568
+ if field_id not in valid_field_ids:
569
+ raise ValueError(f"entity '{entity_id}' {resource_type} '{resource_id}' references unknown field '{field_id}'")
570
+
571
+ @staticmethod
572
+ def _validate_special_field_ref(entity_id: str, field_name: str, ref: str | None, valid_field_ids: set[str]) -> None:
573
+ if ref is not None and ref not in valid_field_ids:
574
+ raise ValueError(f"entity '{entity_id}' references unknown field '{ref}' in '{field_name}'")
575
+
576
+ @staticmethod
577
+ def _effective_field_ids(entity: EntitySpec) -> set[str]:
578
+ return {field.field_id for field in entity.fields}
579
+
580
+ @classmethod
581
+ def _validate_portal_ref(cls, section: PortalSectionSpec, entity_map: dict[str, EntitySpec]) -> None:
582
+ if section.entity_id and section.entity_id not in entity_map:
583
+ raise ValueError(f"portal section '{section.section_id}' references unknown entity '{section.entity_id}'")
584
+ if section.chart_id and section.entity_id:
585
+ chart_ids = {chart.chart_id for chart in entity_map[section.entity_id].charts}
586
+ if section.chart_id not in chart_ids:
587
+ raise ValueError(f"portal section '{section.section_id}' references unknown chart '{section.chart_id}'")
588
+ if section.view_id and section.entity_id:
589
+ view_ids = {view.view_id for view in entity_map[section.entity_id].views}
590
+ if section.view_id not in view_ids:
591
+ raise ValueError(f"portal section '{section.section_id}' references unknown view '{section.view_id}'")
592
+
593
+ @classmethod
594
+ def _validate_navigation_ref(cls, item: NavigationItemSpec, entity_map: dict[str, EntitySpec]) -> None:
595
+ if item.entity_id and item.entity_id not in entity_map:
596
+ raise ValueError(f"navigation item '{item.item_id}' references unknown entity '{item.entity_id}'")
597
+ if item.chart_id and item.entity_id:
598
+ chart_ids = {chart.chart_id for chart in entity_map[item.entity_id].charts}
599
+ if item.chart_id not in chart_ids:
600
+ raise ValueError(f"navigation item '{item.item_id}' references unknown chart '{item.chart_id}'")
601
+ if item.view_id and item.entity_id:
602
+ view_ids = {view.view_id for view in entity_map[item.entity_id].views}
603
+ if item.view_id not in view_ids:
604
+ raise ValueError(f"navigation item '{item.item_id}' references unknown view '{item.view_id}'")
605
+ for child in item.children:
606
+ cls._validate_navigation_ref(child, entity_map)
607
+
608
+
609
+ class AppFlowBuildSpec(StrictModel):
610
+ solution_name: str
611
+ summary: str | None = None
612
+ business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
613
+ package: PackageSpec = Field(default_factory=PackageSpec)
614
+ entities: list[EntitySpec]
615
+ roles: list[RoleSpec] = Field(default_factory=list)
616
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
617
+ preferences: Preferences = Field(default_factory=Preferences)
618
+ assumptions: list[str] = Field(default_factory=list)
619
+ constraints: list[str] = Field(default_factory=list)
620
+ metadata: dict[str, Any] = Field(default_factory=dict)
621
+
622
+ @model_validator(mode="after")
623
+ def validate_entities(self) -> "AppFlowBuildSpec":
624
+ if not self.entities:
625
+ raise ValueError("entities must be a non-empty list")
626
+ return self
627
+
628
+
629
+ class AppEntityBuildSpec(StrictModel):
630
+ entity_id: str
631
+ display_name: str
632
+ plural_name: str | None = None
633
+ kind: EntityKind
634
+ aliases: list[str] = Field(default_factory=list)
635
+ business_purpose: str | None = None
636
+ description: str | None = None
637
+ fields: list[FieldSpec] = Field(default_factory=list)
638
+ form_layout: FormLayoutSpec | None = None
639
+ sample_records: list[SampleRecordSpec] = Field(default_factory=list)
640
+ title_field_id: str | None = None
641
+ status_field_id: str | None = None
642
+ owner_field_id: str | None = None
643
+ start_field_id: str | None = None
644
+ end_field_id: str | None = None
645
+ acceptance_criteria: list[str] = Field(default_factory=list)
646
+ portal_exposure: bool = True
647
+ navigation_exposure: bool = True
648
+ icon: str | None = None
649
+ color: str | None = None
650
+ ordinal: int | None = None
651
+ metadata: dict[str, Any] = Field(default_factory=dict)
652
+
653
+ @model_validator(mode="after")
654
+ def validate_entity_shape(self) -> "AppEntityBuildSpec":
655
+ if not self.fields:
656
+ raise ValueError(f"entity '{self.entity_id}' must declare fields")
657
+ field_ids = [field.field_id for field in self.fields]
658
+ if len(field_ids) != len(set(field_ids)):
659
+ raise ValueError(f"entity '{self.entity_id}' has duplicate field_id values")
660
+ field_labels = [field.label for field in self.fields]
661
+ if len(field_labels) != len(set(field_labels)):
662
+ raise ValueError(f"entity '{self.entity_id}' has duplicate field labels")
663
+ field_map = {field.field_id: field for field in self.fields}
664
+ for index, sample_record in enumerate(self.sample_records, start=1):
665
+ record_name = sample_record.record_id or f"sample#{index}"
666
+ for field_id, raw_value in sample_record.values.items():
667
+ field = field_map.get(field_id)
668
+ if field is None:
669
+ raise ValueError(f"entity '{self.entity_id}' sample record '{record_name}' references unknown field '{field_id}'")
670
+ _validate_sample_value(self.entity_id, record_name, field, raw_value)
671
+ return self
672
+
673
+
674
+ class AppBuildSpec(StrictModel):
675
+ solution_name: str
676
+ summary: str | None = None
677
+ business_context: BusinessContextSpec = Field(default_factory=BusinessContextSpec)
678
+ package: PackageSpec = Field(default_factory=PackageSpec)
679
+ entities: list[AppEntityBuildSpec]
680
+ roles: list[RoleSpec] = Field(default_factory=list)
681
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
682
+ preferences: Preferences = Field(default_factory=Preferences)
683
+ assumptions: list[str] = Field(default_factory=list)
684
+ constraints: list[str] = Field(default_factory=list)
685
+ metadata: dict[str, Any] = Field(default_factory=dict)
686
+
687
+ @model_validator(mode="after")
688
+ def validate_entities(self) -> "AppBuildSpec":
689
+ if not self.entities:
690
+ raise ValueError("entities must be a non-empty list")
691
+ return self
692
+
693
+
694
+ class FlowEntityBuildSpec(StrictModel):
695
+ entity_id: str
696
+ workflow: WorkflowSpec | None = None
697
+ lifecycle_stages: list[LifecycleStageSpec] = Field(default_factory=list)
698
+ status_field_id: str | None = None
699
+ owner_field_id: str | None = None
700
+ start_field_id: str | None = None
701
+ end_field_id: str | None = None
702
+ metadata: dict[str, Any] = Field(default_factory=dict)
703
+
704
+ @model_validator(mode="after")
705
+ def validate_flow_shape(self) -> "FlowEntityBuildSpec":
706
+ if self.workflow is None and not self.lifecycle_stages:
707
+ raise ValueError(f"entity '{self.entity_id}' must declare workflow or lifecycle_stages")
708
+ if self.workflow and self.workflow.enabled and not self.workflow.nodes and not self.lifecycle_stages:
709
+ raise ValueError(f"entity '{self.entity_id}' workflow.nodes must be a non-empty list when workflow.enabled is true")
710
+ return self
711
+
712
+
713
+ class FlowBuildSpec(StrictModel):
714
+ solution_name: str | None = None
715
+ entities: list[FlowEntityBuildSpec]
716
+ roles: list[RoleSpec] = Field(default_factory=list)
717
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
718
+ metadata: dict[str, Any] = Field(default_factory=dict)
719
+
720
+ @model_validator(mode="after")
721
+ def validate_entities(self) -> "FlowBuildSpec":
722
+ if not self.entities:
723
+ raise ValueError("entities must be a non-empty list")
724
+ return self
725
+
726
+
727
+ class EntityViewSpec(StrictModel):
728
+ entity_id: str
729
+ views: list[ViewSpec] = Field(default_factory=list)
730
+
731
+ @model_validator(mode="after")
732
+ def validate_views(self) -> "EntityViewSpec":
733
+ if not self.views:
734
+ raise ValueError("views must be a non-empty list")
735
+ return self
736
+
737
+
738
+ class ViewsBuildSpec(StrictModel):
739
+ solution_name: str | None = None
740
+ entities: list[EntityViewSpec]
741
+ metadata: dict[str, Any] = Field(default_factory=dict)
742
+
743
+ @model_validator(mode="after")
744
+ def validate_entities(self) -> "ViewsBuildSpec":
745
+ if not self.entities:
746
+ raise ValueError("entities must be a non-empty list")
747
+ return self
748
+
749
+
750
+ class EntityAnalyticsSpec(StrictModel):
751
+ entity_id: str
752
+ charts: list[ChartSpec] = Field(default_factory=list)
753
+
754
+ @model_validator(mode="after")
755
+ def validate_charts(self) -> "EntityAnalyticsSpec":
756
+ if not self.charts:
757
+ raise ValueError("charts must be a non-empty list")
758
+ return self
759
+
760
+
761
+ class AnalyticsPortalBuildSpec(StrictModel):
762
+ solution_name: str | None = None
763
+ entities: list[EntityAnalyticsSpec] = Field(default_factory=list)
764
+ portal: PortalSpec = Field(default_factory=PortalSpec)
765
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
766
+ metadata: dict[str, Any] = Field(default_factory=dict)
767
+
768
+ @model_validator(mode="after")
769
+ def validate_payload(self) -> "AnalyticsPortalBuildSpec":
770
+ if not self.entities and not self.portal.sections:
771
+ raise ValueError("analytics build must declare charts or portal sections")
772
+ return self
773
+
774
+
775
+ class NavigationBuildSpec(StrictModel):
776
+ solution_name: str | None = None
777
+ navigation: NavigationSpec = Field(default_factory=NavigationSpec)
778
+ publish_policy: PublishPolicy = Field(default_factory=PublishPolicy)
779
+ metadata: dict[str, Any] = Field(default_factory=dict)
780
+
781
+ @model_validator(mode="after")
782
+ def validate_navigation(self) -> "NavigationBuildSpec":
783
+ if self.navigation.enabled and not self.navigation.items:
784
+ raise ValueError("navigation.items must be a non-empty list when navigation.enabled is true")
785
+ return self
786
+
787
+
788
+ BuildManifest = SolutionSpec
789
+
790
+
791
+ def _validate_sample_value(entity_id: str, record_name: str, field: FieldSpec, raw_value: Any) -> None:
792
+ field_type = field.type
793
+ if raw_value is None:
794
+ return
795
+ if field_type == FieldType.number:
796
+ if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)):
797
+ raise ValueError(
798
+ f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects an integer-compatible number"
799
+ )
800
+ if isinstance(raw_value, float) and not raw_value.is_integer():
801
+ raise ValueError(
802
+ f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' must be an integer-compatible number; use amount for decimals"
803
+ )
804
+ return
805
+ if field_type == FieldType.amount:
806
+ if isinstance(raw_value, bool) or not isinstance(raw_value, (int, float)):
807
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a numeric amount")
808
+ return
809
+ if field_type == FieldType.member:
810
+ if isinstance(raw_value, int) and raw_value > 0:
811
+ return
812
+ if isinstance(raw_value, dict) and isinstance(raw_value.get("id") or raw_value.get("uid"), int):
813
+ return
814
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a positive member id or member object")
815
+ if field_type == FieldType.boolean:
816
+ if not isinstance(raw_value, bool):
817
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a boolean")
818
+ return
819
+ if field_type == FieldType.single_select:
820
+ if not isinstance(raw_value, str):
821
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a string option")
822
+ if field.options and raw_value not in field.options:
823
+ raise ValueError(
824
+ f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' uses unknown option '{raw_value}'"
825
+ )
826
+ return
827
+ if field_type == FieldType.multi_select:
828
+ items = raw_value if isinstance(raw_value, list) else [raw_value]
829
+ if not all(isinstance(item, str) for item in items):
830
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects string options")
831
+ if field.options:
832
+ unknown = [item for item in items if item not in field.options]
833
+ if unknown:
834
+ raise ValueError(
835
+ f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' uses unknown options {unknown}"
836
+ )
837
+ return
838
+ if field_type in {FieldType.date, FieldType.datetime, FieldType.text, FieldType.long_text, FieldType.phone, FieldType.email, FieldType.address}:
839
+ if not isinstance(raw_value, str):
840
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects a string")
841
+ return
842
+ if field_type == FieldType.relation:
843
+ refs = raw_value if isinstance(raw_value, list) else [raw_value]
844
+ if not all(isinstance(item, (str, int)) for item in refs):
845
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects record reference ids")
846
+ return
847
+ if field_type == FieldType.attachment:
848
+ items = raw_value if isinstance(raw_value, list) else [raw_value]
849
+ if not all(isinstance(item, dict) for item in items):
850
+ raise ValueError(f"entity '{entity_id}' sample record '{record_name}' field '{field.field_id}' expects attachment objects")
851
+ return
852
+
853
+
854
+ FieldSpec.model_rebuild()
855
+ NavigationItemSpec.model_rebuild()