| """QuestionOS Shadow Sync — Export/Import snapshots for cloud VM. |
| |
| Architecture: |
| - Mac runs heavy local compute when present |
| - Before shutdown: export QuestionOS snapshot (repos, state, datasets, receipts, endpoints) |
| - Cloud Ubuntu VM imports snapshot and serves approved endpoints |
| - On return: Mac pulls back new receipts, usage logs, dataset deltas |
| |
| The shadow is not a full clone of the Mac. It's a functional clone of work state. |
| """ |
|
|
| import os |
| import json |
| import tarfile |
| import hashlib |
| import subprocess |
| from datetime import datetime |
| from typing import Dict, List, Optional |
|
|
|
|
| class ShadowSync: |
| """Handles export/import of QuestionOS state for shadow VM deployment. |
| |
| The snapshot contains: |
| - Compressed datasets (question residues) |
| - Receipt ledger |
| - Question/Execution/Cost-Avoidance ledgers |
| - Endpoint definitions (FastAPI apps) |
| - SQLite/DuckDB state |
| - Secrets policy (never actual secrets) |
| - Docker image hash (if containerized) |
| |
| The snapshot does NOT contain: |
| - Raw API keys, tokens, or credentials |
| - Full repo clones (only approved repo list) |
| - System-level configuration |
| """ |
|
|
| def __init__(self, base_dir: str = None): |
| self.base_dir = base_dir or os.path.join(os.getcwd(), 'questionos') |
| self.snapshots_dir = os.path.join(self.base_dir, 'snapshots') |
| os.makedirs(self.snapshots_dir, exist_ok=True) |
|
|
| def export_snapshot(self, label: str = None) -> Dict: |
| """Export current QuestionOS state as a portable snapshot. |
| |
| Returns metadata about the snapshot including path and manifest. |
| """ |
| timestamp = datetime.now().isoformat() |
| snapshot_name = f"snapshot_{timestamp.replace(':', '-').replace('.', '_')}" |
| if label: |
| snapshot_name += f"_{label}" |
| snapshot_dir = os.path.join(self.snapshots_dir, snapshot_name) |
| os.makedirs(snapshot_dir, exist_ok=True) |
|
|
| manifest = { |
| 'snapshot_id': snapshot_name, |
| 'created_at': timestamp, |
| 'label': label or 'unnamed', |
| 'components': {}, |
| 'secrets_policy': 'no_secrets_included', |
| 'compatible_runtime': 'ubuntu-22.04+', |
| } |
|
|
| |
| ledgers_src = os.path.join(self.base_dir, 'ledgers') |
| ledgers_dst = os.path.join(snapshot_dir, 'ledgers') |
| if os.path.exists(ledgers_src): |
| self._copy_dir(ledgers_src, ledgers_dst) |
| manifest['components']['ledgers'] = self._dir_info(ledgers_dst) |
|
|
| |
| datasets_src = os.path.join(self.base_dir, 'datasets') |
| datasets_dst = os.path.join(snapshot_dir, 'datasets') |
| if os.path.exists(datasets_src): |
| self._copy_dir(datasets_src, datasets_dst) |
| manifest['components']['datasets'] = self._dir_info(datasets_dst) |
|
|
| |
| receipts_src = os.path.join(self.base_dir, 'receipts') |
| receipts_dst = os.path.join(snapshot_dir, 'receipts') |
| if os.path.exists(receipts_src): |
| self._copy_dir(receipts_src, receipts_dst) |
| manifest['components']['receipts'] = self._dir_info(receipts_dst) |
|
|
| |
| sessions_src = os.path.join(self.base_dir, 'sessions') |
| sessions_dst = os.path.join(snapshot_dir, 'sessions') |
| if os.path.exists(sessions_src): |
| self._copy_dir(sessions_src, sessions_dst) |
| manifest['components']['sessions'] = self._dir_info(sessions_dst) |
|
|
| |
| endpoints = self._collect_endpoints() |
| if endpoints: |
| endpoints_path = os.path.join(snapshot_dir, 'endpoints.json') |
| with open(endpoints_path, 'w') as f: |
| json.dump(endpoints, f, indent=2) |
| manifest['components']['endpoints'] = {'count': len(endpoints), 'file': 'endpoints.json'} |
|
|
| |
| policy = { |
| 'policy': 'no_secrets_included', |
| 'note': 'Cloud VM must use its own environment variables for credentials.', |
| 'required_env_vars': self._detect_required_env_vars(), |
| } |
| policy_path = os.path.join(snapshot_dir, 'secrets_policy.json') |
| with open(policy_path, 'w') as f: |
| json.dump(policy, f, indent=2) |
| manifest['secrets_policy'] = policy |
|
|
| |
| manifest_path = os.path.join(snapshot_dir, 'manifest.json') |
| with open(manifest_path, 'w') as f: |
| json.dump(manifest, f, indent=2) |
|
|
| snapshot_hash = self._hash_dir(snapshot_dir) |
| manifest['snapshot_hash'] = snapshot_hash |
| with open(manifest_path, 'w') as f: |
| json.dump(manifest, f, indent=2) |
|
|
| |
| tarball_path = os.path.join(self.snapshots_dir, f"{snapshot_name}.tar.gz") |
| with tarfile.open(tarball_path, 'w:gz') as tar: |
| tar.add(snapshot_dir, arcname=snapshot_name) |
|
|
| manifest['tarball_path'] = tarball_path |
| manifest['tarball_size'] = os.path.getsize(tarball_path) |
|
|
| with open(manifest_path, 'w') as f: |
| json.dump(manifest, f, indent=2) |
|
|
| return manifest |
|
|
| def import_snapshot(self, snapshot_path: str) -> Dict: |
| """Import a snapshot from a tarball or directory. |
| |
| Merges new receipts, usage logs, and dataset deltas from the shadow VM. |
| """ |
| if snapshot_path.endswith('.tar.gz'): |
| extract_dir = os.path.join(self.snapshots_dir, 'importing') |
| os.makedirs(extract_dir, exist_ok=True) |
| with tarfile.open(snapshot_path, 'r:gz') as tar: |
| tar.extractall(extract_dir) |
| snapshot_name = os.listdir(extract_dir)[0] |
| snapshot_dir = os.path.join(extract_dir, snapshot_name) |
| else: |
| snapshot_dir = snapshot_path |
|
|
| manifest_path = os.path.join(snapshot_dir, 'manifest.json') |
| if not os.path.exists(manifest_path): |
| return {'imported': False, 'error': 'no manifest found'} |
|
|
| with open(manifest_path) as f: |
| manifest = json.load(f) |
|
|
| |
| expected_hash = manifest.get('snapshot_hash') |
| actual_hash = self._hash_dir(snapshot_dir, exclude=['manifest.json']) |
| if expected_hash and actual_hash != expected_hash: |
| return {'imported': False, 'error': 'hash mismatch', |
| 'expected': expected_hash[:16], 'actual': actual_hash[:16]} |
|
|
| imported = {} |
|
|
| |
| ledgers_src = os.path.join(snapshot_dir, 'ledgers') |
| if os.path.exists(ledgers_src): |
| ledgers_dst = os.path.join(self.base_dir, 'ledgers') |
| merged = self._merge_ledgers(ledgers_src, ledgers_dst) |
| imported['ledgers'] = merged |
|
|
| |
| datasets_src = os.path.join(snapshot_dir, 'datasets') |
| if os.path.exists(datasets_src): |
| datasets_dst = os.path.join(self.base_dir, 'datasets') |
| merged = self._merge_dirs(datasets_src, datasets_dst) |
| imported['datasets'] = merged |
|
|
| |
| receipts_src = os.path.join(snapshot_dir, 'receipts') |
| if os.path.exists(receipts_src): |
| receipts_dst = os.path.join(self.base_dir, 'receipts') |
| merged = self._merge_dirs(receipts_src, receipts_dst) |
| imported['receipts'] = merged |
|
|
| |
| if snapshot_path.endswith('.tar.gz'): |
| import shutil |
| shutil.rmtree(extract_dir, ignore_errors=True) |
|
|
| return { |
| 'imported': True, |
| 'snapshot': manifest.get('snapshot_id'), |
| 'components': imported, |
| 'verified': True, |
| } |
|
|
| def _copy_dir(self, src: str, dst: str): |
| """Copy directory contents.""" |
| import shutil |
| if os.path.exists(dst): |
| shutil.rmtree(dst) |
| shutil.copytree(src, dst) |
|
|
| def _merge_dirs(self, src: str, dst: str) -> Dict: |
| """Merge src into dst, adding files that don't exist.""" |
| os.makedirs(dst, exist_ok=True) |
| added = 0 |
| skipped = 0 |
| for item in os.listdir(src): |
| src_path = os.path.join(src, item) |
| dst_path = os.path.join(dst, item) |
| if not os.path.exists(dst_path): |
| import shutil |
| if os.path.isdir(src_path): |
| shutil.copytree(src_path, dst_path) |
| else: |
| shutil.copy2(src_path, dst_path) |
| added += 1 |
| else: |
| skipped += 1 |
| return {'added': added, 'skipped': skipped} |
|
|
| def _merge_ledgers(self, src: str, dst: str) -> Dict: |
| """Merge ledger JSON files, appending new entries.""" |
| os.makedirs(dst, exist_ok=True) |
| merged = {} |
| for ledger_file in os.listdir(src): |
| if not ledger_file.endswith('.json'): |
| continue |
| src_path = os.path.join(src, ledger_file) |
| dst_path = os.path.join(dst, ledger_file) |
|
|
| if not os.path.exists(dst_path): |
| import shutil |
| shutil.copy2(src_path, dst_path) |
| merged[ledger_file] = 'copied' |
| continue |
|
|
| with open(src_path) as f: |
| src_data = json.load(f) |
| with open(dst_path) as f: |
| dst_data = json.load(f) |
|
|
| if isinstance(src_data, list) and isinstance(dst_data, list): |
| existing_ids = {e.get('entry_id') or e.get('question_id') or e.get('receipt_id') |
| for e in dst_data if isinstance(e, dict)} |
| new_entries = [e for e in src_data |
| if (e.get('entry_id') or e.get('question_id') or e.get('receipt_id')) not in existing_ids] |
| dst_data.extend(new_entries) |
| with open(dst_path, 'w') as f: |
| json.dump(dst_data, f, indent=2) |
| merged[ledger_file] = f'added {len(new_entries)} entries' |
| else: |
| merged[ledger_file] = 'skipped (non-list format)' |
|
|
| return merged |
|
|
| def _collect_endpoints(self) -> List[Dict]: |
| """Collect endpoint definitions from sessions.""" |
| endpoints = [] |
| sessions_dir = os.path.join(self.base_dir, 'sessions') |
| if not os.path.exists(sessions_dir): |
| return endpoints |
|
|
| for session_id in os.listdir(sessions_dir): |
| state_path = os.path.join(sessions_dir, session_id, 'state.json') |
| if os.path.exists(state_path): |
| with open(state_path) as f: |
| state = json.load(f) |
| if state.get('endpoint_url'): |
| serve_path = os.path.join(sessions_dir, session_id, 'serve.py') |
| endpoints.append({ |
| 'session_id': session_id, |
| 'url': state['endpoint_url'], |
| 'intent': state.get('intent_class'), |
| 'project': state.get('project'), |
| 'serve_script': os.path.basename(serve_path) if os.path.exists(serve_path) else None, |
| }) |
| return endpoints |
|
|
| def _detect_required_env_vars(self) -> List[str]: |
| """Detect environment variables referenced in session files.""" |
| env_vars = set() |
| sessions_dir = os.path.join(self.base_dir, 'sessions') |
| if not os.path.exists(sessions_dir): |
| return [] |
|
|
| for session_id in os.listdir(sessions_dir): |
| session_dir = os.path.join(sessions_dir, session_id) |
| for fname in os.listdir(session_dir): |
| if fname.endswith('.py') or fname.endswith('.sh'): |
| fpath = os.path.join(session_dir, fname) |
| try: |
| with open(fpath) as f: |
| content = f.read() |
| import re |
| matches = re.findall(r'os\.environ\.get\(["\'](\w+)["\']', content) |
| matches += re.findall(r'\$\{(\w+)\}', content) |
| env_vars.update(matches) |
| except Exception: |
| pass |
|
|
| return sorted(list(env_vars)) |
|
|
| def _dir_info(self, path: str) -> Dict: |
| """Get directory info.""" |
| total_size = 0 |
| file_count = 0 |
| for root, dirs, files in os.walk(path): |
| for f in files: |
| fp = os.path.join(root, f) |
| total_size += os.path.getsize(fp) |
| file_count += 1 |
| return {'files': file_count, 'size_bytes': total_size} |
|
|
| def _hash_dir(self, dir_path: str, exclude: List[str] = None) -> str: |
| """Compute SHA-256 hash of a directory's contents.""" |
| exclude = exclude or [] |
| h = hashlib.sha256() |
| for root, dirs, files in os.walk(dir_path): |
| dirs.sort() |
| files.sort() |
| for f in files: |
| if f in exclude: |
| continue |
| fpath = os.path.join(root, f) |
| relpath = os.path.relpath(fpath, dir_path) |
| h.update(relpath.encode()) |
| with open(fpath, 'rb') as fh: |
| h.update(fh.read()) |
| return h.hexdigest() |
|
|
| def list_snapshots(self) -> List[Dict]: |
| """List available snapshots.""" |
| snapshots = [] |
| if not os.path.exists(self.snapshots_dir): |
| return snapshots |
|
|
| for item in sorted(os.listdir(self.snapshots_dir), reverse=True): |
| if item.endswith('.tar.gz'): |
| manifest_name = item.replace('.tar.gz', '') |
| manifest_path = os.path.join(self.snapshots_dir, manifest_name, 'manifest.json') |
| if os.path.exists(manifest_path): |
| with open(manifest_path) as f: |
| m = json.load(f) |
| snapshots.append({ |
| 'name': manifest_name, |
| 'label': m.get('label'), |
| 'created': m.get('created_at', '?')[:19], |
| 'size': os.path.getsize(os.path.join(self.snapshots_dir, item)), |
| 'hash': m.get('snapshot_hash', '?')[:16], |
| }) |
| return snapshots |
|
|