# Cleanup Workflow — Orphan → Gallery-Original > **Audience**: AI sessions, operators, contributors > **Doctrine**: FVW v8.2 §17.6 > **Status**: ACTIVE (per the 2026-07-22 gallery entry shapes decision) --- ## Overview Gallery mirrors (`_module_kind: "gallery-mirror"`) are copies of modules whose canonical home is elsewhere. When the source dies, the mirror is **orphaned** — the canonical is gone, but the gallery still has a copy. After a configurable threshold (default 30 days), the mirror is **promoted** to `gallery-original` — the gallery IS canonical. This workflow runs daily, checks every mirror, and handles the state transitions. --- ## The 4 Mirror States Per FVW v8.2 §17.6.1, a `gallery-mirror` can be in one of 4 states: | `_mirror_status` | Meaning | |---|---| | `healthy` | Source is alive, mirror is current | | `orphaned` | Source is dead, mirror is stranded | | `reattached` | Source came back, mirror resumed | | `promoted` | Mirror became its own thing (now `gallery-original`) | The state machine: ``` source alive │ ▼ ┌──────────────────────┐ │ healthy │ └──────────────────────┘ │ │ source │ │ source comes dies │ │ back ▼ ▼ ┌──────────────┐ ┌──────────────┐ │ orphaned │ │ reattached │ └──────────────┘ └──────────────┘ │ │ 30+ days (default) ▼ ┌──────────────────────┐ │ promoted │ ← _module_kind changes │ (gallery-original) │ └──────────────────────┘ ``` --- ## The Daily Cleanup Workflow In the gallery, at `.github/workflows/cleanup-mirrors.yml`: ```yaml name: Cleanup Mirrors (Daily) on: schedule: - cron: '0 4 * * *' # 4 AM daily (after pull-mirrors at 3 AM) workflow_dispatch: # manual trigger jobs: cleanup: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run cleanup script run: | python3 scripts/cleanup-mirrors.py # The script: # 1. Walks gallery/modules/ and gallery/forks/ # 2. For each module with _module_kind: "gallery-mirror" or "app-fork": # a. Checks if _canonical_source is alive (GitHub API) # b. If dead: transitions to orphaned # c. If orphaned for 30+ days: promotes to gallery-original # d. If alive: updates _last_verified, sets _mirror_status: "healthy" # 3. For each "app-fork" with dead app: same as above # 4. Commits changes to module-meta.json + registry.json # 5. Opens issues for any auto-promotions (operator visibility) - name: Notify operator if: success() run: | # Send a summary: "Today: 2 sources died, 1 promoted, 3 reattached" # Email / Slack / whatever the operator uses ``` --- ## The Cleanup Script Logic The script (in `fv-module-gallery/scripts/cleanup-mirrors.py`): ```python #!/usr/bin/env python3 """ Cleanup Mirrors — daily run. For every gallery-mirror and app-fork: 1. Check if the source is alive 2. If dead: transition to orphaned 3. If orphaned for 30+ days: promote to gallery-original 4. If alive: mark as healthy """ import json import os import sys from datetime import datetime, timezone, timedelta from pathlib import Path import requests # for GitHub API def is_source_alive(canonical_source: str) -> bool: """Check if the GitHub repo + path exists.""" if not canonical_source: return False parts = canonical_source.split("/") if len(parts) < 2: return False owner, repo = parts[0], parts[1] path = "/".join(parts[2:]) if len(parts) > 2 else "" url = f"https://api.github.com/repos/{owner}/{repo}/contents/{path}" resp = requests.get(url, headers={"Accept": "application/vnd.github.v3+json"}) return resp.status_code == 200 def get_threshold(meta: dict) -> int: """Get the cleanup threshold in days (default 30, can be overridden per entry).""" return meta.get("_cleanup_threshold_days", 30) def is_expired(orphaned_at: str, threshold_days: int) -> bool: """Check if orphaned_at + threshold_days is in the past.""" if not orphaned_at: return False orphaned = datetime.fromisoformat(orphaned_at.replace("Z", "+00:00")) threshold = timedelta(days=threshold_days) return datetime.now(timezone.utc) > orphaned + threshold def cleanup_module(module_dir: Path, registry: dict) -> str: """Cleanup a single module. Returns the action taken.""" meta_path = module_dir / "module-meta.json" if not meta_path.exists(): return "skipped (no module-meta.json)" meta = json.loads(meta_path.read_text()) kind = meta.get("_module_kind") if kind not in ("gallery-mirror", "app-fork"): return "skipped (not a mirror or fork)" source = meta.get("_canonical_source") status = meta.get("_mirror_status", "healthy") now = datetime.now(timezone.utc).isoformat() alive = is_source_alive(source) if alive: # Source is alive — mark as healthy if status in ("orphaned", "reattached"): meta["_mirror_status"] = "reattached" meta["_previously_orphaned"] = True meta["_reattached_at"] = now else: meta["_mirror_status"] = "healthy" meta["_last_verified"] = now meta_path.write_text(json.dumps(meta, indent=2)) return f"healthy (source alive: {source})" else: # Source is dead if status == "healthy": # Just became orphaned meta["_mirror_status"] = "orphaned" meta["_orphaned_at"] = now meta["_last_verified"] = now meta_path.write_text(json.dumps(meta, indent=2)) return f"orphaned (source dead: {source})" elif status == "orphaned": # Already orphaned — check threshold threshold = get_threshold(meta) orphaned_at = meta.get("_orphaned_at") if is_expired(orphaned_at, threshold): # Promote to gallery-original meta["_module_kind"] = "gallery-original" meta["_canonical_source"] = None meta["_promoted_from"] = source meta["_promoted_at"] = now meta["_mirror_status"] = "promoted" meta_path.write_text(json.dumps(meta, indent=2)) return f"PROMOTED to gallery-original (orphaned {threshold} days, source was {source})" else: return f"still orphaned ({threshold - days_since(orphaned_at)} days until promote)" elif status == "reattached": # Was reattached but now dead again — back to orphaned meta["_mirror_status"] = "orphaned" meta["_orphaned_at"] = now meta["_last_verified"] = now meta_path.write_text(json.dumps(meta, indent=2)) return f"orphaned again (source dead: {source})" def main(): gallery_root = Path(".") modules_dir = gallery_root / "gallery" / "modules" forks_dir = gallery_root / "gallery" / "forks" actions = [] for search_dir in [modules_dir, forks_dir]: if not search_dir.exists(): continue for module_dir in search_dir.rglob("module-meta.json"): action = cleanup_module(module_dir.parent, None) actions.append((str(module_dir.parent), action)) print(f"Cleanup complete. {len(actions)} modules checked.") for path, action in actions: print(f" {path}: {action}") if __name__ == "__main__": main() ``` --- ## The 30-Day Threshold (Configurable) Default: 30 days. Per-entry override: ```json { "_module_kind": "gallery-mirror", "_canonical_source": "avidtech6/ai-shell/ai-toast", "_cleanup_threshold_days": 90, // wait 90 days instead of 30 ... } ``` Set to `null` to disable auto-promotion: ```json { "_cleanup_threshold_days": null, // never auto-promote ... } ``` When disabled, the operator must explicitly promote via the gallery's admin tools. --- ## What the Operator Sees The cleanup workflow reports daily: ``` Cleanup complete. 47 modules checked. gallery/modules/ai-toast: healthy (source alive: avidtech6/ai-shell/ai-toast) gallery/modules/ai-mic: orphaned (source dead: avidtech6/ai-shell/ai-mic) gallery/modules/vibecoder-toolbar: healthy gallery/forks/vibecoder-standalone/ai-toast-vibecoder-style: PROMOTED to gallery-original ... ``` And weekly summary: ``` This week: - 2 sources died (now orphaned) - 1 promoted to gallery-original (orphaned >30 days) - 0 reattached (sources came back) - 44 healthy ``` --- ## Manual Overrides The operator can manually: 1. **Force-promote** an orphan immediately (override 30-day threshold) 2. **Force-keep** a mirror as `gallery-mirror` even if source is dead (set `_cleanup_threshold_days: null`) 3. **Reattach** a `gallery-original` back to a `gallery-mirror` (if source comes back) 4. **Archive** a `gallery-original` (move to `gallery/archived/`) All via PRs to `fv-module-gallery`, modifying the module's `module-meta.json`. --- ## See also - FVW v8.2 §17.6 — Mirror Cleanup + Auto-Promotion - FVW v8.2 §17.5 — Module Canonical Home + Mirror Model - `gallery-pact/AI-PUBLISHING-GUIDE.md` — the 4 questions - `gallery-pact/MIRROR-WORKFLOW.md` — how mirrors stay in sync - `/workspace/position-gallery-entry-shapes-v4.md`