diff --git a/app.py b/app.py index e2d60b0..5f46d39 100644 --- a/app.py +++ b/app.py @@ -140,6 +140,70 @@ def validate_workspace_snapshot(data): return True, None +def _parse_workspace_time(value): + if not value: + return None + try: + return datetime.fromisoformat(str(value).replace("Z", "+00:00")) + except (TypeError, ValueError): + return None + + +def workspace_integrity_warnings(snapshot, saved_at=None): + warnings = [] + if not isinstance(snapshot, dict): + return ["Workspace snapshot is malformed."] + + terminals = snapshot.get("terminals") + if not isinstance(terminals, list) or not terminals: + warnings.append("Workspace snapshot has no terminal list.") + terminals = [] + + terminal_ids = {item for item in terminals if isinstance(item, int)} + if len(terminal_ids) != len(terminals): + warnings.append("Workspace snapshot contains invalid terminal ids.") + + active_terminal = snapshot.get("activeTerminalId") + if active_terminal is not None and active_terminal not in terminal_ids: + warnings.append("Active terminal is missing from the terminal list.") + + terminal_snapshots = snapshot.get("terminalSnapshots", []) + if terminal_snapshots is not None and not isinstance(terminal_snapshots, list): + warnings.append("Terminal snapshot payload is malformed.") + elif isinstance(terminal_snapshots, list): + for terminal_snapshot in terminal_snapshots: + if not isinstance(terminal_snapshot, dict): + warnings.append("Terminal snapshot entry is malformed.") + break + snap_id = terminal_snapshot.get("id") + if snap_id is not None and snap_id not in terminal_ids: + warnings.append("Terminal snapshot references a missing terminal.") + break + + replay_state = snapshot.get("replayState") or {} + if not isinstance(replay_state, dict): + warnings.append("Replay state is malformed.") + elif replay_state.get("active"): + session_id = replay_state.get("sessionId") + if not session_id: + warnings.append("Active replay state is missing a session reference.") + else: + replay_path = os.path.join(SESSION_LOG_DIR, f"{session_id}.json") + if not os.path.exists(replay_path): + warnings.append("Replay session referenced by snapshot is missing.") + + saved_dt = _parse_workspace_time(saved_at) + if saved_at and not saved_dt: + warnings.append("Snapshot timestamp is malformed.") + elif saved_dt: + if saved_dt.tzinfo is None: + saved_dt = saved_dt.replace(tzinfo=timezone.utc) + if (_utc_now() - saved_dt).days > 14: + warnings.append("Snapshot is older than 14 days.") + + return warnings + + def load_workspace_state(): if not os.path.exists(WORKSPACE_STATE_FILE): return None @@ -169,6 +233,7 @@ def save_workspace_state(data): try: with open(WORKSPACE_STATE_FILE, "w", encoding="utf-8") as f: json.dump(payload, f, indent=2) + _invalidate_reliability_cache(keys=['diagnostics']) return True, None except Exception as e: return False, str(e) @@ -1726,6 +1791,7 @@ def _build_workspace_diagnostics(workspace_payload=None): 'workspace_ok': True, 'snapshot_corrupted': False, 'replay_active_in_snapshot': False, + 'has_integrity_warnings': False, } if not workspace_payload: @@ -1749,6 +1815,12 @@ def _build_workspace_diagnostics(workspace_payload=None): } snapshot = workspace_payload.get('workspace', workspace_payload) + integrity = workspace_integrity_warnings(snapshot, workspace_payload.get('saved_at')) + if integrity: + warnings.extend(integrity) + indicators['workspace_ok'] = False + indicators['has_integrity_warnings'] = True + if isinstance(snapshot, dict) and snapshot.get('replayState', {}).get('active'): indicators['replay_active_in_snapshot'] = True warnings.append('Last workspace snapshot had an active replay session.') @@ -1766,10 +1838,25 @@ def _build_workspace_diagnostics(workspace_payload=None): 'indicators': indicators, 'saved_at': workspace_payload.get('saved_at'), 'version': workspace_payload.get('version'), + 'preview': _workspace_snapshot_preview(workspace_payload), 'profile_corruption_count': len(profile_corruption), } +def _workspace_snapshot_preview(workspace_payload): + snapshot = workspace_payload.get('workspace', workspace_payload) if isinstance(workspace_payload, dict) else {} + if not isinstance(snapshot, dict): + snapshot = {} + terminals = snapshot.get('terminals') if isinstance(snapshot.get('terminals'), list) else [] + return { + 'workspace_name': workspace_payload.get('profile_name') or snapshot.get('workspaceName') or 'Recovered workspace', + 'terminal_count': len(terminals), + 'snapshot_timestamp': workspace_payload.get('saved_at'), + 'has_replay': bool(snapshot.get('replayState', {}).get('active')) if isinstance(snapshot.get('replayState'), dict) else False, + 'has_debug': bool(snapshot.get('debuggerVisible')), + } + + def _build_replay_diagnostics(summary=None): """Replay/session instability linked to reliability summaries (no extra storage).""" summary = summary if summary is not None else _load_reliability_summary() @@ -2318,15 +2405,6 @@ def parse_script_metadata(filepath): elif line.startswith("# url:"): metadata["url"] = line[6:].strip() elif not line.startswith("#") and line: - if line.startswith('# name:'): - name_val = line[7:].strip() - if name_val: - metadata['name'] = name_val - elif line.startswith('# desc:'): - metadata['desc'] = line[7:].strip() - elif line.startswith('# tag:'): - metadata['tag'] = line[6:].strip() - elif not line.startswith('#') and line: break except Exception: # nosec B110 pass @@ -2778,6 +2856,41 @@ def persist_workspace_state(): return jsonify({"success": success, "error": error}) +@app.route("/api/workspace/export", methods=["GET"]) +def export_workspace_state(): + data = load_workspace_state() + if not data or data.get("corrupted"): + return jsonify({"success": False, "error": "No valid workspace snapshot to export"}), 404 + body = json.dumps(data, indent=2) + return Response( + body, + mimetype="application/json", + headers={"Content-Disposition": "attachment; filename=devshell-workspace.json"}, + ) + + +@app.route("/api/workspace/import", methods=["POST"]) +def import_workspace_state(): + payload = request.get_json(silent=True) + if not isinstance(payload, dict): + return jsonify({"success": False, "error": "Import must be a JSON object"}), 400 + + workspace = payload.get("workspace", payload) + valid, error = validate_workspace_snapshot(workspace) + if not valid: + return jsonify({"success": False, "error": error}), 400 + + success, error = save_workspace_state(workspace) + if not success: + return jsonify({"success": False, "error": error}), 500 + + stored = load_workspace_state() + return jsonify({ + "success": True, + "diagnostics": _build_workspace_diagnostics(stored), + }) + + @app.route("/api/workspace/profile", methods=["POST"]) def save_workspace_profile(): data = request.get_json(silent=True) or {} diff --git a/ui/app.js b/ui/app.js index e4ccd0c..03fe20d 100644 --- a/ui/app.js +++ b/ui/app.js @@ -25,6 +25,8 @@ const API = { reliability_trends: '/api/reliability/trends', reliability_recommendations: '/api/reliability/recommendations', reliability_diagnostics: '/api/reliability/diagnostics', + workspace_export: '/api/workspace/export', + workspace_import: '/api/workspace/import', }; // ─── State ──────────────────────────────────────────────── @@ -3884,6 +3886,23 @@ function bindEvents() { document .getElementById('workspace-save-profile') ?.addEventListener('click', saveWorkspaceProfile); + + document + .getElementById('workspace-export-btn') + ?.addEventListener('click', exportWorkspaceSnapshot); + + document + .getElementById('workspace-import-btn') + ?.addEventListener('click', () => { + document.getElementById('workspace-import-file')?.click(); + }); + + document + .getElementById('workspace-import-file') + ?.addEventListener('change', (event) => { + importWorkspaceSnapshot(event.target.files?.[0]); + event.target.value = ''; + }); } // ─── Helpers ─────────────────────────────────────────────── @@ -4083,18 +4102,7 @@ async function checkWorkspaceRecovery() { } const snapshot = data.workspace.workspace; - - const savedAt = data.workspace.saved_at; - const modalBody = document.querySelector('#workspace-restore-overlay .modal-body'); - if (modalBody && savedAt) { - const existing = modalBody.querySelector('.workspace-snapshot-meta'); - if (!existing) { - const meta = document.createElement('div'); - meta.className = 'workspace-snapshot-meta'; - meta.textContent = `Snapshot saved at: ${savedAt}`; - modalBody.appendChild(meta); - } - } + renderWorkspaceRestorePreview(data.workspace, workspaceDiag); document .getElementById('workspace-restore-overlay') @@ -4104,23 +4112,55 @@ async function checkWorkspaceRecovery() { .getElementById('workspace-restore-btn') ?.addEventListener('click', () => { restoreWorkspace(snapshot, 'full'); - }); + }, { once: true }); document .getElementById('workspace-safe-btn') ?.addEventListener('click', () => { restoreWorkspace(snapshot, 'safe'); - }); + }, { once: true }); document .getElementById('workspace-clean-btn') - ?.addEventListener('click', closeWorkspaceRestore); + ?.addEventListener('click', closeWorkspaceRestore, { once: true }); } catch (err) { console.error(err); } } +function renderWorkspaceRestorePreview(workspacePayload, diagnostics = {}) { + const panel = document.getElementById('workspace-restore-preview'); + if (!panel) return; + + const snapshot = workspacePayload?.workspace || {}; + const preview = diagnostics.preview || {}; + const warnings = diagnostics.warnings || []; + const terminalCount = preview.terminal_count ?? (Array.isArray(snapshot.terminals) ? snapshot.terminals.length : 0); + const rows = [ + ['Workspace', preview.workspace_name || 'Recovered workspace'], + ['Terminals', terminalCount], + ['Snapshot', preview.snapshot_timestamp || workspacePayload?.saved_at || 'Unknown'], + ['Replay', preview.has_replay ? 'Present' : 'None'], + ['Debugger', preview.has_debug ? 'Present' : 'None'], + ]; + + panel.hidden = false; + panel.innerHTML = safeHTML(` +
+ ${rows.map(([label, value]) => ` + ${escapeHtml(label)} + ${escapeHtml(String(value))} + `).join('')} +
+ ${warnings.length ? ` +
+ ${warnings.map(warning => `
${escapeHtml(warning)}
`).join('')} +
+ ` : '
No integrity warnings.
'} + `); +} + function closeWorkspaceRestore() { document .getElementById('workspace-restore-overlay') @@ -4332,6 +4372,56 @@ async function saveWorkspaceProfile() { } } +async function exportWorkspaceSnapshot() { + try { + const res = await fetch(API.workspace_export); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + notify(data.error || 'No workspace snapshot available to export.', 'warning'); + return; + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = 'devshell-workspace.json'; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); + } catch (err) { + console.error(err); + notify('Failed to export workspace snapshot.', 'error'); + } +} + +async function importWorkspaceSnapshot(file) { + if (!file) return; + + try { + const text = await file.text(); + const payload = JSON.parse(text); + const res = await fetch(API.workspace_import, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await res.json(); + + if (!data.success) { + notify(data.error || 'Invalid workspace snapshot.', 'error'); + return; + } + + const warning = data.diagnostics?.warnings?.[0]; + notify(warning || 'Workspace snapshot imported.', warning ? 'warning' : 'success'); + } catch (err) { + console.error(err); + notify('Import must be a valid workspace JSON file.', 'error'); + } +} + async function loadWorkspaceProfile(name) { try { const res = await fetch(`/api/workspace/profile/${encodeURIComponent(name)}`); @@ -5251,3 +5341,4 @@ async function loadPredefinedScript(key) { notify(`Failed to link script: ${err.message}`, 'error'); } } +} \ No newline at end of file diff --git a/ui/index.html b/ui/index.html index 29166b2..ebd4c85 100644 --- a/ui/index.html +++ b/ui/index.html @@ -1161,6 +1161,7 @@

Restore Previous Workspace

@@ -1191,4 +1197,4 @@

Workspace Profiles

- \ No newline at end of file + diff --git a/ui/style.css b/ui/style.css index 4eb0e62..b305dc2 100644 --- a/ui/style.css +++ b/ui/style.css @@ -3065,6 +3065,39 @@ body.debugger-open #app-main { color: var(--text-secondary); opacity: 0.8; }/* ─── HEADER DIGITAL SYSTEM CLOCK (#114) ─── */ +.workspace-integrity-panel { + margin-top: 14px; + padding: 12px; + border: 1px solid var(--border-color); + border-radius: 8px; + background: var(--bg-secondary); + font-size: 13px; +} + +.workspace-integrity-grid { + display: grid; + grid-template-columns: minmax(90px, 0.7fr) 1fr; + gap: 6px 12px; +} + +.workspace-integrity-grid span { + color: var(--text-secondary); +} + +.workspace-integrity-warnings { + margin-top: 10px; + color: var(--accent-yellow); +} + +.workspace-integrity-warnings div + div { + margin-top: 4px; +} + +.workspace-integrity-ok { + margin-top: 10px; + color: var(--accent); +} + #header-clock { font-family: var(--font-mono, monospace); font-size: 13px;