Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 122 additions & 9 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand All @@ -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.')
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 {}
Expand Down
121 changes: 106 additions & 15 deletions ui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────
Expand Down Expand Up @@ -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 ───────────────────────────────────────────────
Expand Down Expand Up @@ -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')
Expand All @@ -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(`
<div class="workspace-integrity-grid">
${rows.map(([label, value]) => `
<span>${escapeHtml(label)}</span>
<strong>${escapeHtml(String(value))}</strong>
`).join('')}
</div>
${warnings.length ? `
<div class="workspace-integrity-warnings">
${warnings.map(warning => `<div>${escapeHtml(warning)}</div>`).join('')}
</div>
` : '<div class="workspace-integrity-ok">No integrity warnings.</div>'}
`);
}

function closeWorkspaceRestore() {
document
.getElementById('workspace-restore-overlay')
Expand Down Expand Up @@ -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)}`);
Expand Down Expand Up @@ -5251,3 +5341,4 @@ async function loadPredefinedScript(key) {
notify(`Failed to link script: ${err.message}`, 'error');
}
}
}
8 changes: 7 additions & 1 deletion ui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1161,6 +1161,7 @@ <h2>Restore Previous Workspace</h2>
<div class="modal-body">
<p>DevShell detected a previous workspace session.</p>
<p>Would you like to restore it?</p>
<div id="workspace-restore-preview" class="workspace-integrity-panel" hidden></div>
<div class="workspace-recovery-actions">
<button id="workspace-restore-btn" class="btn">Restore Workspace</button>
<button id="workspace-safe-btn" class="btn">Safe Restore</button>
Expand All @@ -1184,11 +1185,16 @@ <h2>Workspace Profiles</h2>
placeholder="Profile name">
<button id="workspace-save-profile" class="btn">Save Current Workspace</button>
</div>
<div class="workspace-recovery-actions">
<button id="workspace-export-btn" class="btn">Export Snapshot</button>
<button id="workspace-import-btn" class="btn">Import Snapshot</button>
<input id="workspace-import-file" type="file" accept="application/json,.json" hidden>
</div>
<div id="workspace-profile-list"></div>
</div>
</div>
</div>

</body>

</html>
</html>
Loading
Loading