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.
- devagent_hmi.py +471 -0
- devagent_ignition-0.1.0.dist-info/METADATA +204 -0
- devagent_ignition-0.1.0.dist-info/RECORD +133 -0
- devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
- devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
- devagent_spatial/__init__.py +1 -0
- devagent_spatial/_resources/README.md +187 -0
- devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
- devagent_spatial/_resources/SHA256SUMS.txt +270 -0
- devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
- devagent_spatial/_resources/assets/agent.js +102 -0
- devagent_spatial/_resources/assets/creator.css +247 -0
- devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
- devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
- devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
- devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
- devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
- devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
- devagent_spatial/_resources/assets/customer_documents.js +110 -0
- devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
- devagent_spatial/_resources/assets/facility2d.css +24 -0
- devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
- devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
- devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
- devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
- devagent_spatial/_resources/assets/fallback_scene.js +17 -0
- devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
- devagent_spatial/_resources/assets/harness.js +78 -0
- devagent_spatial/_resources/assets/live_design_review.js +129 -0
- devagent_spatial/_resources/assets/operations_ui.js +305 -0
- devagent_spatial/_resources/assets/operator_viewer.html +64 -0
- devagent_spatial/_resources/assets/project_package.js +52 -0
- devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
- devagent_spatial/_resources/assets/runtime_feed.js +30 -0
- devagent_spatial/_resources/assets/studio.html +139 -0
- devagent_spatial/_resources/assets/style.css +191 -0
- devagent_spatial/_resources/assets/test_workspace.html +195 -0
- devagent_spatial/_resources/assets/three_scene.js +442 -0
- devagent_spatial/_resources/assets/unified_engineer.js +101 -0
- devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
- devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
- devagent_spatial/_resources/assets/viewer.html +45 -0
- devagent_spatial/_resources/assets/viewer.js +112 -0
- devagent_spatial/_resources/design-system.json +130 -0
- devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
- devagent_spatial/_resources/examples/cv101_guided.json +272 -0
- devagent_spatial/_resources/examples/make_examples.py +36 -0
- devagent_spatial/_resources/examples/single_floor.json +444 -0
- devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
- devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
- devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
- devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
- devagent_spatial/_resources/examples/warehouse.json +708 -0
- devagent_spatial/_resources/examples/water.json +288 -0
- devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
- devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
- devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
- devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
- devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
- devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
- devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
- devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
- devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
- devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
- devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
- devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
- devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
- devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
- devagent_spatial/_resources/reference/v8-original/README.md +36 -0
- devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
- devagent_spatial/_resources/reference/v8-original/model.py +124 -0
- devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
- devagent_spatial/_resources/reference/v8-original/server.py +100 -0
- devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
- devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
- devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
- devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
- devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
- devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
- devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
- devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
- devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
- devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
- devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
- devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
- devagent_spatial/_resources/templates/water_connections.csv +7 -0
- devagent_spatial/_resources/templates/water_equipment.csv +10 -0
- devagent_spatial/_resources/templates/water_signals.csv +12 -0
- devagent_spatial/agent_harness.py +299 -0
- devagent_spatial/agent_runtime.py +314 -0
- devagent_spatial/contract_opc_fat.py +216 -0
- devagent_spatial/contract_opc_source.py +265 -0
- devagent_spatial/control_intents.py +38 -0
- devagent_spatial/customer_intake.py +235 -0
- devagent_spatial/engineering_expertise.py +114 -0
- devagent_spatial/generator.py +201 -0
- devagent_spatial/ignition_native.py +234 -0
- devagent_spatial/ignition_project.py +144 -0
- devagent_spatial/live_lab_binding.py +128 -0
- devagent_spatial/model.py +297 -0
- devagent_spatial/native_reference.py +69 -0
- devagent_spatial/opc_snapshot.py +87 -0
- devagent_spatial/operations.py +284 -0
- devagent_spatial/operations_capture.py +47 -0
- devagent_spatial/operations_lab.py +64 -0
- devagent_spatial/original_r9.py +169 -0
- devagent_spatial/original_v8.py +81 -0
- devagent_spatial/preflight.py +140 -0
- devagent_spatial/preview_simulation.py +189 -0
- devagent_spatial/project_package.py +101 -0
- devagent_spatial/qualification.py +152 -0
- devagent_spatial/strict_json.py +29 -0
- devagent_spatial/studio.py +894 -0
- devagent_spatial/studio_workflow.py +277 -0
- devagent_spatial/test_reporting.py +87 -0
- devagent_spatial/v076_opc_fat.py +180 -0
- devagent_spatial/v078_commands.py +277 -0
- devagent_spatial/v078_native_commands.py +161 -0
- devagent_spatial/v07_fat.py +110 -0
- devagent_spatial/v07_native.py +318 -0
- 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'})
|