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,318 @@
1
+ """V0.7: create-only, offline, read-only Perspective candidate + route.
2
+
3
+ DOES NOT import to Gateway, connect to PLC, certify physical mappings or execute FAT.
4
+ Binding source is the explicit engineer-owned lab manifest; no NodeId -> tag path inference.
5
+ All generated resources are sanitized: no scripts, events, outputs, commands, or writes.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import stat
14
+ import tempfile
15
+ import zipfile
16
+ from copy import deepcopy
17
+ from pathlib import Path
18
+
19
+ from .generator import fat_inventory, slugify, ROOT
20
+ from .ignition_native import NativeHold, _j, _label, _flex, build_view, _template_reference, native_member_names, sha, VIEW_PATH
21
+ from .ignition_project import _project_blob, _zip_blob
22
+ from .model import qualify
23
+ from .strict_json import loads as strict_loads
24
+
25
+ SCHEMA = 'devagent.ignition.v07.lab_compiler.v1'
26
+ MANIFEST_SCHEMA = 'devagent.ignition.v07.lab_bindings.v1'
27
+ CONFIRM = 'ISOLATED_OFFLINE_COMPILER_ONLY'
28
+ PAGE_CONFIG = 'com.inductiveautomation.perspective/page-config/config.json'
29
+ PAGE_RESOURCE = 'com.inductiveautomation.perspective/page-config/resource.json'
30
+ LAB_PATH = re.compile(r'^\[DevAgentLab[A-Za-z0-9_]{0,32}\](?:[A-Za-z0-9_][A-Za-z0-9_ -]{0,59}/)*[A-Za-z0-9_][A-Za-z0-9_ -]{0,59}$')
31
+ H64 = re.compile(r'^[0-9a-f]{64}$')
32
+
33
+
34
+ def canonical(obj: dict) -> bytes:
35
+ return (json.dumps(obj, sort_keys=True, ensure_ascii=False, separators=(',', ':'))+'\n').encode('utf-8')
36
+
37
+
38
+ def model_sha(model: dict) -> str:
39
+ return hashlib.sha256(canonical(model)).hexdigest()
40
+
41
+
42
+ def manifest_skeleton(model: dict) -> dict:
43
+ if qualify(model)['status'] != 'PASS_STATIC_CONTRACT_ONLY':
44
+ raise NativeHold('CUSTOMER_MODEL_HOLD')
45
+ return {'schema': MANIFEST_SCHEMA, 'model_sha256': model_sha(model),
46
+ 'scope': 'ISOLATED_TEST_GATEWAY_ONLY', 'engineer': '',
47
+ 'bindings': [{'signal_id':s['id'],'asset_id':s['asset_id'],'role':s['role'],
48
+ 'source':deepcopy(s['source']),'tag_path':'',
49
+ 'approval':'PENDING_ENGINEER_REVIEW'} for s in model.get('signals',[])],
50
+ 'note':'No tag paths are inferred. Provide only isolated DevAgentLab provider paths from reviewed test Gateway.'}
51
+
52
+
53
+ def validate_manifest(model: dict, manifest: dict) -> dict:
54
+ if qualify(model)['status']!='PASS_STATIC_CONTRACT_ONLY':raise NativeHold('CUSTOMER_MODEL_HOLD')
55
+ if not isinstance(manifest,dict) or set(manifest)!={'schema','model_sha256','scope','engineer','bindings','note'}:
56
+ raise NativeHold('BINDING_MANIFEST_SCHEMA_INVALID')
57
+ if manifest['schema']!=MANIFEST_SCHEMA or manifest['model_sha256']!=model_sha(model):
58
+ raise NativeHold('BINDING_MANIFEST_MODEL_IDENTITY_MISMATCH')
59
+ if manifest['scope']!='ISOLATED_TEST_GATEWAY_ONLY':raise NativeHold('BINDING_SCOPE_NOT_ISOLATED')
60
+ if not isinstance(manifest['engineer'],str) or not 3<=len(manifest['engineer'].strip())<=120:
61
+ raise NativeHold('ENGINEER_ID_REQUIRED')
62
+ if not isinstance(manifest['note'],str) or len(manifest['note'])>600:
63
+ raise NativeHold('BINDING_MANIFEST_NOTE_INVALID')
64
+ bindings=manifest['bindings']
65
+ signals={s['id']:s for s in model['signals']}
66
+ if not signals or not isinstance(bindings,list) or len(bindings)!=len(signals):
67
+ raise NativeHold('ALL_DECLARED_SIGNALS_REQUIRE_EXPLICIT_DISPOSITION')
68
+ seen,paths,approved=set(),set(),{}
69
+ for entry in bindings:
70
+ if not isinstance(entry,dict) or set(entry)!={'signal_id','asset_id','role','source','tag_path','approval'}:
71
+ raise NativeHold('BINDING_ENTRY_SCHEMA_INVALID')
72
+ sid=entry['signal_id']
73
+ if not isinstance(sid,str) or sid not in signals or sid in seen:raise NativeHold('BINDING_SIGNAL_ID_UNKNOWN_OR_DUPLICATE')
74
+ seen.add(sid);s=signals[sid]
75
+ if any(entry[f]!=s[f] for f in ('asset_id','role','source')):
76
+ raise NativeHold('BINDING_SOURCE_OR_ASSET_MISMATCH: '+sid)
77
+ path=entry['tag_path']; decision=entry['approval']
78
+ if decision=='PENDING_ENGINEER_REVIEW':
79
+ if path!='':raise NativeHold('PENDING_BINDING_CANNOT_HAVE_PATH: '+sid)
80
+ elif decision=='APPROVED_FOR_ISOLATED_FAT_ONLY':
81
+ if not isinstance(path,str) or len(path)>220 or not LAB_PATH.fullmatch(path):
82
+ raise NativeHold('UNSAFE_OR_NON_LAB_TAG_PATH: '+sid)
83
+ if path in paths:raise NativeHold('DUPLICATE_LAB_TAG_PATH: '+sid)
84
+ paths.add(path);approved[sid]=path
85
+ else:
86
+ raise NativeHold('BINDING_APPROVAL_INVALID: '+sid)
87
+ if seen!=set(signals):raise NativeHold('BINDING_SIGNAL_COVERAGE_MISMATCH')
88
+ # Zero independently approved paths is a valid commissioning state. The
89
+ # generated HMI must show NOT MAPPED rather than invent a Gateway binding.
90
+ return approved
91
+
92
+
93
+ def _walk(component):
94
+ yield component
95
+ for child in component.get('children',[]):yield from _walk(child)
96
+
97
+
98
+ def _bound_display(signal:dict, path: str | None):
99
+ label=_label('UNKNOWN' if path else 'NOT MAPPED',13,True,
100
+ '#e6bd70' if path is None else '#ecf5ff',name='LiveValue')
101
+ if path:
102
+ # Perspective defaults must retain BAD/STALE quality overlays (no opt-out).
103
+ label['propConfig']={'props.text':{'binding':{'type':'tag',
104
+ 'config':{'mode':'direct','tagPath':path}}}}
105
+ return _flex([
106
+ _label('RAW '+signal['role'].replace('_',' ').upper(),11,False,'#82a6b9',name='SignalRole'),
107
+ label,
108
+ _label(signal.get('unit','') or ' ',10,False,'#82a6b9',name='EngineeringUnit')
109
+ ],name='SignalRow',direction='row',style={'alignItems':'center','justifyContent':'space-between',
110
+ 'gap':12,'borderTop':'1px solid #294b61','paddingTop':7,'paddingBottom':3})
111
+
112
+
113
+ def bound_view(model:dict,manifest:dict):
114
+ approved=validate_manifest(model,manifest)
115
+ view=build_view(model)
116
+ # Preserve R9 geometry and safety semantics while applying approved brand palette.
117
+ design=strict_loads((ROOT/'design-system.json').read_text(encoding='utf8'))
118
+ brand=design['palettes'][model.get('palette','r9_cyan')]
119
+ token_map={'#07131f':brand['background'], '#0e2638':brand['panel'],
120
+ '#294b61':brand['border'], '#58c1dc':brand['accent'],
121
+ '#82a6b9':brand['muted']}
122
+ # Replace the conceptual rows, never reuse untrusted reference scripts.
123
+ signals={s['id']:s for s in model['signals']}
124
+ by_asset={e['id']:[] for e in model['equipment']}
125
+ for s in model['signals']:by_asset[s['asset_id']].append(s)
126
+ for comp in _walk(view['root']):
127
+ if comp.get('meta',{}).get('name')!='AssetCard':continue
128
+ idlabels=[c for c in comp['children'] if c.get('meta',{}).get('name')=='AssetId']
129
+ if len(idlabels)!=1:raise NativeHold('NATIVE_ASSET_CARD_ID_INVALID')
130
+ eid=idlabels[0]['props']['text']
131
+ static=[c for c in comp['children'] if c.get('meta',{}).get('name') in ('AssetId','AssetName','AssetDetail')]
132
+ comp['children']=static+[_bound_display(s,approved.get(s['id']))
133
+ for s in sorted(by_asset[eid],key=lambda item:item['role'])]
134
+ if not by_asset[eid]:comp['children'].append(_label('NOT MAPPED',12,True,'#e6bd70',name='UnmappedAsset'))
135
+ # VIEW BUILD: Never present unverified values as real PLC telemetry.
136
+ for comp in _walk(view['root']):
137
+ if comp.get('meta',{}).get('name')=='ReleaseNotice':
138
+ comp['props']['text']='ISOLATED LAB CANDIDATE • BINDINGS NOT PHYSICALLY VERIFIED • READ ONLY'
139
+ def repaint(value):
140
+ if isinstance(value,dict):return {k:repaint(v) for k,v in value.items()}
141
+ if isinstance(value,list):return [repaint(x) for x in value]
142
+ if isinstance(value,str):
143
+ for old,new in token_map.items():value=value.replace(old,new)
144
+ return value
145
+ view=repaint(view)
146
+ if sum(1 for _ in _walk(view['root']))>1600:raise NativeHold('NATIVE_VIEW_COMPLEXITY_LIMIT')
147
+ return view
148
+
149
+
150
+ def _page_resource(reference:Path,template_view:str):
151
+ match=VIEW_PATH.fullmatch(template_view)
152
+ if not match:raise NativeHold('TEMPLATE_VIEW_PATH_UNSUPPORTED')
153
+ prefix=match.group('prefix')
154
+ pc,pr=prefix+PAGE_CONFIG,prefix+PAGE_RESOURCE
155
+ with zipfile.ZipFile(reference) as z:
156
+ names=set(z.namelist())
157
+ if pc not in names or pr not in names:
158
+ raise NativeHold('REAL_REFERENCE_PAGE_CONFIG_REQUIRED')
159
+ config=_j(z.read(pc),'REFERENCE_PAGE_CONFIG')
160
+ resource=_j(z.read(pr),'REFERENCE_PAGE_RESOURCE')
161
+ if not isinstance(config,dict) or not isinstance(config.get('pages'),dict):
162
+ raise NativeHold('REFERENCE_PAGE_CONFIG_SHAPE_UNSUPPORTED')
163
+ if not isinstance(resource,dict) or 'config.json' not in resource.get('files',[]):
164
+ raise NativeHold('REFERENCE_PAGE_RESOURCE_SHAPE_UNSUPPORTED')
165
+ return pc,pr,resource
166
+
167
+
168
+ def expected_members(model:dict,manifest:dict,reference:Path,template_view:str,
169
+ reference_sha256:str,new_project_name:str,*,project_enabled:bool=True):
170
+ approved=validate_manifest(model,manifest)
171
+ if not H64.fullmatch(reference_sha256):raise NativeHold('EXACT_REFERENCE_SHA256_REQUIRED')
172
+ reference=Path(reference)
173
+ index,base_resource=_template_reference(reference,template_view,reference_sha256)
174
+ # This routed lab artifact is executable only as a new isolated project.
175
+ # It remains read-only and production-held. The older generic project
176
+ # candidate API stays disabled by default.
177
+ project_path,project_blob,project_info=_project_blob(
178
+ reference,template_view,new_project_name,enabled=project_enabled)
179
+ pc,pr,page_res=_page_resource(reference,template_view)
180
+ name=slugify(model['name']).replace('-','_')
181
+ view_path,res_path=native_member_names(template_view,name)
182
+ if view_path in index['views']:raise NativeHold('TARGET_VIEW_ALREADY_EXISTS_IN_REFERENCE')
183
+ if len(model.get('equipment',[]))>150:raise NativeHold('EQUIPMENT_LIMIT')
184
+ def resource_from(template,filename):
185
+ metadata={k:deepcopy(template[k]) for k in ('scope','version','restricted','overridable') if k in template}
186
+ metadata['attributes']={}
187
+ metadata['files']=[filename]
188
+ return metadata
189
+ view=bound_view(model,manifest)
190
+ route={'pages':{'/':{'viewPath':'DevAgentGenerated/'+name,
191
+ 'title':'DevAgent • '+model['name'][:85]}},'sharedDocks':{}}
192
+ members={project_path:project_blob,
193
+ view_path:canonical(view),res_path:canonical(resource_from(base_resource,'view.json')),
194
+ pc:canonical(route),pr:canonical(resource_from(page_res,'config.json'))}
195
+ if len(members)!=5:raise NativeHold('NATIVE_MEMBER_COLLISION')
196
+ return members,{'approved_bindings':len(approved),'signals':len(model['signals']),
197
+ 'unmapped_signals':len(model['signals'])-len(approved),
198
+ 'project_source_info':project_info,'page_route':'/',
199
+ 'fat_signal_coverage':len([c for c in fat_inventory(model) if c.get('signal_id')])}
200
+
201
+
202
+ def _verify_resource_safety(members:dict,signal_count:int,approved_count:int):
203
+ if len(members)!=5:raise NativeHold('PROJECT_RESOURCE_COUNT_INVALID')
204
+ views=[k for k in members if k.endswith('/view.json')]
205
+ if len(views)!=1:raise NativeHold('VIEW_COUNT_INVALID')
206
+ v=strict_loads(members[views[0]].decode('utf8'))
207
+ count=0;names=[]
208
+ def walk(c):
209
+ nonlocal count
210
+ count+=1
211
+ if not isinstance(c,dict) or c.get('type') not in ('ia.container.flex','ia.display.label'):
212
+ raise NativeHold('NON_ALLOWLISTED_COMPONENT')
213
+ if any(k in c for k in ('events','customMethods','scripts','onChange')):
214
+ raise NativeHold('ACTIVE_COMPONENT_FEATURE_PROHIBITED')
215
+ for binding in c.get('propConfig',{}).values():
216
+ if set(binding)!={'binding'} or set(binding['binding'])!={'type','config'}:
217
+ raise NativeHold('BINDING_FEATURE_NOT_ALLOWLISTED')
218
+ if binding['binding']['type']!='tag':raise NativeHold('NON_TAG_BINDING_DISALLOWED')
219
+ cfg=binding['binding']['config']
220
+ if set(cfg)!={'mode','tagPath'} or cfg['mode']!='direct' or not LAB_PATH.fullmatch(cfg['tagPath']):
221
+ raise NativeHold('NON_LAB_BINDING_REJECTED')
222
+ names.append(cfg['tagPath'])
223
+ for child in c.get('children',[]):walk(child)
224
+ walk(v['root'])
225
+ if count>1600 or len(names)!=approved_count or len(names)!=len(set(names)) or len(names)>signal_count:
226
+ raise NativeHold('COMPILER_BINDING_COUNT_OR_COMPLEXITY_MISMATCH')
227
+ # Sanity guard for serialized write/script tokens, even if injected in innocuous attributes.
228
+ serialized=json.dumps(v).lower()
229
+ for token in ('writeblocking','writeasync','system.opc.write','onactionperformed','"bidirectional": true',
230
+ '"overlayoptout": true','"script"'):
231
+ if token in serialized:raise NativeHold('FORBIDDEN_RUNTIME_FEATURE: '+token)
232
+ return names
233
+
234
+
235
+ def compile_lab(model,manifest,reference,template_view,reference_sha256,new_project_name,out,confirm):
236
+ if confirm!=CONFIRM:raise NativeHold('EXPLICIT_ISOLATED_COMPILER_CONFIRMATION_REQUIRED')
237
+ target=Path(out).expanduser().absolute()
238
+ if target.suffix.lower()!='.zip' or not target.parent.is_dir() or target.exists() or target.is_symlink():
239
+ raise NativeHold('NEW_ZIP_OUTPUT_REQUIRED_CREATE_ONLY')
240
+ reference=Path(reference).expanduser().absolute()
241
+ before=sha(reference)
242
+ members,meta=expected_members(model,manifest,reference,template_view,reference_sha256,new_project_name)
243
+ if before!=sha(reference):raise NativeHold('REFERENCE_CHANGED_DURING_COMPILATION')
244
+ _verify_resource_safety(members,meta['signals'],meta['approved_bindings'])
245
+ payload=_zip_blob(members)
246
+ fd,temp=tempfile.mkstemp(prefix='.devagent-v07-',suffix='.zip',dir=str(target.parent))
247
+ try:
248
+ with os.fdopen(fd,'wb') as f:
249
+ f.write(payload);f.flush();os.fsync(f.fileno())
250
+ if Path(temp).read_bytes()!=payload:raise NativeHold('STAGED_PROJECT_BYTES_CHANGED')
251
+ try:os.link(temp,target)
252
+ except FileExistsError:raise NativeHold('OUTPUT_ALREADY_EXISTS_AT_PUBLISH') from None
253
+ try:os.chmod(target,0o600)
254
+ except OSError:pass
255
+ finally:Path(temp).unlink(missing_ok=True)
256
+ return {'schema':SCHEMA,'status':'PASS_OFFLINE_BOUND_PROJECT_CANDIDATE_ONLY',
257
+ 'project_zip':str(target),'project_sha256':sha(target),
258
+ 'reference_sha256':before,'model_sha256':model_sha(model),
259
+ 'manifest_sha256':hashlib.sha256(canonical(manifest)).hexdigest(),
260
+ 'native_view':'GENERATED','native_page_routing':'GENERATED_UNTESTED',
261
+ 'tag_bindings':'GENERATED_UNTESTED',**meta,
262
+ 'project_enabled_for_isolated_test':True,
263
+ 'unmapped_display_policy':'NOT MAPPED',
264
+ 'runtime_import':'NOT_TESTED','gateway_fat':'NOT_EXECUTED',
265
+ 'physical_mapping':'NOT_VERIFIED','production_release':'HOLD'}
266
+
267
+
268
+ def verify_lab(model,manifest,reference,template_view,reference_sha256,new_project_name,candidate):
269
+ target=Path(candidate)
270
+ if not target.is_file() or target.is_symlink():raise NativeHold('CANDIDATE_NOT_SAFE_FILE')
271
+ members,meta=expected_members(model,manifest,Path(reference),template_view,reference_sha256,new_project_name)
272
+ if target.stat().st_size>4_000_000:raise NativeHold('CANDIDATE_TOO_LARGE')
273
+ try:
274
+ with zipfile.ZipFile(target) as z:
275
+ actual=z.infolist()
276
+ if len(actual)!=5 or set(z.namelist())!=set(members) or z.testzip():
277
+ raise NativeHold('CANDIDATE_RESOURCE_ALLOWLIST_HOLD')
278
+ for item in actual:
279
+ if item.flag_bits&1 or stat.S_IFMT((item.external_attr>>16)&0xffff)==stat.S_IFLNK:
280
+ raise NativeHold('CANDIDATE_SYMLINK_OR_ENCRYPTED')
281
+ if z.read(item)!=members[item.filename]:raise NativeHold('CANDIDATE_REPRODUCIBILITY_MISMATCH')
282
+ except zipfile.BadZipFile:raise NativeHold('CANDIDATE_ZIP_INVALID') from None
283
+ if target.read_bytes()!=_zip_blob(members):raise NativeHold('CANDIDATE_NOT_BYTE_REPRODUCIBLE')
284
+ _verify_resource_safety(members,meta['signals'],meta['approved_bindings'])
285
+ return {'schema':SCHEMA,'status':'PASS_OFFLINE_RESOURCE_REPRODUCIBILITY_ONLY',
286
+ 'project_sha256':sha(target),**meta,'gateway_fat':'NOT_EXECUTED',
287
+ 'project_enabled_for_isolated_test':True,
288
+ 'unmapped_display_policy':'NOT MAPPED',
289
+ 'runtime_import':'NOT_TESTED','production_release':'HOLD'}
290
+
291
+
292
+ def fat_plan(model:dict,manifest:dict) -> dict:
293
+ approved=validate_manifest(model,manifest)
294
+ inventory=fat_inventory(model)
295
+ per_signal={s['id']:[] for s in model['signals']}
296
+ for case in inventory:
297
+ if case.get('signal_id') in per_signal:per_signal[case['signal_id']].append(case['id'])
298
+ if any(len(cases)!=1 for cases in per_signal.values()):
299
+ raise NativeHold('FAT_INVENTORY_LACKS_EXACTLY_ONE_CASE_PER_SIGNAL')
300
+ cases=[]
301
+ for s in model['signals']:
302
+ cases.append({'id':per_signal[s['id']][0], 'signal_id':s['id'],
303
+ 'equipment_id':s['asset_id'],'role':s['role'],
304
+ 'tag_binding':'LAB_DECLARED_NOT_GATEWAY_VERIFIED' if s['id'] in approved else 'NOT_MAPPED',
305
+ 'phases':['VALID_SOURCE_IDENTITY','OPC_READBACK','IGNITION_QUALIFIED_VALUE',
306
+ 'PERSPECTIVE_RENDER','GOOD_TO_BAD_QUALITY','STALE_OR_COMMS_LOSS'],
307
+ 'result':'HOLD_NOT_EXECUTED','evidence':[], 'plc_write_allowed':False})
308
+ requirements=[{'id':'REQ_'+r['id'],'requirement_id':r['id'],
309
+ 'text':r['text'],'result':'HOLD_NOT_EXECUTED',
310
+ 'reason':'ACCEPTANCE_CRITERIA_AND_SIGNAL_TRACEABILITY_REQUIRE_ENGINEER_APPROVAL',
311
+ 'required_evidence':['APPROVED_ACCEPTANCE_CRITERIA','IGNITION_HMI_FAT','ENGINEER_SIGNOFF']}
312
+ for r in model.get('requirements',[])]
313
+ return {'schema':'devagent.fat.v07.execution_plan.v1',
314
+ 'model_sha256':model_sha(model),'signal_count':len(model['signals']),
315
+ 'individual_signal_coverage':len(cases),
316
+ 'mapping_coverage':len(approved),'requirements_count':len(model.get('requirements',[])),
317
+ 'cases':cases,'requirement_cases':requirements,'gateway_fat':'NOT_EXECUTED','physical_mapping':'NOT_VERIFIED',
318
+ 'production_release':'HOLD'}