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