devagent-ignition 0.1.0__py3-none-any.whl

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 (133) hide show
  1. devagent_hmi.py +471 -0
  2. devagent_ignition-0.1.0.dist-info/METADATA +204 -0
  3. devagent_ignition-0.1.0.dist-info/RECORD +133 -0
  4. devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
  5. devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
  6. devagent_spatial/__init__.py +1 -0
  7. devagent_spatial/_resources/README.md +187 -0
  8. devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
  9. devagent_spatial/_resources/SHA256SUMS.txt +270 -0
  10. devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
  11. devagent_spatial/_resources/assets/agent.js +102 -0
  12. devagent_spatial/_resources/assets/creator.css +247 -0
  13. devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
  14. devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
  15. devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
  16. devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
  17. devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
  18. devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
  19. devagent_spatial/_resources/assets/customer_documents.js +110 -0
  20. devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
  21. devagent_spatial/_resources/assets/facility2d.css +24 -0
  22. devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
  23. devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
  24. devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
  25. devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
  26. devagent_spatial/_resources/assets/fallback_scene.js +17 -0
  27. devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
  28. devagent_spatial/_resources/assets/harness.js +78 -0
  29. devagent_spatial/_resources/assets/live_design_review.js +129 -0
  30. devagent_spatial/_resources/assets/operations_ui.js +305 -0
  31. devagent_spatial/_resources/assets/operator_viewer.html +64 -0
  32. devagent_spatial/_resources/assets/project_package.js +52 -0
  33. devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
  34. devagent_spatial/_resources/assets/runtime_feed.js +30 -0
  35. devagent_spatial/_resources/assets/studio.html +139 -0
  36. devagent_spatial/_resources/assets/style.css +191 -0
  37. devagent_spatial/_resources/assets/test_workspace.html +195 -0
  38. devagent_spatial/_resources/assets/three_scene.js +442 -0
  39. devagent_spatial/_resources/assets/unified_engineer.js +101 -0
  40. devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
  41. devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
  42. devagent_spatial/_resources/assets/viewer.html +45 -0
  43. devagent_spatial/_resources/assets/viewer.js +112 -0
  44. devagent_spatial/_resources/design-system.json +130 -0
  45. devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
  46. devagent_spatial/_resources/examples/cv101_guided.json +272 -0
  47. devagent_spatial/_resources/examples/make_examples.py +36 -0
  48. devagent_spatial/_resources/examples/single_floor.json +444 -0
  49. devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
  50. devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
  51. devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
  52. devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
  53. devagent_spatial/_resources/examples/warehouse.json +708 -0
  54. devagent_spatial/_resources/examples/water.json +288 -0
  55. devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
  56. devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
  57. devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
  58. devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
  59. devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
  60. devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
  61. devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
  62. devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
  63. devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
  64. devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
  65. devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
  66. devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
  67. devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
  68. devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
  69. devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
  70. devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
  71. devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
  72. devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
  73. devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
  74. devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
  75. devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
  76. devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
  77. devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
  78. devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
  79. devagent_spatial/_resources/reference/v8-original/README.md +36 -0
  80. devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
  81. devagent_spatial/_resources/reference/v8-original/model.py +124 -0
  82. devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
  83. devagent_spatial/_resources/reference/v8-original/server.py +100 -0
  84. devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
  85. devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
  86. devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
  87. devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
  88. devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
  89. devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
  90. devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
  91. devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
  92. devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
  93. devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
  94. devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
  95. devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
  96. devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
  97. devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
  98. devagent_spatial/_resources/templates/water_connections.csv +7 -0
  99. devagent_spatial/_resources/templates/water_equipment.csv +10 -0
  100. devagent_spatial/_resources/templates/water_signals.csv +12 -0
  101. devagent_spatial/agent_harness.py +299 -0
  102. devagent_spatial/agent_runtime.py +314 -0
  103. devagent_spatial/contract_opc_fat.py +216 -0
  104. devagent_spatial/contract_opc_source.py +265 -0
  105. devagent_spatial/control_intents.py +38 -0
  106. devagent_spatial/customer_intake.py +235 -0
  107. devagent_spatial/engineering_expertise.py +114 -0
  108. devagent_spatial/generator.py +201 -0
  109. devagent_spatial/ignition_native.py +234 -0
  110. devagent_spatial/ignition_project.py +144 -0
  111. devagent_spatial/live_lab_binding.py +128 -0
  112. devagent_spatial/model.py +297 -0
  113. devagent_spatial/native_reference.py +69 -0
  114. devagent_spatial/opc_snapshot.py +87 -0
  115. devagent_spatial/operations.py +284 -0
  116. devagent_spatial/operations_capture.py +47 -0
  117. devagent_spatial/operations_lab.py +64 -0
  118. devagent_spatial/original_r9.py +169 -0
  119. devagent_spatial/original_v8.py +81 -0
  120. devagent_spatial/preflight.py +140 -0
  121. devagent_spatial/preview_simulation.py +189 -0
  122. devagent_spatial/project_package.py +101 -0
  123. devagent_spatial/qualification.py +152 -0
  124. devagent_spatial/strict_json.py +29 -0
  125. devagent_spatial/studio.py +894 -0
  126. devagent_spatial/studio_workflow.py +277 -0
  127. devagent_spatial/test_reporting.py +87 -0
  128. devagent_spatial/v076_opc_fat.py +180 -0
  129. devagent_spatial/v078_commands.py +277 -0
  130. devagent_spatial/v078_native_commands.py +161 -0
  131. devagent_spatial/v07_fat.py +110 -0
  132. devagent_spatial/v07_native.py +318 -0
  133. devagent_spatial/v083_native.py +349 -0
@@ -0,0 +1,894 @@
1
+ """Loopback-only customer project wizard; no credential storage or PLC writes."""
2
+ from __future__ import annotations
3
+ import csv
4
+ import io
5
+ import json
6
+ import re
7
+ import uuid
8
+ import threading
9
+ import hashlib
10
+ import secrets
11
+ import time
12
+ from pathlib import Path
13
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
14
+ from urllib.parse import urlsplit,unquote
15
+ from .generator import build, slugify, BuildHold, ROOT
16
+ from .model import qualify, clean_project, MAX_EQUIPMENT, MAX_SIGNALS
17
+ from .strict_json import loads as strict_loads
18
+
19
+ LIMIT = 300_000
20
+ STUDIO_HTML = (ROOT/'assets'/'studio.html').read_bytes() if (ROOT/'assets'/'studio.html').exists() else b''
21
+
22
+
23
+ def csv_rows(raw: str, required: tuple[str,...], *, maximum: int) -> list[dict]:
24
+ if not isinstance(raw,str) or len(raw)>100_000:
25
+ raise BuildHold('CSV_TEXT_TOO_LARGE')
26
+ if not raw.strip():return []
27
+ stream=io.StringIO(raw)
28
+ reader=csv.DictReader(stream, strict=True)
29
+ if not reader.fieldnames or not set(required).issubset(reader.fieldnames):
30
+ raise BuildHold('CSV_HEADERS_REQUIRED: '+','.join(required))
31
+ if len(reader.fieldnames)>30 or len(reader.fieldnames)!=len(set(reader.fieldnames)):
32
+ raise BuildHold('CSV_HEADERS_INVALID_OR_DUPLICATE')
33
+ rows=[]
34
+ try:
35
+ for row in reader:
36
+ if len(rows)>=maximum:raise BuildHold('CSV_LIMIT_EXCEEDED')
37
+ if None in row or any(not isinstance(v,str) for v in row.values()):
38
+ raise BuildHold('CSV_EXTRA_UNESCAPED_DELIMITERS')
39
+ if any(v.strip() for v in row.values()):rows.append({k:v.strip() for k,v in row.items()})
40
+ except csv.Error as ex:
41
+ raise BuildHold('CSV_MALFORMED: '+str(ex)) from ex
42
+ return rows
43
+
44
+
45
+ def _kind_from_asset_signals(asset_id: str, roles: set[str]) -> str:
46
+ """Make a conceptual display symbol, never assert discovered hardware identity."""
47
+ family_groups = (
48
+ (set(('photoeye_blocked', 'photoeye_count')), 'photoeye'),
49
+ (set(('scanner_result', 'scanner_barcode', 'scan_count', 'no_read_count')), 'scanner'),
50
+ (set(('vfd_frequency',)), 'vfd'),
51
+ (set(('tank_level',)), 'tank'),
52
+ )
53
+ matches = [kind for family,kind in family_groups if roles & family]
54
+ if len(matches) == 1:
55
+ return matches[0]
56
+ if len(matches) > 1:
57
+ return 'other'
58
+ # These are conventional IDs only. The project explicitly flags them for review.
59
+ for pattern,kind in ((r'^(?:CV|CONV)[0-9]', 'conveyor'),
60
+ (r'^VFD[0-9]', 'vfd'),
61
+ (r'^PUMP[0-9]', 'pump'),
62
+ (r'^SCN[0-9]', 'scanner'),
63
+ (r'^PE[0-9]', 'photoeye')):
64
+ if re.match(pattern,asset_id):
65
+ return kind
66
+ return 'other'
67
+
68
+
69
+ def from_wizard(payload: dict) -> dict:
70
+ """Keep four original steps; accept safe partial inputs without guessing OPC identity."""
71
+ if not isinstance(payload,dict):raise BuildHold('WIZARD_EXPECTED_OBJECT')
72
+ if set(payload)-{'name','industry','layout','palette','description','equipment_csv','connections_csv','signals_csv','requirements'}:
73
+ raise BuildHold('UNRECOGNIZED_WIZARD_FIELD')
74
+ for k in ('name','industry','layout','palette','description','equipment_csv','connections_csv','signals_csv','requirements'):
75
+ if k in payload and not isinstance(payload[k],str):
76
+ raise BuildHold('WIZARD_TEXT_FIELD_INVALID_'+k.upper())
77
+ name=payload.get('name','').strip()
78
+ if not name:raise BuildHold('PROJECT_NAME_REQUIRED')
79
+ industry=payload.get('industry','').strip() or 'Unspecified Industry'
80
+ layout=payload.get('layout','').strip() or 'spatial_overview'
81
+ palette=payload.get('palette','').strip() or 'r9_cyan'
82
+ description=payload.get('description','')
83
+ eqcsv=csv_rows(payload.get('equipment_csv',''),('id',), maximum=MAX_EQUIPMENT)
84
+ if any(set(r)-{'id','name','kind','floor','zone','x','y','z'} for r in eqcsv):raise BuildHold('UNRECOGNIZED_EQUIPMENT_CSV_FIELD')
85
+ conncsv=csv_rows(payload.get('connections_csv',''),('from','to'),maximum=400)
86
+ if any(set(r)-{'from','to'} for r in conncsv):raise BuildHold('UNRECOGNIZED_CONNECTION_CSV_FIELD')
87
+ sigcsv=csv_rows(payload.get('signals_csv',''),('id','asset_id','role','datatype','namespace_uri','node_id'),maximum=MAX_SIGNALS)
88
+ if any(set(r)-{'id','asset_id','role','datatype','namespace_uri','node_id','unit','polarity'} for r in sigcsv):raise BuildHold('UNRECOGNIZED_SIGNAL_CSV_FIELD')
89
+ equipment=[]
90
+ for idx,r in enumerate(eqcsv):
91
+ floor_text=r.get('floor','').strip()
92
+ try:floor=int(floor_text) if floor_text else 1
93
+ except (ValueError,TypeError):raise BuildHold(f'EQUIPMENT_FLOOR_INVALID_ROW_{idx+2}')
94
+ zone = r.get('zone','') or ('FLOOR_UNSPECIFIED_PREVIEW_F1' if not floor_text else '')
95
+ e={'id':r['id'],'name':r.get('name','') or r['id'],
96
+ 'kind':r.get('kind','') or 'other','floor':floor,'zone':zone}
97
+ coord=[r.get(k,'') for k in ('x','y','z')]
98
+ if any(coord):
99
+ if not all(coord):raise BuildHold(f'PARTIAL_COORDINATES_ROW_{idx+2}')
100
+ try:e['position']={k:float(r[k]) for k in ('x','y','z')}
101
+ except ValueError:raise BuildHold(f'INVALID_COORDINATES_ROW_{idx+2}')
102
+ equipment.append(e)
103
+ if not equipment and sigcsv:
104
+ # Candidates, not verified equipment: asset_id comes from supplied mapping,
105
+ # displayed symbol is guessed only when a recognized kind is unambiguous.
106
+ asset_roles={}
107
+ for r in sigcsv:
108
+ asset_roles.setdefault(r['asset_id'],set()).add(r['role'])
109
+ if len(asset_roles)>MAX_EQUIPMENT:
110
+ raise BuildHold('TOO_MANY_ASSETS_DERIVED_FROM_SIGNALS')
111
+ for asset_id,roles in sorted(asset_roles.items()):
112
+ equipment.append({'id':asset_id,'name':asset_id,
113
+ 'kind':_kind_from_asset_signals(asset_id,roles),
114
+ 'floor':1,'zone':'INFERRED_FROM_TAGS_REVIEW_REQUIRED'})
115
+ if not equipment:
116
+ raise BuildHold('INSUFFICIENT_INPUT: supply equipment IDs or fully asset-linked OPC UA signal CSV')
117
+ signals=[]
118
+ for r in sigcsv:
119
+ s={'id':r['id'],'asset_id':r['asset_id'],'role':r['role'], 'datatype':r['datatype'],
120
+ 'unit':r.get('unit',''),'polarity':r.get('polarity',''),
121
+ 'source':{'namespace_uri':r['namespace_uri'],'node_id':r['node_id']},'write_authorized':False}
122
+ signals.append(s)
123
+ requirements=[]
124
+ reqtext=payload.get('requirements','')
125
+ if len(reqtext)>16000:raise BuildHold('INVALID_REQUIREMENTS_TEXT')
126
+ for i,line in enumerate(reqtext.splitlines()):
127
+ line=line.strip()
128
+ if line:requirements.append({'id':f'R{i+1:03d}','text':line})
129
+ return {'schema_version':1,'name':name,'industry':industry,
130
+ 'theme':'devagent_spatial_v1','palette':palette,'layout':layout,'description':description,
131
+ 'equipment':equipment,'connections':[{'from':r['from'],'to':r['to']} for r in conncsv],
132
+ 'signals':signals,'requirements':requirements}
133
+
134
+
135
+ class StudioServer(ThreadingHTTPServer):
136
+ daemon_threads=True
137
+ allow_reuse_address=True
138
+ def __init__(self,address,workspace:Path,vendor_three:Path|None,*,allow_legacy_concept_build:bool=False):
139
+ if address[0]!='127.0.0.1':raise ValueError('LAB_STUDIO_BIND_LOOPBACK_ONLY')
140
+ self.workspace=workspace.expanduser().resolve()
141
+ self.workspace.mkdir(parents=True,exist_ok=True)
142
+ self.vendor_three=vendor_three
143
+ self.allow_legacy_concept_build=bool(allow_legacy_concept_build) # test compatibility only
144
+ self.creator_three_ready=False
145
+ if vendor_three is not None:
146
+ vendor=Path(vendor_three).expanduser().resolve()
147
+ names=('three.module.js','three.core.js','LICENSE-three.txt')
148
+ try:
149
+ self.creator_three_ready=(all((vendor/n).is_file() for n in names)
150
+ and (vendor/'three.module.js').stat().st_size>200_000
151
+ and (vendor/'three.core.js').stat().st_size>500_000
152
+ and "REVISION = '180'" in (vendor/'three.core.js').read_text(encoding='utf8'))
153
+ except (OSError,UnicodeError):self.creator_three_ready=False
154
+ # Studio R2 is a single isolated-lab instance; serialize test imports/stimuli.
155
+ self.lab_lock=threading.Lock()
156
+ self.lab_sessions={}
157
+ # Ephemeral, single-use, AI + deterministic-harness receipts.
158
+ # Store only the approved model-input fingerprint; never an API key or raw proposal.
159
+ self.engineer_review_lock=threading.Lock()
160
+ self.engineer_reviews={}
161
+ self.lab_results={} # In-memory, session-bound lab receipts only. No production claims.
162
+ from .live_lab_binding import LiveLabBridge
163
+ self.lab_live=LiveLabBridge()
164
+ from .preview_simulation import PreviewSimulator
165
+ self.preview_sim=PreviewSimulator()
166
+ # Source-pinned V8 demo state is isolated from every generated customer
167
+ # project and never opens a PLC, OPC UA, or Ignition connection.
168
+ from .original_v8 import OriginalV8Demo
169
+ self.original_v8=OriginalV8Demo()
170
+ from .original_r9 import OriginalR9Demo
171
+ self.original_r9=OriginalR9Demo()
172
+ self.original_r9_token=secrets.token_urlsafe(36)
173
+ self.lab_port=8770
174
+ super().__init__(address,StudioHandler)
175
+
176
+
177
+ class StudioHandler(BaseHTTPRequestHandler):
178
+ server_version='DevAgentSpatialStudio/0.6-lab-feed-candidate'
179
+ def headers(self,typ,nbytes):
180
+ self.send_response(200)
181
+ self.send_header('Content-Type',typ)
182
+ self.send_header('Content-Length',str(nbytes))
183
+ self.send_header('X-Content-Type-Options','nosniff')
184
+ self.send_header('Cache-Control','no-store')
185
+ self.send_header('Referrer-Policy','no-referrer')
186
+ self.send_header('Cross-Origin-Resource-Policy','same-origin')
187
+ self.send_header('X-Frame-Options','DENY')
188
+ self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
189
+ self.end_headers()
190
+ def output(self,code,data,typ='application/json; charset=utf-8',*,allow_same_origin_frame=False):
191
+ # Only generated operator viewer HTML may be embedded by this SAME origin
192
+ # Creator Design Review. All other responses keep the deny-by-default policy.
193
+ raw=json.dumps(data,ensure_ascii=False).encode() if not isinstance(data,bytes) else data
194
+ self.send_response(code);self.send_header('Content-Type',typ);self.send_header('Content-Length',str(len(raw)))
195
+ self.send_header('X-Content-Type-Options','nosniff');self.send_header('Cache-Control','no-store')
196
+ self.send_header('Referrer-Policy','no-referrer')
197
+ self.send_header('Cross-Origin-Resource-Policy','same-origin')
198
+ self.send_header('X-Frame-Options','SAMEORIGIN' if allow_same_origin_frame else 'DENY')
199
+ ancestors="'self'" if allow_same_origin_frame else "'none'"
200
+ self.send_header('Content-Security-Policy',"default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; object-src 'none'; base-uri 'none'; frame-ancestors "+ancestors)
201
+ self.end_headers()
202
+ self.wfile.write(raw)
203
+ def same_host(self):
204
+ host=self.headers.get('Host','')
205
+ return host in {f'127.0.0.1:{self.server.server_port}',f'localhost:{self.server.server_port}'}
206
+ def do_GET(self):
207
+ if not self.same_host():return self.output(403,{'error':'HOST_NOT_ALLOWED'})
208
+ path=unquote(urlsplit(self.path).path)
209
+ if path in ('/','/index.html'):
210
+ return self.output(200,(ROOT/'assets'/'studio.html').read_bytes(),'text/html; charset=utf-8')
211
+ if path=='/api/original-r9/info':
212
+ return self.output(200,self.server.original_r9.info())
213
+ if path=='/api/original-r9/lab/state':
214
+ return self.output(200,self.server.original_r9.snapshot())
215
+ if path=='/api/original-r9/lab/session':
216
+ return self.output(200,{'enabled':True,
217
+ 'csrf_token':self.server.original_r9_token,
218
+ 'scope':'ORIGINAL_R9_ISOLATED_SYNTHETIC_ONLY'})
219
+ if path=='/api/original-r9/engine/status':
220
+ return self.output(200,{'offline_ready':True,'three_vendor':'SNAPSHOT_PINNED',
221
+ 'production':'HOLD'})
222
+ if path=='/api/original-r9/bridge/facility/state':
223
+ return self.output(404,{'error':'NO_REAL_V1C_CONNECTION_IN_CREATOR_DEMO',
224
+ 'source':'UNKNOWN_NOT_MAPPED'})
225
+ r9_asset=re.fullmatch(r'/original-r9/(index\.html|styles\.css|app\.mjs|model\.mjs|state_adapter\.mjs|lab_panel\.mjs|vendor/three\.module\.js|vendor/three\.core\.js|vendor/addons/controls/OrbitControls\.js)',path)
226
+ if r9_asset:
227
+ from .original_r9 import web_response,OriginalR9Hold
228
+ try:
229
+ raw,mime=web_response(r9_asset.group(1))
230
+ return self.output(200,raw,mime,allow_same_origin_frame=path.endswith('/index.html'))
231
+ except (OriginalR9Hold,OSError,UnicodeError):
232
+ return self.output(422,{'status':'HOLD','reason':'R9_SNAPSHOT_VALIDATION_FAILED'})
233
+ if path in ('/api/state','/api/original-v8/state'):
234
+ return self.output(200,self.server.original_v8.snapshot())
235
+ v8_pages={
236
+ '/original-v8/index.html':'index.html',
237
+ '/original-v8/simulator.html':'simulator.html',
238
+ }
239
+ if path in v8_pages:
240
+ file=ROOT/'reference'/'v8-original'/'web'/v8_pages[path]
241
+ return self.output(200,file.read_bytes(),'text/html; charset=utf-8',allow_same_origin_frame=True)
242
+ v8_static={
243
+ '/app.js':('app.js','text/javascript; charset=utf-8'),
244
+ '/simulator.js':('simulator.js','text/javascript; charset=utf-8'),
245
+ '/style.css':('style.css','text/css; charset=utf-8'),
246
+ }
247
+ if path in v8_static:
248
+ name,mime=v8_static[path]
249
+ return self.output(200,(ROOT/'reference'/'v8-original'/'web'/name).read_bytes(),mime)
250
+ if path=='/topology.json':
251
+ return self.output(200,(ROOT/'reference'/'v8-original'/'topology.json').read_bytes(),
252
+ 'application/json; charset=utf-8')
253
+ if path in ('/api/example/warehouse','/api/example/water','/api/example/single_floor','/api/example/cv101_guided',
254
+ '/api/example/warehouse-3floor-standard-2d'):
255
+ kind=path.rsplit('/',1)[-1]
256
+ return self.output(200,json.loads((ROOT/'examples'/f'{kind}.json').read_text(encoding='utf8')))
257
+ if path=='/api/status':return self.output(200,{'status':'READY','engine':'DevAgent V0.7.3 Engineering Harness Candidate',
258
+ 'creator_three_ready':self.server.creator_three_ready,
259
+ 'mode':'LOCAL_ONLY','writes':False,'agent':'BYOM_DRAFT_AND_HUMAN_REVIEW'})
260
+ if path=='/api/ai/providers':
261
+ from .agent_runtime import PROVIDERS
262
+ return self.output(200,{'providers':list(PROVIDERS),'secret_storage':'NONE',
263
+ 'mode':'SESSION_ONLY','production_release':'HOLD'})
264
+ # Locally persisted, explicitly synthetic protocol evidence; no production proof.
265
+ opc_report=re.fullmatch(r'/api/v076-opc/([a-z0-9-]{1,100})/report/([0-9a-f]{12})',path)
266
+ if opc_report:
267
+ pid,rid=opc_report.groups()
268
+ from .studio_workflow import load_project,LabHold
269
+ try:
270
+ load_project(self.server.workspace,pid)
271
+ file=self.server.workspace/pid/'reports'/('lab-opc-v076-'+rid+'.json')
272
+ if not file.is_file() or file.stat().st_size>3_000_000:
273
+ return self.output(404,{'status':'HOLD','reason':'REPORT_NOT_FOUND'})
274
+ return self.output(200,strict_loads(file.read_text(encoding='utf8')))
275
+ except (OSError,ValueError,LabHold) as exc:
276
+ return self.output(422,{'status':'HOLD','reason':'LAB_REPORT_UNAVAILABLE'})
277
+ cmd_report=re.fullmatch(r'/api/v078-command/([a-z0-9-]{1,100})/report/([0-9a-f]{12})',path)
278
+ if cmd_report:
279
+ pid,rid=cmd_report.groups()
280
+ from .studio_workflow import load_project,LabHold
281
+ try:
282
+ load_project(self.server.workspace,pid)
283
+ file=self.server.workspace/pid/'reports'/('lab-command-v078-'+rid+'.json')
284
+ if not file.is_file() or file.is_symlink() or file.stat().st_size>400_000:
285
+ return self.output(404,{'status':'HOLD','reason':'COMMAND_FAT_REPORT_NOT_FOUND'})
286
+ return self.output(200,strict_loads(file.read_text(encoding='utf8')))
287
+ except (OSError,ValueError,LabHold):
288
+ return self.output(422,{'status':'HOLD','reason':'LAB_REPORT_UNAVAILABLE'})
289
+ # Separate engineering workflow: no lab tools are added to operator HMI.
290
+ simulation_status=re.fullmatch(r'/api/preview-sim/([a-z0-9-]{1,100})/status',path)
291
+ if simulation_status:
292
+ from .studio_workflow import load_project,LabHold
293
+ from .preview_simulation import PreviewSimulationHold
294
+ try:
295
+ pid=simulation_status.group(1)
296
+ return self.output(200,self.server.preview_sim.status(pid,load_project(self.server.workspace,pid)))
297
+ except (LabHold,PreviewSimulationHold,OSError,ValueError) as exc:
298
+ return self.output(422,{'status':'HOLD','reason':str(exc)[:120]})
299
+ lab_page=re.fullmatch(r'/projects/([a-z0-9-]+)/test/index.html',path)
300
+ if lab_page:
301
+ from .studio_workflow import load_project,LabHold
302
+ try:load_project(self.server.workspace,lab_page.group(1))
303
+ except (LabHold,OSError,ValueError,KeyError) as ex:
304
+ return self.output(422,{'status':'HOLD','reason':str(ex)[:100]})
305
+ return self.output(200,(ROOT/'assets'/'test_workspace.html').read_bytes(),'text/html; charset=utf-8')
306
+ lab_status=re.fullmatch(r'/api/lab/([a-z0-9-]+)/status',path)
307
+ if lab_status:
308
+ from .studio_workflow import load_project,project_lab_dir,studio_state,_same_session,LabHold
309
+ try:
310
+ pid=lab_status.group(1)
311
+ model=load_project(self.server.workspace,pid)
312
+ prepared=project_lab_dir(self.server.workspace,pid).is_dir()
313
+ try:
314
+ state=studio_state(self.server.lab_port)
315
+ info={'online':True,'lab_tags':state['customer']['tag_count'],
316
+ 'revision':state['customer']['revision'],
317
+ 'active_project_session':_same_session(self.server.lab_sessions.get(pid),state)}
318
+ except LabHold:info={'online':False,'lab_tags':None,'revision':None,'active_project_session':False}
319
+ return self.output(200,{'project_name':model['name'],'assets':len(model['equipment']),
320
+ 'signals':len(model['signals']),'prepared':prepared,'studio':info,
321
+ 'hmi_lab_feed_attached':self.server.lab_live.enabled(pid),
322
+ 'preview_status':'CONCEPT_ONLY_NOT_FINAL_IGNITION','production':'HOLD'})
323
+ except (LabHold,OSError,ValueError,KeyError) as ex:
324
+ return self.output(422,{'status':'HOLD','reason':str(ex)[:100]})
325
+ test_report=re.fullmatch(r'/api/lab/([a-z0-9-]+)/(test-coverage|lab-report.json)',path)
326
+ if test_report:
327
+ from .test_reporting import summarize
328
+ from .studio_workflow import LabHold
329
+ try:
330
+ pid,kind=test_report.groups()
331
+ report=summarize(self.server.workspace,pid,self.server.lab_port,
332
+ self.server.lab_sessions.get(pid),self.server.lab_results.get(pid))
333
+ if kind=='lab-report.json':
334
+ return self.output(200,report,'application/json; charset=utf-8')
335
+ return self.output(200,report)
336
+ except (LabHold,OSError,KeyError,ValueError,TypeError) as ex:
337
+ return self.output(422,{'status':'HOLD','reason':str(ex)[:100]})
338
+ lab_evidence=re.fullmatch(r'/api/lab/([a-z0-9-]+)/evidence/((?:protocol-probe|isolated-fault-scenario)-[0-9a-f]{12}\.json)',path)
339
+ if lab_evidence:
340
+ from .studio_workflow import load_project,project_lab_dir,LabHold
341
+ try:
342
+ pid,name=lab_evidence.groups()
343
+ load_project(self.server.workspace,pid)
344
+ labdir=project_lab_dir(self.server.workspace,pid)
345
+ file=labdir/name
346
+ if not file.is_file() or file.is_symlink() or file.resolve().parent!=labdir.resolve():
347
+ return self.output(404,{'status':'HOLD','reason':'EVIDENCE_NOT_FOUND'})
348
+ if file.stat().st_size>1_000_000:return self.output(422,{'status':'HOLD','reason':'EVIDENCE_SIZE_HOLD'})
349
+ return self.output(200,file.read_bytes(),'application/json; charset=utf-8')
350
+ except (LabHold,ValueError,OSError,KeyError) as ex:
351
+ return self.output(422,{'status':'HOLD','reason':str(ex)[:100]})
352
+ live_feed=re.fullmatch(r'/api/live-lab/([a-z0-9-]+)/readings',path)
353
+ if live_feed:
354
+ from .studio_workflow import LabHold
355
+ try:
356
+ pid=live_feed.group(1)
357
+ from .studio_workflow import load_project
358
+ model=load_project(self.server.workspace,pid)
359
+ if self.server.preview_sim.active(pid):
360
+ # Explicitly requested project-scoped synthetic preview feed.
361
+ # This NEVER probes or writes Studio/OPC UA.
362
+ result=self.server.preview_sim.reading(pid,model)
363
+ else:
364
+ result=self.server.lab_live.read(self.server.workspace,pid,self.server.lab_port)
365
+ return self.output(200 if result['status']!='HOLD' else 422,result)
366
+ except (LabHold,ValueError,OSError,KeyError) as ex:
367
+ return self.output(422,{'status':'HOLD','reason':str(ex)[:115]})
368
+ if path in ('/assets/creator.css','/assets/creator_scene.mjs','/assets/creator_ui.mjs','/assets/creator_2d.mjs','/assets/creator_original_v8.mjs','/assets/creator_original_r9.mjs','/assets/creator_r9_diagnostics.mjs','/assets/facility2d_core.mjs','/assets/flow_geometry.mjs','/assets/r9_transport.mjs'):
369
+ file=ROOT/'assets'/path.rsplit('/',1)[-1]
370
+ return self.output(200,file.read_bytes(),'text/css; charset=utf-8' if path.endswith('.css') else 'text/javascript; charset=utf-8')
371
+ vendor_asset=re.fullmatch(r'/assets/creator-vendor/(three\.module\.js|three\.core\.js)',path)
372
+ if vendor_asset:
373
+ if not self.server.creator_three_ready:return self.output(404,{'error':'VENDOR_UNAVAILABLE_USE_SCHEMATIC_FALLBACK'})
374
+ file=Path(self.server.vendor_three).expanduser().resolve()/vendor_asset.group(1)
375
+ return self.output(200,file.read_bytes(),'text/javascript; charset=utf-8')
376
+ if path=='/assets/v078_command_ui.js':
377
+ return self.output(200,(ROOT/'assets'/'v078_command_ui.js').read_bytes(),
378
+ 'text/javascript; charset=utf-8')
379
+ if path=='/assets/v076_fat_ui.js':
380
+ return self.output(200,(ROOT/'assets'/'v076_fat_ui.js').read_bytes(),
381
+ 'text/javascript; charset=utf-8')
382
+ if path=='/assets/harness.js':
383
+ return self.output(200,(ROOT/'assets'/'harness.js').read_bytes(),'text/javascript; charset=utf-8')
384
+ if path=='/assets/agent.js':
385
+ return self.output(200,(ROOT/'assets'/'agent.js').read_bytes(),'text/javascript; charset=utf-8')
386
+ # The unified AI engineering button must have a real, served event handler.
387
+ # Explicit allowlisting preserves the read-only static asset boundary.
388
+ if path=='/assets/project_package.js':
389
+ return self.output(200,(ROOT/'assets'/'project_package.js').read_bytes(),
390
+ 'text/javascript; charset=utf-8')
391
+ if path=='/assets/customer_documents.js':
392
+ return self.output(200,(ROOT/'assets'/'customer_documents.js').read_bytes(),
393
+ 'text/javascript; charset=utf-8')
394
+ if path=='/assets/live_design_review.js':
395
+ return self.output(200,(ROOT/'assets'/'live_design_review.js').read_bytes(),'text/javascript; charset=utf-8')
396
+ if path=='/assets/unified_engineer.js':
397
+ return self.output(200,(ROOT/'assets'/'unified_engineer.js').read_bytes(),
398
+ 'text/javascript; charset=utf-8')
399
+ match=re.fullmatch(r'/projects/([a-z0-9-]+)/viewer/(index.html|style.css|viewer.js|three_scene.js|flow_geometry.mjs|r9_transport.mjs|fallback_scene.js|operations_ui.js|runtime_feed.js|facility2d_core.mjs|facility2d_app.mjs|facility2d_simulation.mjs|facility2d.css|equipment_controls.mjs|vendor/three.module.js|vendor/three.core.js)',path)
400
+ if match:
401
+ name,resource=match.groups()
402
+ file=(self.server.workspace/name/'viewer'/resource).resolve()
403
+ base=(self.server.workspace/name/'viewer').resolve()
404
+ if not file.is_relative_to(base) or not file.is_file():return self.output(404,{'error':'NOT_FOUND'})
405
+ types={'.html':'text/html; charset=utf-8','.css':'text/css; charset=utf-8','.js':'text/javascript; charset=utf-8','.mjs':'text/javascript; charset=utf-8'}
406
+ return self.output(200,file.read_bytes(),types.get(file.suffix,'application/octet-stream'),
407
+ allow_same_origin_frame=(resource=='index.html'))
408
+ return self.output(404,{'error':'NOT_FOUND'})
409
+ def do_POST(self):
410
+ if not self.same_host():return self.output(403,{'error':'HOST_NOT_ALLOWED'})
411
+ origin=self.headers.get('Origin')
412
+ if origin not in (None, f'http://127.0.0.1:{self.server.server_port}',f'http://localhost:{self.server.server_port}'):
413
+ return self.output(403,{'error':'ORIGIN_NOT_ALLOWED'})
414
+ route=urlsplit(self.path).path
415
+ if route in ('/api/original-r9/lab/command','/api/original-r9/reset'):
416
+ # This is isolated *in-memory* synthetic lab state, never an OPC UA
417
+ # command. Require explicit browser origin and CSRF for mutations.
418
+ if origin is None or not self.same_host():
419
+ return self.output(403,{'error':'R9_ORIGIN_REQUIRED'})
420
+ if self.headers.get('Content-Type','').split(';')[0]!='application/json':
421
+ return self.output(415,{'error':'R9_JSON_REQUIRED'})
422
+ try:
423
+ size=int(self.headers.get('Content-Length','-1'))
424
+ except ValueError:
425
+ return self.output(400,{'error':'R9_INVALID_LENGTH'})
426
+ if not 0<size<=512:return self.output(413,{'error':'R9_REQUEST_TOO_LARGE'})
427
+ if route=='/api/original-r9/lab/command':
428
+ if self.headers.get('X-DevAgent-Lab-Token')!=self.server.original_r9_token:
429
+ return self.output(403,{'error':'R9_CSRF_REQUIRED'})
430
+ elif self.headers.get('X-DevAgent-Request')!='original-r9-simulator-v1':
431
+ return self.output(403,{'error':'R9_RESET_CONFIRMATION_REQUIRED'})
432
+ try:
433
+ payload=strict_loads(self.rfile.read(size))
434
+ if not isinstance(payload,dict):raise ValueError('R9_OBJECT_REQUIRED')
435
+ if route.endswith('/command'):
436
+ if set(payload)!={'command','request_id'}:raise ValueError('R9_COMMAND_FIELDS_INVALID')
437
+ return self.output(200,self.server.original_r9.command(payload['command'],payload['request_id']))
438
+ if payload!={'confirm':'RESET_SYNTHETIC_R9_ONLY'}:raise ValueError('R9_RESET_FIELDS_INVALID')
439
+ return self.output(200,self.server.original_r9.reset())
440
+ except (ValueError,TypeError,KeyError) as exc:
441
+ return self.output(409,{'status':'HOLD','reason':str(exc)[:120]})
442
+ # Explicit local user action, raw binary file, no disk persistence.
443
+ if route=='/api/customer-documents/inspect':
444
+ if origin is None or self.headers.get('X-DevAgent-Request')!='customer-doc-intake-v1':
445
+ return self.output(403,{'status':'HOLD','reason':'DOCUMENT_ORIGIN_REQUIRED'})
446
+ if self.headers.get('Content-Type','').split(';')[0]!='application/octet-stream':
447
+ return self.output(415,{'status':'HOLD','reason':'BINARY_DOCUMENT_REQUIRED'})
448
+ from .customer_intake import inspect,IntakeHold,MAX_FILE
449
+ try:
450
+ size=int(self.headers.get('Content-Length','-1'))
451
+ if not 0<size<=MAX_FILE:return self.output(413,{'status':'HOLD','reason':'DOCUMENT_SIZE_LIMIT'})
452
+ filename=unquote(self.headers.get('X-DevAgent-Filename',''))
453
+ data=inspect(filename,self.rfile.read(size))
454
+ return self.output(200,data)
455
+ except (IntakeHold,ValueError,TypeError) as exc:
456
+ code=str(exc)
457
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}',code):code='DOCUMENT_INSPECTION_HOLD'
458
+ return self.output(422,{'status':'HOLD','reason':code})
459
+ if route=='/api/customer-package/inspect':
460
+ if origin is None or self.headers.get('X-DevAgent-Request')!='customer-project-package-v1':
461
+ return self.output(403,{'status':'HOLD','reason':'PACKAGE_ORIGIN_REQUIRED'})
462
+ if self.headers.get('Content-Type','').split(';')[0]!='application/octet-stream':
463
+ return self.output(415,{'status':'HOLD','reason':'BINARY_PROJECT_ZIP_REQUIRED'})
464
+ from .project_package import inspect_package,PackageHold,MAX_PACKAGE_BYTES
465
+ try:
466
+ size=int(self.headers.get('Content-Length','-1'))
467
+ if not 0<size<=MAX_PACKAGE_BYTES:
468
+ return self.output(413,{'status':'HOLD','reason':'PROJECT_PACKAGE_SIZE_HOLD'})
469
+ filename=unquote(self.headers.get('X-DevAgent-Filename',''))
470
+ response=inspect_package(filename,self.rfile.read(size))
471
+ return self.output(200,response)
472
+ except (PackageHold,ValueError,TypeError) as exc:
473
+ reason=str(exc)
474
+ if not re.fullmatch(r'[A-Z0-9_]{2,100}',reason):reason='PROJECT_PACKAGE_HOLD'
475
+ return self.output(422,{'status':'HOLD','reason':reason})
476
+ if self.headers.get('Content-Type','').split(';')[0]!='application/json':return self.output(415,{'error':'JSON_REQUIRED'})
477
+ try:size=int(self.headers.get('Content-Length','-1'))
478
+ except ValueError:return self.output(400,{'error':'INVALID_LENGTH'})
479
+ if not 0<size<=LIMIT:return self.output(413,{'error':'REQUEST_SIZE_HOLD'})
480
+ route=urlsplit(self.path).path
481
+ if route in ('/api/scenario','/api/original-v8/scenario'):
482
+ # The immutable original simulator posts same-origin JSON without a
483
+ # custom header. Host/origin checks plus loopback binding preserve
484
+ # its source behavior while preventing cross-origin actuation.
485
+ if origin is None:
486
+ return self.output(403,{'error':'ORIGINAL_V8_SIMULATOR_ORIGIN_REQUIRED'})
487
+ try:
488
+ payload=strict_loads(self.rfile.read(size))
489
+ return self.output(200,self.server.original_v8.transition(payload))
490
+ except (ValueError,TypeError,KeyError) as exc:
491
+ return self.output(400,{'error':str(exc)[:120]})
492
+ if route=='/api/original-v8/reset':
493
+ if origin is None or self.headers.get('X-DevAgent-Request')!='original-v8-simulator-v1':
494
+ return self.output(403,{'error':'ORIGINAL_V8_SIMULATOR_ORIGIN_REQUIRED'})
495
+ try:
496
+ payload=strict_loads(self.rfile.read(size))
497
+ if payload!={'confirm':'RESET_SYNTHETIC_DEMO_ONLY'}:
498
+ raise ValueError('ORIGINAL_V8_RESET_CONFIRMATION_REQUIRED')
499
+ return self.output(200,self.server.original_v8.reset())
500
+ except (ValueError,TypeError) as exc:
501
+ return self.output(400,{'error':str(exc)[:120]})
502
+ if route=='/api/engineer/revoke':
503
+ if origin is None or self.headers.get('X-DevAgent-Request')!='engineer-pipeline-v1':
504
+ return self.output(403,{'status':'HOLD','reason':'ENGINEER_ORIGIN_REQUIRED'})
505
+ try:
506
+ payload=strict_loads(self.rfile.read(size))
507
+ if not isinstance(payload,dict) or set(payload)!={'review_token','confirm'} or payload['confirm']!='REVOKE_LOCAL_REVIEW_ONLY':
508
+ raise ValueError('ENGINEER_REVOKE_SCHEMA_HOLD')
509
+ token=payload['review_token']
510
+ if not isinstance(token,str) or not 30<=len(token)<=128:
511
+ raise ValueError('ENGINEER_REVIEW_TOKEN_INVALID')
512
+ with self.server.engineer_review_lock:
513
+ revoked=self.server.engineer_reviews.pop(token,None) is not None
514
+ return self.output(200,{'status':'REVIEW_REVOKED' if revoked else 'REVIEW_ALREADY_UNAVAILABLE',
515
+ 'production':'HOLD'})
516
+ except (ValueError,TypeError):
517
+ return self.output(422,{'status':'HOLD','reason':'ENGINEER_REVOKE_SCHEMA_HOLD'})
518
+ if route=='/api/customer-documents/suggest':
519
+ if origin is None or self.headers.get('X-DevAgent-Request')!='customer-doc-intake-v1':
520
+ return self.output(403,{'status':'HOLD','reason':'DOCUMENT_ORIGIN_REQUIRED'})
521
+ from .customer_intake import suggest,IntakeHold
522
+ from .agent_runtime import AgentHold
523
+ try:
524
+ payload=strict_loads(self.rfile.read(size))
525
+ if not isinstance(payload,dict) or set(payload)!={'connection','documents','confirm'} or payload['confirm']!='CUSTOMER_REQUESTED_AI_DOCUMENT_EXTRACTION':
526
+ raise IntakeHold('DOCUMENT_AI_CONSENT_REQUIRED')
527
+ return self.output(200,suggest(payload['connection'],payload['documents']))
528
+ except (IntakeHold,AgentHold,ValueError,TypeError,KeyError) as exc:
529
+ code=str(exc)
530
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}',code):code='AI_DOCUMENT_EXTRACTION_HOLD'
531
+ return self.output(422,{'status':'HOLD','reason':code,'production':'HOLD'})
532
+ command_action=re.fullmatch(r'/api/v078-command/([a-z0-9-]{1,100})/run',route)
533
+ if command_action:
534
+ if origin is None or self.headers.get('X-DevAgent-Request')!='isolated-device-command-v078':
535
+ return self.output(403,{'status':'HOLD','reason':'COMMAND_ORIGIN_REQUIRED'})
536
+ from .v078_commands import (contract_template,run_virtual_plc_fat,CommandHold,LAB_CONFIRM)
537
+ from .studio_workflow import load_project,LabHold
538
+ pid=command_action.group(1)
539
+ try:
540
+ req=strict_loads(self.rfile.read(size))
541
+ if not isinstance(req,dict) or set(req)!={'asset_id','reviewer','confirm'}:
542
+ raise CommandHold('COMMAND_REQUEST_SCHEMA_INVALID')
543
+ if req['confirm']!=LAB_CONFIRM:
544
+ raise CommandHold('COMMAND_LAB_CONFIRM_REQUIRED')
545
+ model=load_project(self.server.workspace,pid)
546
+ c=contract_template(model,req['asset_id'])
547
+ name=req['reviewer']
548
+ if not isinstance(name,str) or not 3<=len(name.strip())<=100 or any(ord(ch)<32 for ch in name):
549
+ raise CommandHold('LAB_REVIEWER_NAME_REQUIRED')
550
+ c['engineer']=name
551
+ c['approval']='APPROVED_FOR_ISOLATED_FAT_ONLY'
552
+ if not self.server.lab_lock.acquire(blocking=False):
553
+ raise CommandHold('LAB_BUSY')
554
+ try:
555
+ import asyncio
556
+ result=asyncio.run(asyncio.wait_for(run_virtual_plc_fat(
557
+ model,c,port=4899,confirm=LAB_CONFIRM),timeout=45))
558
+ rid=secrets.token_hex(6)
559
+ from .v076_opc_fat import save_new
560
+ save_new(self.server.workspace/pid/'reports'/('lab-command-v078-'+rid+'.json'),result)
561
+ public={'status':result['status'],'cases_checked':result['case_count'],
562
+ 'report_url':f'/api/v078-command/{pid}/report/{rid}',
563
+ 'gateway':'NOT_VERIFIED','physical_plc':'NOT_VERIFIED','production':'HOLD'}
564
+ return self.output(200 if result['status'].startswith('PASS_') else 422,public)
565
+ finally:self.server.lab_lock.release()
566
+ except (OSError,CommandHold,LabHold,ValueError,TypeError,KeyError,TimeoutError) as exc:
567
+ code=str(exc)
568
+ if not re.fullmatch('[A-Z][A-Z0-9_]{1,119}',code):code='VIRTUAL_COMMAND_LAB_HOLD'
569
+ return self.output(422,{'status':'HOLD','reason':code,
570
+ 'gateway':'NOT_VERIFIED','production':'HOLD'})
571
+ # Customer UI FAT action is bound to an existing AI-reviewed/build project.
572
+ # It opens only an ephemeral fake OPC UA server on localhost; NEVER Gateway/PLC.
573
+ opc_action=re.fullmatch(r'/api/v076-opc/([a-z0-9-]{1,100})/run',route)
574
+ if opc_action:
575
+ if origin is None or self.headers.get('X-DevAgent-Request')!='isolated-opc-fat-v076':
576
+ return self.output(403,{'status':'HOLD','reason':'OPC_LAB_ORIGIN_REQUIRED'})
577
+ from .v076_opc_fat import run_loopback_fat,save_new,LabHold
578
+ from .studio_workflow import load_project
579
+ pid=opc_action.group(1)
580
+ try:
581
+ req=strict_loads(self.rfile.read(size))
582
+ if req!={'confirm':'ISOLATED_FAKE_OPC_LOOPBACK_ONLY'}:
583
+ raise LabHold('LAB_CONFIRMATION_REQUIRED')
584
+ model=load_project(self.server.workspace,pid)
585
+ # Do not start a second protocol server while one lab is active.
586
+ if not self.server.lab_lock.acquire(blocking=False):
587
+ raise LabHold('LAB_BUSY')
588
+ try:
589
+ import asyncio
590
+ result=asyncio.run(asyncio.wait_for(run_loopback_fat(model,port=4898),timeout=50))
591
+ rid=secrets.token_hex(6)
592
+ save_new(self.server.workspace/pid/'reports'/('lab-opc-v076-'+rid+'.json'),result)
593
+ result={'status':result['status'],'cases_passed':result['cases_passed'],
594
+ 'cases_planned':result['cases_planned'],
595
+ 'report_url':f'/api/v076-opc/{pid}/report/{rid}',
596
+ 'gateway':'NOT_VERIFIED','production':'HOLD'}
597
+ return self.output(200 if result['status']=='PASS_LAB_OPC_PROTOCOL_ONLY' else 422,result)
598
+ finally:self.server.lab_lock.release()
599
+ except (OSError,LabHold,ValueError,TypeError,KeyError) as exc:
600
+ code=str(exc)
601
+ if not re.fullmatch('[A-Z][A-Z0-9_]{1,119}',code):code='FAKE_OPC_LAB_HOLD'
602
+ return self.output(422,{'status':'HOLD','reason':code,
603
+ 'gateway':'NOT_VERIFIED','production':'HOLD'})
604
+ if route=='/api/creator-scene':
605
+ if origin is None or self.headers.get('X-DevAgent-Request')!='concept-scene-v1':
606
+ return self.output(403,{'status':'HOLD','reason':'CONCEPT_SCENE_ORIGIN_REQUIRED'})
607
+ try:
608
+ payload=strict_loads(self.rfile.read(size))
609
+ if not isinstance(payload,dict):raise BuildHold('SCENE_EXPECTED_OBJECT')
610
+ # Only declared equipment/topology drive this design preview. OPC tags
611
+ # and operating values are deliberately excluded, never inferred.
612
+ model=from_wizard({
613
+ 'name':payload.get('name') or 'Untitled preview',
614
+ 'equipment_csv':payload.get('equipment_csv',''),
615
+ 'connections_csv':payload.get('connections_csv','')})
616
+ from .generator import enrich
617
+ scene=enrich(model)
618
+ return self.output(200,{
619
+ 'status':'CONCEPTUAL_LAYOUT_NOT_AS_BUILT','data_source':'USER_EQUIPMENT_CSV_ONLY',
620
+ 'engine_status':'NO_LIVE_TAGS_NO_PLC_VERIFICATION',
621
+ 'assets':[{'id':e['id'],'name':e['name'],'kind':e['kind'],'floor':e['floor'],
622
+ 'zone':e['zone'],'position':e['position'],'dimensions':e['dimensions'],
623
+ 'geometry_status':e['geometry_status']} for e in scene['equipment']],
624
+ 'connections':scene['connections'],
625
+ 'three_available':self.server.creator_three_ready})
626
+ except (BuildHold,ValueError,KeyError,TypeError,UnicodeError,RecursionError) as e:
627
+ return self.output(422,{'status':'HOLD','reason':str(e)[:180],
628
+ 'data_source':'NO_LIVE_DATA'})
629
+ preview_action=re.fullmatch(r'/api/preview-sim/([a-z0-9-]{1,100})/(start|stop|pause|fault)',route)
630
+ if preview_action:
631
+ from .preview_simulation import PreviewSimulationHold
632
+ from .studio_workflow import load_project,LabHold
633
+ if origin is None or self.headers.get('X-DevAgent-Lab')!='isolated-workspace-v1':
634
+ return self.output(403,{'status':'HOLD','reason':'SIMULATION_ORIGIN_CONFIRMATION_REQUIRED'})
635
+ try:
636
+ pid,operation=preview_action.groups()
637
+ payload=strict_loads(self.rfile.read(size))
638
+ if not isinstance(payload,dict) or payload.get('confirm')!='SYNTHETIC_PREVIEW_ONLY' or set(payload)-{'confirm','asset_id','pace'}:
639
+ raise PreviewSimulationHold('SYNTHETIC_PREVIEW_CONFIRMATION_REQUIRED')
640
+ if operation!='fault' and 'asset_id' in payload:
641
+ raise PreviewSimulationHold('UNEXPECTED_FAULT_ASSET')
642
+ if operation!='start' and 'pace' in payload:
643
+ raise PreviewSimulationHold('PREVIEW_PACE_ONLY_ON_START')
644
+ if 'pace' in payload and payload['pace'] not in ('slow','normal','fast'):
645
+ raise PreviewSimulationHold('UNKNOWN_PREVIEW_PACE')
646
+ model=load_project(self.server.workspace,pid)
647
+ # Never hide an actual authorized lab feed behind synthetic values.
648
+ if operation!='stop' and self.server.lab_live.enabled(pid):
649
+ raise PreviewSimulationHold('LIVE_LAB_ATTACHED_STOP_OR_DETACH_FIRST')
650
+ return self.output(200,self.server.preview_sim.action(pid,model,operation,fault_asset=payload.get('asset_id'),pace=payload.get('pace')))
651
+ except (PreviewSimulationHold,LabHold,ValueError,OSError,TypeError) as exc:
652
+ return self.output(422,{'status':'HOLD','reason':str(exc)[:140]})
653
+ operator_action=re.fullmatch(r'/api/preview-sim/([a-z0-9-]{1,100})/operator/(start|stop|reset|ack)',route)
654
+ if operator_action:
655
+ from .preview_simulation import PreviewSimulationHold
656
+ from .studio_workflow import load_project,LabHold
657
+ if origin is None or self.headers.get('X-DevAgent-Operator')!='synthetic-session-v1':
658
+ return self.output(403,{'status':'HOLD','reason':'SIMULATION_OPERATOR_ORIGIN_REQUIRED'})
659
+ try:
660
+ pid,command=operator_action.groups()
661
+ body=strict_loads(self.rfile.read(size))
662
+ if body!={'confirm':'SYNTHETIC_OPERATOR_ONLY'}:
663
+ raise PreviewSimulationHold('EXACT_SIMULATION_OPERATOR_CONFIRMATION_REQUIRED')
664
+ if not self.server.preview_sim.active(pid):
665
+ raise PreviewSimulationHold('NO_ACTIVE_PROJECT_SCOPED_SIMULATOR')
666
+ if self.server.lab_live.enabled(pid):
667
+ raise PreviewSimulationHold('OPC_LAB_ACTIVE_SYNTHETIC_CONTROLS_DENIED')
668
+ model=load_project(self.server.workspace,pid)
669
+ result=self.server.preview_sim.action(pid,model,'operator-'+command)
670
+ result['command_authority']='SYNTHETIC_SIMULATOR_ONLY'
671
+ return self.output(200,result)
672
+ except (PreviewSimulationHold,LabHold,ValueError,KeyError,OSError) as exc:
673
+ return self.output(422,{'status':'HOLD','reason':str(exc)[:140]})
674
+ lab_action=re.fullmatch(r'/api/lab/([a-z0-9-]+)/(prepare|import|probe|scenario|sample|autorun|readonly-probe)',route)
675
+ if lab_action:
676
+ if origin is None or self.headers.get('X-DevAgent-Lab')!='isolated-workspace-v1':
677
+ return self.output(403,{'status':'HOLD','reason':'LAB_CSRF_CONFIRMATION_REQUIRED'})
678
+ if not self.server.lab_lock.acquire(blocking=False):
679
+ return self.output(409,{'status':'HOLD','reason':'ANOTHER_LAB_ACTION_IN_PROGRESS'})
680
+ try:
681
+ from . import studio_workflow as wf
682
+ from .operations import make_synthetic_frames,evaluate
683
+ pid,op=lab_action.groups()
684
+ body=strict_loads(self.rfile.read(size))
685
+ if not isinstance(body,dict) or body!={'confirm':'ISOLATED_TEST_ONLY'}:
686
+ raise wf.LabHold('EXPLICIT_ISOLATED_LAB_CONFIRMATION_REQUIRED')
687
+ workspace=self.server.workspace;port=self.server.lab_port
688
+ model=wf.load_project(workspace,pid)
689
+ if op=='prepare':data=wf.prepare(workspace,pid,model)
690
+ elif op=='readonly-probe':
691
+ data=wf.readonly_probe_existing_unbound(workspace,pid,port)
692
+ self.server.lab_results.setdefault(pid,{})['unbound_probe']=data
693
+ # The read-only evidence can attach live display only.
694
+ if self.server.preview_sim.active(pid):raise wf.LabHold('STOP_SYNTHETIC_SIMULATION_BEFORE_OPC_ATTACH')
695
+ self.server.lab_live.attach(workspace,pid,port,readonly_receipt=data)
696
+ elif op=='sample':
697
+ receipt=evaluate(model,make_synthetic_frames(model))
698
+ data={'status':'PASS_SYNTHETIC_RULE_DEMO_ONLY','project_id':pid,
699
+ 'frames':len(receipt['frames']),
700
+ 'alarm_events':[{'kind':e['kind'],'asset':e['asset_id']} for e in receipt['events']],
701
+ 'studio':'NOT_USED','ignition':'NOT_VERIFIED','production':'HOLD'}
702
+ self.server.lab_results.setdefault(pid,{})['sample']=data
703
+ elif op=='autorun':
704
+ # Explicit one-click isolated-lab intent. Hard HOLD on a
705
+ # populated / non-lab Studio before modifying any tags.
706
+ state=wf.studio_state(port)
707
+ session=self.server.lab_sessions.get(pid)
708
+ if state['customer']['tag_count']==0:
709
+ # Only a truly empty Studio may accept new customer tags.
710
+ prep=wf.prepare(workspace,pid,model)
711
+ imported,session=wf.import_tags(workspace,pid,port,state,session)
712
+ self.server.lab_sessions[pid]=session
713
+ self.server.lab_results[pid]={}
714
+ execution='NEW_ISOLATED_LAB_IMPORT'
715
+ else:
716
+ # A failed probe/fault after a successful import must be
717
+ # resumable, but NEVER adopt pre-existing foreign tags.
718
+ # require_session checks Creator import provenance, exact
719
+ # Studio process/revision/endpoint and entire manifest.
720
+ if not wf._same_session(session,state):
721
+ raise wf.LabHold('STUDIO_HAS_EXISTING_TAGS_REQUIRE_FRESH_ISOLATED_LAB')
722
+ wf.require_session(workspace,pid,port,session)
723
+ prep={'status':'EXISTING_PREPARED_LAB_REUSED'}
724
+ imported={'status':'SAME_CREATOR_PROJECT_SESSION_REUSED',
725
+ 'imported':state['customer']['tag_count']}
726
+ execution='RESUME_VERIFIED_CREATOR_SESSION'
727
+ protocol=wf.protocol_probe(workspace,pid,port,session)
728
+ self.server.lab_results.setdefault(pid,{})['probe']=protocol
729
+ if self.server.preview_sim.active(pid):raise wf.LabHold('STOP_SYNTHETIC_SIMULATION_BEFORE_OPC_ATTACH')
730
+ self.server.lab_live.attach(workspace,pid,port,session=session)
731
+ scenario=wf.run_fault_scenario(workspace,pid,port,session)
732
+ self.server.lab_results.setdefault(pid,{})['fault']=scenario
733
+ data={'status':'PASS_STUDIO_LAB_PROTOCOL_AND_FAULT_ONLY',
734
+ 'execution':execution,'prepared':prep,'imported':imported,
735
+ 'readback':protocol,'fault':scenario,
736
+ 'ignition':'NOT_VERIFIED','production':'HOLD',
737
+ 'fat':'LAB_SOURCE_AND_EVENT_ONLY_NOT_HMI_FAT'}
738
+ elif op=='import':
739
+ state=wf.studio_state(port)
740
+ data,session=wf.import_tags(workspace,pid,port,state,self.server.lab_sessions.get(pid))
741
+ self.server.lab_sessions[pid]=session
742
+ self.server.lab_results[pid]={}
743
+ elif op=='probe':
744
+ data=wf.protocol_probe(workspace,pid,port,self.server.lab_sessions.get(pid))
745
+ self.server.lab_results.setdefault(pid,{})['probe']=data
746
+ if self.server.preview_sim.active(pid):raise wf.LabHold('STOP_SYNTHETIC_SIMULATION_BEFORE_OPC_ATTACH')
747
+ self.server.lab_live.attach(workspace,pid,port,session=self.server.lab_sessions.get(pid))
748
+ elif op=='scenario':
749
+ data=wf.run_fault_scenario(workspace,pid,port,self.server.lab_sessions.get(pid))
750
+ self.server.lab_results.setdefault(pid,{})['fault']=data
751
+ reply_code=200
752
+ except (wf.LabHold,ValueError,OSError,KeyError,TypeError) as ex:
753
+ code=str(ex)
754
+ if len(code)>160:code='LAB_WORKFLOW_HOLD'
755
+ reply_code=422
756
+ data={'status':'HOLD','reason':code,
757
+ 'production':'HOLD','ignition':'NOT_VERIFIED'}
758
+ finally:self.server.lab_lock.release()
759
+ # The response is sent AFTER unlocking so an immediate client follow-up
760
+ # cannot spuriously race with the previous completed lab action.
761
+ return self.output(reply_code,data)
762
+ if route == '/api/engineer/prepare':
763
+ # The only user-facing Creator preparation workflow: real BYOM -> harness.
764
+ # The model call itself proves we did not simply accept a front-end 'connected' badge.
765
+ if origin is None or self.headers.get('X-DevAgent-Request') != 'engineer-pipeline-v1':
766
+ return self.output(403, {'status':'HOLD','reason':'ENGINEER_ORIGIN_REQUIRED'})
767
+ try:
768
+ from .agent_harness import run_harness
769
+ from .agent_runtime import AgentHold
770
+ request = strict_loads(self.rfile.read(size))
771
+ if not isinstance(request,dict) or set(request)-{'wizard','connection','request','max_attempts'}:
772
+ raise AgentHold('ENGINEER_REQUEST_INVALID')
773
+ connection=request.get('connection')
774
+ if not isinstance(connection,dict) or not connection.get('model'):
775
+ raise AgentHold('AI_PROVIDER_REQUIRED')
776
+ result=run_harness(request)
777
+ if result.get('status')!='DRAFT_REQUIRES_HUMAN_REVIEW' or not result.get('winner'):
778
+ return self.output(422,{'status':'HOLD','reason':'AI_HARNESS_NO_VALID_CANDIDATE',
779
+ 'attempts':result.get('attempts',[]),
780
+ 'engineering':result.get('engineering',{}),
781
+ 'production':'HOLD'})
782
+ from .agent_harness import _checked_wizard
783
+ candidate=_checked_wizard(result['winner']['fields'])
784
+ result['winner']['fields']=candidate
785
+ digest=hashlib.sha256(json.dumps(candidate,sort_keys=True,ensure_ascii=False,
786
+ separators=(',',':')).encode('utf8')).hexdigest()
787
+ token=secrets.token_urlsafe(32)
788
+ with self.server.engineer_review_lock:
789
+ now=time.monotonic()
790
+ self.server.engineer_reviews={k:v for k,v in self.server.engineer_reviews.items()
791
+ if now-v['created']<600}
792
+ if len(self.server.engineer_reviews)>=64:
793
+ raise AgentHold('ENGINEER_REVIEW_CAPACITY_HOLD')
794
+ self.server.engineer_reviews[token]={'digest':digest,'created':now}
795
+ result['review_token']=token
796
+ result['review_expires_seconds']=600
797
+ result['review_scope']='ISOLATED_CONCEPT_PREVIEW_ONLY'
798
+ return self.output(200,result)
799
+ except (AgentHold,ValueError,KeyError,TypeError,RecursionError,OSError) as e:
800
+ code=str(e)
801
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}',code):code='AI_PIPELINE_HOLD'
802
+ return self.output(422,{'status':'HOLD','reason':code,'production':'HOLD'})
803
+ if route == '/api/engineer/build':
804
+ if origin is None or self.headers.get('X-DevAgent-Request') != 'engineer-pipeline-v1':
805
+ return self.output(403,{'status':'HOLD','reason':'ENGINEER_ORIGIN_REQUIRED'})
806
+ try:
807
+ request=strict_loads(self.rfile.read(size))
808
+ if not isinstance(request,dict) or set(request)!={'wizard','review_token','confirm'}:
809
+ raise BuildHold('ENGINEER_BUILD_SCHEMA_HOLD')
810
+ if request['confirm']!='CUSTOMER_APPROVED_ISOLATED_CONCEPT_ONLY':
811
+ raise BuildHold('CUSTOMER_REVIEW_REQUIRED')
812
+ token=request['review_token']
813
+ if not isinstance(token,str) or not 30<=len(token)<=128:
814
+ raise BuildHold('ENGINEER_REVIEW_TOKEN_INVALID')
815
+ from .agent_harness import _checked_wizard
816
+ wizard=_checked_wizard(request['wizard'])
817
+ digest=hashlib.sha256(json.dumps(wizard,sort_keys=True,ensure_ascii=False,
818
+ separators=(',',':')).encode('utf8')).hexdigest()
819
+ with self.server.engineer_review_lock:
820
+ receipt=self.server.engineer_reviews.pop(token,None)
821
+ if not receipt or time.monotonic()-receipt['created']>600 or not secrets.compare_digest(digest,receipt['digest']):
822
+ raise BuildHold('ENGINEER_REVIEW_EXPIRED_OR_INPUT_CHANGED')
823
+ data=from_wizard(wizard)
824
+ report=qualify(data)
825
+ if report['status']=='HOLD':
826
+ return self.output(422,{'status':'HOLD','reason':'ENGINEERING_CONTRACT_HOLD',
827
+ 'engineering':report,'production':'HOLD'})
828
+ slug=slugify(data['name']);pid=f'{slug}-{uuid.uuid4().hex[:8]}'
829
+ built=build(data,self.server.workspace/pid,vendor_three=self.server.vendor_three)
830
+ return self.output(201,{'status':built['status'],'project_id':pid,
831
+ 'url':f'/projects/{pid}/viewer/index.html','warnings':built['issues'],
832
+ 'fat_cases':built['fat_cases_generated'],
833
+ 'renderer':'THREE_JS_R180' if built['three_vendor_bundled'] else 'SCHEMATIC_FALLBACK',
834
+ 'ai_pipeline':'SERVER_VERIFIED_HARNESS_REVIEW_RECEIPT',
835
+ 'ignition':'NOT_VERIFIED','production':'HOLD'})
836
+ except (BuildHold,ValueError,TypeError,KeyError,RecursionError,OSError) as e:
837
+ code=str(e)
838
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}',code):code='ENGINEER_BUILD_HOLD'
839
+ return self.output(422,{'status':'HOLD','reason':code,'production':'HOLD'})
840
+ if route == '/api/agent/harness':
841
+ # User explicitly triggers review/AI: no background transmission.
842
+ if self.headers.get('X-DevAgent-Request') != 'agent-harness-v1' or origin is None:
843
+ return self.output(403, {'status':'HOLD','reason':'AGENT_CSRF_PROTECTION_REQUIRED'})
844
+ try:
845
+ from .agent_harness import run_harness
846
+ from .agent_runtime import AgentHold
847
+ raw = strict_loads(self.rfile.read(size))
848
+ result = run_harness(raw)
849
+ return self.output(200 if not result['status'].startswith('HOLD') else 422, result)
850
+ except (AgentHold, ValueError, TypeError, KeyError, RecursionError, OSError) as ex:
851
+ code = str(ex)
852
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}', code):
853
+ code = 'AGENT_HARNESS_FAILED_CLOSED'
854
+ return self.output(422, {'status':'HOLD','reason':code,
855
+ 'production':'HOLD','source_mutation':'NONE'})
856
+ if route in ('/api/ai/test','/api/agent/draft'):
857
+ if self.headers.get('X-DevAgent-Request')!='agent-session-v1' or origin is None:
858
+ return self.output(403,{'error':'AGENT_CSRF_PROTECTION_REQUIRED'})
859
+ try:
860
+ from .agent_runtime import connectivity,run_agent,AgentHold
861
+ raw=strict_loads(self.rfile.read(size))
862
+ result=connectivity(raw) if route=='/api/ai/test' else run_agent(raw)
863
+ return self.output(200 if not result['status'].startswith('HOLD') else 422,result)
864
+ except AgentHold as e:
865
+ # Expose only internal HOLD codes, never model text or secrets.
866
+ code=str(e)
867
+ if not re.fullmatch(r'[A-Z][A-Z0-9_]{1,119}',code):
868
+ code='AGENT_PROVIDER_OR_ENGINEERING_VALIDATION_FAILED'
869
+ return self.output(422,{'status':'HOLD','reason':code,
870
+ 'detail':'No project was changed. Review provider permissions, model compatibility, or engineering inputs.'})
871
+ except (ValueError,TypeError,KeyError,RecursionError,UnicodeError):
872
+ return self.output(422,{'status':'HOLD','reason':'AGENT_PROVIDER_OR_ENGINEERING_VALIDATION_FAILED',
873
+ 'detail':'No project was changed.'})
874
+ if route not in ('/api/validate','/api/build'):
875
+ return self.output(404,{'error':'NOT_FOUND'})
876
+ if route=='/api/build' and not self.server.allow_legacy_concept_build:
877
+ return self.output(403,{'status':'HOLD','reason':'AI_ENGINEERING_PIPELINE_REQUIRED',
878
+ 'next_action':'USE_ENGINEER_PREPARE_AND_REVIEWED_BUILD',
879
+ 'production':'HOLD'})
880
+ try:
881
+ payload=strict_loads(self.rfile.read(size))
882
+ data=from_wizard(payload)
883
+ except (BuildHold, ValueError, KeyError, TypeError, RecursionError, UnicodeError) as e:
884
+ return self.output(400,{'status':'HOLD','reason':str(e)[:350]})
885
+ report=qualify(data)
886
+ if route=='/api/validate':return self.output(200,report)
887
+ if report['status']=='HOLD':return self.output(422,report)
888
+ try:
889
+ slug=slugify(data['name']);id=f'{slug}-{uuid.uuid4().hex[:8]}'
890
+ result=build(data,self.server.workspace/id,vendor_three=self.server.vendor_three)
891
+ except (BuildHold,ValueError,OSError,TypeError,RecursionError) as e:return self.output(422,{'status':'HOLD','reason':str(e)[:350]})
892
+ return self.output(201,{'status':result['status'],'project_id':id,'url':f'/projects/{id}/viewer/index.html',
893
+ 'warnings':result['issues'],'fat_cases':result['fat_cases_generated'],
894
+ 'renderer':'THREE_JS_R180' if result['three_vendor_bundled'] else 'SCHEMATIC_FALLBACK'})