fraclab-sdk 0.1.0__py3-none-any.whl → 0.1.2__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.
@@ -0,0 +1,331 @@
1
+ """Run page: edit params for existing runs and execute."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ import streamlit as st
8
+
9
+ from fraclab_sdk.algorithm import AlgorithmLibrary
10
+ from fraclab_sdk.config import SDKConfig
11
+ from fraclab_sdk.run import RunManager, RunStatus
12
+ from fraclab_sdk.workbench import ui_styles
13
+
14
+ st.set_page_config(page_title="Run", page_icon="▶️", layout="wide", initial_sidebar_state="expanded")
15
+ st.title("Run")
16
+
17
+ ui_styles.apply_global_styles()
18
+
19
+ # Guidance: if no params UI shows up, validate InputSpec via the editor page.
20
+ st.info(
21
+ "看不到参数输入框?请在 Schema Editor 页面点击 Validate 检查 "
22
+ "`schema/inputspec.py`,确保生成的 schema 可用。",
23
+ icon="ℹ️",
24
+ )
25
+ # --- Page-Specific CSS ---
26
+ st.markdown("""
27
+ <style>
28
+ /* Compact form labels */
29
+ div[data-testid="stNumberInput"] label,
30
+ div[data-testid="stTextInput"] label,
31
+ div[data-testid="stCheckbox"] label {
32
+ margin-bottom: 0px !important;
33
+ font-size: 0.85rem !important;
34
+ color: #666 !important;
35
+ }
36
+
37
+ /* Divider spacing */
38
+ hr { margin-top: 1rem; margin-bottom: 1rem; }
39
+ </style>
40
+ """, unsafe_allow_html=True)
41
+
42
+ config = SDKConfig()
43
+ run_mgr = RunManager(config)
44
+ algo_lib = AlgorithmLibrary(config)
45
+
46
+
47
+ # --- Intelligent Layout Engine ---
48
+
49
+ def _is_compact_field(schema: dict) -> bool:
50
+ """Determine if a field is small enough to fit in a grid column."""
51
+ ftype = schema.get("type")
52
+ # Numbers, Booleans, and short Strings (without enums/long defaults) are compact
53
+ if ftype in ["integer", "number", "boolean"]:
54
+ return True
55
+ if ftype == "string" and len(str(schema.get("default", ""))) < 50:
56
+ return True
57
+ return False
58
+
59
+ def render_field_widget(key: str, schema: dict, value: Any, path: str) -> Any:
60
+ """Render a single widget based on schema type."""
61
+ ftype = schema.get("type")
62
+ title = schema.get("title") or key
63
+ # Simplify label: if title is camelCase, maybe split it? For now use title directly.
64
+ # description = schema.get("description") # Tooltip is enough, don't clutter UI text
65
+
66
+ default_val = schema.get("default")
67
+ help_text = schema.get("description")
68
+
69
+ if ftype == "string":
70
+ val = value if value is not None else (default_val or "")
71
+ return st.text_input(title, value=val, help=help_text, key=path)
72
+
73
+ if ftype == "number":
74
+ val = value if value is not None else default_val
75
+ return st.number_input(title, value=float(val or 0.0), help=help_text, key=path)
76
+
77
+ if ftype == "integer":
78
+ val = value if value is not None else default_val
79
+ return int(st.number_input(title, value=int(val or 0), step=1, help=help_text, key=path))
80
+
81
+ if ftype == "boolean":
82
+ val = value if value is not None else default_val
83
+ # Toggle looks better than checkbox in grid
84
+ return st.toggle(title, value=bool(val), help=help_text, key=path)
85
+
86
+ if ftype == "array":
87
+ # Arrays are complex, stick to full width expansion
88
+ return _render_json_editor(title, value, default_val, help_text, path)
89
+
90
+ if ftype == "object":
91
+ # Nested object -> Recursive layout
92
+ with st.container(border=True):
93
+ st.markdown(f"**{title}**")
94
+ props = schema.get("properties", {})
95
+ obj = value if isinstance(value, dict) else (default_val if isinstance(default_val, dict) else {})
96
+
97
+ # Recursive call to grid layout
98
+ return render_schema_grid(props, obj, path)
99
+
100
+ # Fallback
101
+ return _render_json_editor(title, value, default_val, help_text, path)
102
+
103
+ def _render_json_editor(title, value, default, help_text, path):
104
+ """Helper for raw JSON fields."""
105
+ st.markdown(f"<small>{title}</small>", unsafe_allow_html=True)
106
+ current = value if value is not None else (default if default is not None else [])
107
+ text = st.text_area(
108
+ title,
109
+ value=json.dumps(current, indent=2, ensure_ascii=False),
110
+ help=f"{help_text} (Edit as JSON)",
111
+ key=path,
112
+ label_visibility="collapsed",
113
+ height=100
114
+ )
115
+ try:
116
+ return json.loads(text) if text.strip() else current
117
+ except Exception:
118
+ return current
119
+
120
+ def render_schema_grid(properties: Dict[str, dict], current_values: Dict[str, Any], prefix: str) -> Dict[str, Any]:
121
+ """
122
+ Renders fields in a smart grid layout:
123
+ - Compact fields (numbers, bools) get packed into columns (up to 4).
124
+ - Wide fields (objects, arrays) break the line and take full width.
125
+ """
126
+ result = {}
127
+
128
+ # 1. Separate fields into groups to maintain partial order while grid-packing
129
+ # Strategy: Iterate and buffer compact fields. Flush buffer when a wide field hits.
130
+
131
+ compact_buffer = [] # List of (key, schema)
132
+
133
+ def flush_buffer():
134
+ if not compact_buffer:
135
+ return
136
+
137
+ # Calculate optimal columns (max 4, min 2)
138
+ n_items = len(compact_buffer)
139
+ n_cols = 4 if n_items >= 4 else (n_items if n_items > 0 else 1)
140
+
141
+ # Split into rows if > 4 items? Simple logic: Just wrap
142
+ # Actually st.columns handles wrapping poorly, better to batch by 4
143
+
144
+ for i in range(0, n_items, 4):
145
+ batch = compact_buffer[i : i+4]
146
+ cols = st.columns(len(batch))
147
+ for col, (b_key, b_schema) in zip(cols, batch):
148
+ with col:
149
+ val = current_values.get(b_key)
150
+ result[b_key] = render_field_widget(b_key, b_schema, val, f"{prefix}.{b_key}")
151
+
152
+ compact_buffer.clear()
153
+
154
+ for key, prop_schema in properties.items():
155
+ if _is_compact_field(prop_schema):
156
+ compact_buffer.append((key, prop_schema))
157
+ else:
158
+ # Wide field encountered: flush buffer first
159
+ flush_buffer()
160
+ # Render wide field
161
+ val = current_values.get(key)
162
+ result[key] = render_field_widget(key, prop_schema, val, f"{prefix}.{key}")
163
+
164
+ # Final flush
165
+ flush_buffer()
166
+
167
+ # Preserve extra keys in current_values that aren't in schema
168
+ for k, v in current_values.items():
169
+ if k not in result:
170
+ result[k] = v
171
+
172
+ return result
173
+
174
+
175
+ def load_params(run_dir: Path) -> dict:
176
+ path = run_dir / "input" / "params.json"
177
+ try:
178
+ return json.loads(path.read_text())
179
+ except Exception:
180
+ return {}
181
+
182
+
183
+ # ==========================================
184
+ # Main Logic
185
+ # ==========================================
186
+
187
+ runs = run_mgr.list_runs()
188
+
189
+ if not runs:
190
+ st.info("No runs available. Create a run from the Selection page.")
191
+ st.stop()
192
+
193
+ pending_runs = [r for r in runs if r.status == RunStatus.PENDING]
194
+ other_runs = [r for r in runs if r.status != RunStatus.PENDING]
195
+
196
+ # ------------------------------------------
197
+ # 1. Pending Runs (Editor Workspace)
198
+ # ------------------------------------------
199
+ st.subheader("Pending Runs")
200
+
201
+ if not pending_runs:
202
+ st.info("No pending runs waiting for execution.")
203
+ else:
204
+ # Use tabs for context switching
205
+ tabs = st.tabs([f"⚙️ {run.run_id}" for run in pending_runs])
206
+
207
+ for tab, run in zip(tabs, pending_runs):
208
+ with tab:
209
+ run_dir = run_mgr.get_run_dir(run.run_id)
210
+ algo_handle = algo_lib.get_algorithm(run.algorithm_id, run.algorithm_version)
211
+ schema = algo_handle.params_schema
212
+ current_params = load_params(run_dir)
213
+
214
+ # --- Layout: Top Info Bar ---
215
+ with st.container(border=True):
216
+ c1, c2, c3, c4 = st.columns([2, 2, 2, 1])
217
+ with c1: st.caption(f"**Snapshot:** `{run.snapshot_id}`")
218
+ with c2: st.caption(f"**Algo:** `{run.algorithm_id}` v{run.algorithm_version}")
219
+ with c3: st.caption(f"**Created:** {run.created_at}")
220
+ with c4:
221
+ # Timeout setting tucked away here
222
+ timeout = st.number_input("Timeout (s)", value=300, step=10, key=f"to_{run.run_id}", label_visibility="collapsed")
223
+
224
+ # --- Layout: Parameters Grid ---
225
+ st.markdown("##### Parameters")
226
+ with st.container(border=True):
227
+ if schema.get("type") == "object":
228
+ props = schema.get("properties", {})
229
+ # CALL THE GRID ENGINE
230
+ new_params = render_schema_grid(props, current_params, prefix=f"run_{run.run_id}")
231
+ else:
232
+ st.info("Schema is not an object, editing raw JSON.")
233
+ new_params = _render_json_editor("Raw Params", current_params, {}, "", f"run_raw_{run.run_id}")
234
+
235
+ st.divider()
236
+
237
+ # --- Layout: Action Footer ---
238
+ # Right-aligned actions
239
+ _, col_btns = st.columns([3, 4])
240
+ with col_btns:
241
+ b1, b2, b3 = st.columns([1, 1, 1.5], gap="small")
242
+
243
+ with b1:
244
+ if st.button("🚫 Cancel", key=f"cancel_{run.run_id}", width="stretch"):
245
+ try:
246
+ run_mgr.delete_run(run.run_id)
247
+ st.success("Cancelled")
248
+ st.rerun()
249
+ except Exception as e:
250
+ st.error(f"Error: {e}")
251
+
252
+ with b2:
253
+ if st.button("💾 Save", key=f"save_{run.run_id}", width="stretch"):
254
+ try:
255
+ (run_dir / "input").mkdir(parents=True, exist_ok=True)
256
+ (run_dir / "input" / "params.json").write_text(
257
+ json.dumps(new_params, indent=2, ensure_ascii=False),
258
+ encoding="utf-8",
259
+ )
260
+ st.toast("Parameters saved successfully!", icon="💾")
261
+ except Exception as e:
262
+ st.error(f"Save failed: {e}")
263
+
264
+ with b3:
265
+ if st.button("▶️ Run Algorithm", key=f"exec_{run.run_id}", type="primary", width="stretch"):
266
+ try:
267
+ # Auto-save before run
268
+ (run_dir / "input").mkdir(parents=True, exist_ok=True)
269
+ (run_dir / "input" / "params.json").write_text(
270
+ json.dumps(new_params, indent=2, ensure_ascii=False),
271
+ encoding="utf-8",
272
+ )
273
+
274
+ with st.spinner("Initializing execution..."):
275
+ result = run_mgr.execute(run.run_id, timeout_s=int(timeout))
276
+
277
+ if result.error:
278
+ st.error(f"Run Finished: {result.status.value}\n{result.error}")
279
+ else:
280
+ # Navigate to Results page with executed run
281
+ st.session_state.executed_run_id = run.run_id
282
+ st.switch_page("pages/5_Results.py")
283
+ except Exception as e:
284
+ st.error(f"Execution Exception: {e}")
285
+
286
+
287
+ # ------------------------------------------
288
+ # 2. History (Other Runs)
289
+ # ------------------------------------------
290
+ st.subheader("Run History")
291
+
292
+ if not other_runs:
293
+ st.caption("No historical runs.")
294
+
295
+ other_runs_reversed = other_runs[::-1]
296
+
297
+ for run in other_runs_reversed:
298
+ status_config = {
299
+ RunStatus.PENDING: ("⏳", "Pending", "gray"),
300
+ RunStatus.RUNNING: ("🔄", "Running", "blue"),
301
+ RunStatus.SUCCEEDED: ("✅", "Succeeded", "green"),
302
+ RunStatus.FAILED: ("❌", "Failed", "red"),
303
+ RunStatus.TIMEOUT: ("⏱️", "Timeout", "orange"),
304
+ }
305
+ icon, label, color = status_config.get(run.status, ("❓", "Unknown", "gray"))
306
+
307
+ with st.expander(f"{icon} {run.run_id}", expanded=False):
308
+ with st.container(border=True):
309
+ # Info
310
+ c1, c2, c3 = st.columns([3, 2, 2])
311
+ with c1:
312
+ st.caption("Context")
313
+ st.markdown(f"**{run.algorithm_id}** v{run.algorithm_version}")
314
+ st.text(f"Snap: {run.snapshot_id}")
315
+ with c2:
316
+ st.caption("Timing")
317
+ st.text(f"Start: {run.started_at or '--'}")
318
+ st.text(f"End: {run.completed_at or '--'}")
319
+ with c3:
320
+ st.caption("Status")
321
+ st.markdown(f":{color}[**{label}**]")
322
+ if run.error:
323
+ st.error(run.error)
324
+
325
+ # Params Read-only
326
+ st.divider()
327
+ st.caption("Run Parameters")
328
+ run_dir = run_mgr.get_run_dir(run.run_id)
329
+ params_view = load_params(run_dir)
330
+ if params_view:
331
+ st.code(json.dumps(params_view, indent=2, ensure_ascii=False), language="json")
@@ -0,0 +1,298 @@
1
+ """Results viewing page."""
2
+
3
+ import json
4
+
5
+ import streamlit as st
6
+
7
+ from fraclab_sdk.config import SDKConfig
8
+ from fraclab_sdk.algorithm import AlgorithmLibrary
9
+ from fraclab_sdk.results import (
10
+ ResultReader,
11
+ get_artifact_preview_type,
12
+ preview_image,
13
+ preview_json_raw,
14
+ preview_json_table,
15
+ preview_scalar,
16
+ )
17
+ from fraclab_sdk.run import RunManager, RunStatus
18
+ from fraclab_sdk.workbench import ui_styles
19
+
20
+ st.set_page_config(page_title="Results", page_icon="📊", layout="wide", initial_sidebar_state="expanded")
21
+ st.title("Results")
22
+
23
+ ui_styles.apply_global_styles()
24
+
25
+ # Guidance: if artifacts preview looks empty, validate OutputContract via the editor page.
26
+ st.info(
27
+ "看不到期望的输出?请在 Output Editor 页面点击 Validate 检查 "
28
+ "`schema/output_contract.py` 的 datasets/items/artifacts 是否正确。",
29
+ icon="ℹ️",
30
+ )
31
+
32
+ def get_manager():
33
+ """Get run manager."""
34
+ config = SDKConfig()
35
+ return RunManager(config)
36
+
37
+
38
+ run_manager = get_manager()
39
+ algo_lib = AlgorithmLibrary(run_manager._config)
40
+ runs = run_manager.list_runs()
41
+
42
+ if not runs:
43
+ st.info("No runs available.")
44
+ st.stop()
45
+
46
+ # ==========================================
47
+ # 1. Run Selection & Status
48
+ # ==========================================
49
+
50
+ # Prepare options
51
+ run_options = {r.run_id: r for r in runs}
52
+ # Sort by latest first usually makes sense
53
+ run_ids = list(reversed(list(run_options.keys())))
54
+
55
+ # Check for navigation context
56
+ default_run_id = st.session_state.pop("executed_run_id", None) or st.session_state.pop("created_run_id", None)
57
+ default_index = run_ids.index(default_run_id) if default_run_id in run_ids else 0
58
+
59
+ with st.container(border=True):
60
+ col_sel, col_stat = st.columns([4, 1])
61
+
62
+ with col_sel:
63
+ selected_run_id = st.selectbox(
64
+ "Select Run",
65
+ options=run_ids,
66
+ index=default_index,
67
+ format_func=lambda x: f"{x} — {run_options[x].algorithm_id} (v{run_options[x].algorithm_version})",
68
+ label_visibility="collapsed"
69
+ )
70
+
71
+ with col_stat:
72
+ if selected_run_id:
73
+ status = run_options[selected_run_id].status
74
+ status_color = {
75
+ RunStatus.SUCCEEDED: "green",
76
+ RunStatus.FAILED: "red",
77
+ RunStatus.PENDING: "gray",
78
+ RunStatus.RUNNING: "blue"
79
+ }.get(status, "gray")
80
+ st.markdown(f"<div style='text-align:center; padding: 8px; border: 1px solid #ddd; border-radius: 6px;'>Status: <b style='color:{status_color}'>{status.value}</b></div>", unsafe_allow_html=True)
81
+
82
+ if not selected_run_id:
83
+ st.stop()
84
+
85
+ run = run_options[selected_run_id]
86
+ run_dir = run_manager.get_run_dir(selected_run_id)
87
+ reader = ResultReader(run_dir)
88
+
89
+ # ==========================================
90
+ # 2. Run Context
91
+ # ==========================================
92
+
93
+ def _load_output_contract(algo_id: str, algo_version: str):
94
+ """Load output_contract.json from algorithm directory."""
95
+ try:
96
+ handle = algo_lib.get_algorithm(algo_id, algo_version)
97
+ manifest_path = handle.directory / "manifest.json"
98
+ manifest_data = {}
99
+ if manifest_path.exists():
100
+ manifest_data = json.loads(manifest_path.read_text())
101
+ files = manifest_data.get("files") or {}
102
+ rel = files.get("outputContractPath", "dist/output_contract.json")
103
+ contract_path = (handle.directory / rel).resolve()
104
+ if not contract_path.exists():
105
+ return None
106
+ return json.loads(contract_path.read_text())
107
+ except Exception:
108
+ return None
109
+
110
+ output_contract = _load_output_contract(run.algorithm_id, run.algorithm_version)
111
+
112
+ with st.expander("ℹ️ Run Metadata", expanded=False):
113
+ c1, c2, c3 = st.columns(3)
114
+ with c1:
115
+ st.caption("Snapshot ID")
116
+ st.code(run.snapshot_id, language="text")
117
+ with c2:
118
+ st.caption("Algorithm ID")
119
+ st.code(run.algorithm_id, language="text")
120
+ with c3:
121
+ st.caption("Timestamps")
122
+ st.text(f"Start: {run.started_at}\nEnd: {run.completed_at}")
123
+
124
+ if run.error:
125
+ st.error(f"Run Error: {run.error}")
126
+ elif reader.has_manifest() and reader.get_error():
127
+ st.error(f"Manifest Error: {reader.get_error()}")
128
+
129
+ st.divider()
130
+
131
+ # ==========================================
132
+ # 3. Artifacts (Default Expanded)
133
+ # ==========================================
134
+ st.subheader("Artifacts")
135
+
136
+ if not reader.has_manifest():
137
+ st.warning("⚠️ Output manifest not found (Run may have failed or produced no output)")
138
+ else:
139
+ manifest = reader.read_manifest()
140
+
141
+ if not manifest.datasets:
142
+ st.info("No artifacts produced.")
143
+ else:
144
+ for ds in manifest.datasets:
145
+ # Match with contract
146
+ contract_ds = None
147
+ if output_contract:
148
+ contract_ds = next((d for d in output_contract.get("datasets", []) if d.get("key") == ds.datasetKey), None)
149
+
150
+ header = f"📂 {ds.datasetKey}"
151
+ if contract_ds and contract_ds.get("role"):
152
+ header += f" ({contract_ds.get('role')})"
153
+
154
+ # --- DATASET LEVEL: EXPANDED BY DEFAULT ---
155
+ with st.expander(header, expanded=True):
156
+ # Contract Info Bar
157
+ if contract_ds:
158
+ st.caption(
159
+ f"**Schema Definition:** Kind=`{contract_ds.get('kind')}` • "
160
+ f"Owner=`{contract_ds.get('owner')}` • "
161
+ f"Card=`{contract_ds.get('cardinality')}`"
162
+ )
163
+
164
+ # --- ITEMS LEVEL: CARDS (Always Visible) ---
165
+ for item in ds.items:
166
+ art = item.artifact
167
+ preview_type = get_artifact_preview_type(art)
168
+
169
+ with st.container(border=True):
170
+ # Item Header & Metadata
171
+ m1, m2, m3 = st.columns([2, 2, 3])
172
+ with m1:
173
+ st.markdown(f"**Item:** `{item.itemKey or art.artifactKey}`")
174
+ with m2:
175
+ st.caption(f"Type: `{art.artifactType}`")
176
+ with m3:
177
+ if art.mimeType: st.caption(f"MIME: `{art.mimeType}`")
178
+
179
+ # [Modified] 删除了 Owner 和 Dims 的显示
180
+ st.markdown("---")
181
+
182
+ # Content Preview
183
+ if preview_type == "scalar":
184
+ value = preview_scalar(art)
185
+ st.metric(label="Value", value=value)
186
+
187
+ elif preview_type == "image":
188
+ image_path = preview_image(art)
189
+ if image_path and image_path.exists():
190
+ # [Modified] use_column_width=True -> width="stretch"
191
+ st.image(str(image_path), caption=art.artifactKey, width="stretch")
192
+ else:
193
+ st.warning("Image file missing")
194
+
195
+ elif preview_type == "json_table":
196
+ table_data = preview_json_table(art)
197
+ if table_data:
198
+ # Use static table for cleaner look
199
+ st.table([dict(zip(table_data["columns"], row)) for row in table_data["rows"]])
200
+ else:
201
+ st.warning("Invalid table data")
202
+
203
+ elif preview_type == "json_raw":
204
+ json_content = preview_json_raw(art)
205
+ if json_content:
206
+ st.code(json_content, language="json")
207
+ else:
208
+ st.warning("Empty JSON")
209
+
210
+ elif preview_type == "file":
211
+ path = reader.get_artifact_path(art.artifactKey)
212
+ if path:
213
+ f_col1, f_col2 = st.columns([4, 1])
214
+ with f_col1:
215
+ st.code(str(path), language="text")
216
+ with f_col2:
217
+ if path.exists():
218
+ st.download_button(
219
+ "⬇️ Download",
220
+ data=path.read_bytes(),
221
+ file_name=path.name,
222
+ mime=art.mimeType or "application/octet-stream",
223
+ use_container_width=True # Button still uses old kwarg? No, replaced below if needed in logic, but standard download_button uses use_container_width in modern versions. If your version deprecated it for buttons too, this should be width="stretch". Let's stick to consistent modern API.
224
+ )
225
+ else:
226
+ st.warning("File path resolution failed")
227
+
228
+ else:
229
+ st.info("No preview available for this artifact type.")
230
+
231
+ st.divider()
232
+
233
+ # ==========================================
234
+ # 4. Logs & Debug
235
+ # ==========================================
236
+ st.subheader("Logs & System Info")
237
+
238
+ tab1, tab2, tab3, tab4 = st.tabs(["📜 Algorithm Log", "📤 Stdout", "⚠️ Stderr", "🔍 Manifest JSON"])
239
+
240
+ with tab1:
241
+ log = reader.read_algorithm_log()
242
+ if log:
243
+ st.code(log, language="text")
244
+ else:
245
+ st.caption("No algorithm log available.")
246
+
247
+ with tab2:
248
+ stdout = reader.read_stdout()
249
+ if stdout:
250
+ st.code(stdout, language="text")
251
+ else:
252
+ st.caption("No stdout recorded.")
253
+
254
+ with tab3:
255
+ stderr = reader.read_stderr()
256
+ if stderr:
257
+ st.code(stderr, language="text")
258
+ else:
259
+ st.caption("No stderr recorded.")
260
+
261
+ with tab4:
262
+ if reader.has_manifest():
263
+ manifest = reader.read_manifest()
264
+ st.code(json.dumps(manifest.model_dump(exclude_none=True), indent=2), language="json")
265
+ else:
266
+ st.caption("No manifest file.")
267
+
268
+ st.divider()
269
+
270
+ # ==========================================
271
+ # 5. Danger Zone
272
+ # ==========================================
273
+ with st.expander("🗑️ Danger Zone", expanded=False):
274
+ st.markdown("Deleting a run is irreversible. It will remove all artifacts and logs.")
275
+
276
+ confirm_key = f"confirm_del_run_{run.run_id}"
277
+
278
+ if st.button("Delete This Run", key=f"del_btn_{run.run_id}", type="secondary"):
279
+ st.session_state[confirm_key] = True
280
+
281
+ if st.session_state.get(confirm_key):
282
+ st.warning(f"Are you sure you want to delete Run `{run.run_id}`?")
283
+ d_c1, d_c2 = st.columns([1, 1])
284
+ with d_c1:
285
+ # [Modified] use_container_width -> width="stretch"
286
+ if st.button("Yes, Delete", key=f"yes_del_{run.run_id}", type="primary", width="stretch"):
287
+ try:
288
+ run_manager.delete_run(run.run_id)
289
+ st.success(f"Deleted run {run.run_id}")
290
+ st.session_state.pop(confirm_key, None)
291
+ st.rerun()
292
+ except Exception as e:
293
+ st.error(f"Delete failed: {e}")
294
+ with d_c2:
295
+ # [Modified] use_container_width -> width="stretch"
296
+ if st.button("Cancel", key=f"no_del_{run.run_id}", width="stretch"):
297
+ st.session_state.pop(confirm_key, None)
298
+ st.rerun()