#!/usr/bin/env python3 """Preserve terminal legacy resource reviews in the local acceptance database. No active fork is guessed or restored. The stopped SQLite source is required; live WAL sources must use a separately reviewed snapshot. Default is rehearsal. """ from __future__ import annotations import argparse import hashlib import json import sqlite3 import subprocess import uuid from pathlib import Path from apply_weknora_identities import sql_run from weknora_category_etl import save_json from weknora_identity_etl import TARGET_DATABASE, TARGET_TENANT NAMESPACE = uuid.UUID("b81a9a18-0c49-4381-9754-6bd2f28c0107") TABLE = "workbench_resource_version_history" def canonical(value): return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) def digest(value): return hashlib.sha256(canonical(value).encode()).hexdigest() def file_digest(path): result = hashlib.sha256() with path.open("rb") as stream: for block in iter(lambda: stream.read(1024 * 1024), b""): result.update(block) return result.hexdigest() def stopped_source(path): path = path.resolve() if Path(str(path) + "-wal").exists() or Path(str(path) + "-shm").exists(): raise ValueError("Source has WAL/SHM; use a consistent stopped snapshot") opened = subprocess.run(["lsof", str(path)], capture_output=True, text=True) if opened.returncode != 1: raise ValueError("Source is open or its state cannot be verified") before = file_digest(path) with sqlite3.connect(path.as_uri() + "?mode=ro&immutable=1", uri=True) as db: db.row_factory = sqlite3.Row db.execute("PRAGMA query_only=ON") if db.execute("PRAGMA integrity_check").fetchone()[0] != "ok": raise ValueError("Source integrity check failed") forks = [dict(row) for row in db.execute("SELECT * FROM resourcefork ORDER BY id")] reviews = [dict(row) for row in db.execute("SELECT * FROM resourcerevision ORDER BY id")] if before != file_digest(path) or Path(str(path) + "-wal").exists(): raise ValueError("Source changed during read") return {"forks": forks, "reviews": reviews, "source_sha256": before} def target_mappings(): return json.loads(sql_run(f"""BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY; SELECT json_build_object('database',current_database(), 'identities',(SELECT json_object_agg(source_user_id::text,target_user_id) FROM yawan_legacy_identity_mappings WHERE target_tenant_id={TARGET_TENANT}), 'agents',(SELECT json_object_agg(source_id::text,target_id) FROM yawan_legacy_asset_mappings WHERE target_tenant_id={TARGET_TENANT} AND source_kind='agent')); COMMIT;""")) def make_plan(source, target): if target["database"] != TARGET_DATABASE: raise ValueError("Unexpected database") if source["forks"]: raise ValueError("Active historical forks need explicit native cloning; refusing a ledger-only migration") rows = [] for row in source["reviews"]: if row["resource_type"] != "agent" or row["organization_id"] != 1 or row["status"] not in ("rejected", "withdrawn", "approved"): raise ValueError("Unreviewed resource type, scope or pending submission") if str(row["resource_id"]) not in target["agents"] or str(row["owner_user_id"]) not in target["identities"]: raise ValueError("A durable resource or owner mapping is missing") payload = json.loads(row["payload"]) if isinstance(row["payload"], str) else row["payload"] if not isinstance(payload, dict) or not isinstance(payload.get("name"), str): raise ValueError("Malformed historical submission") rows.append({"id": str(uuid.uuid5(NAMESPACE, f"resourcerevision:{row['id']}")), "legacy_id": row["id"], "owner_id": target["identities"][str(row["owner_user_id"])], "source_id": target["agents"][str(row["resource_id"])], "name": payload["name"], "status": row["status"], "proposed_snapshot": payload, "review_note": row["review_note"], "created_at": row["created_at"], "reviewed_at": row["reviewed_at"] or None, "source_snapshot": row, "source_sha256": digest(row)}) return {"database": TARGET_DATABASE, "tenant_id": TARGET_TENANT, "source_sha256": source["source_sha256"], "active_forks": 0, "rows": rows} def literal(value): return "'" + value.replace("'", "''") + "'" def transaction(plan, apply=False): if plan["database"] != TARGET_DATABASE or plan["tenant_id"] != TARGET_TENANT: raise ValueError("Unexpected target") migration = literal(canonical(plan)) return f"""BEGIN ISOLATION LEVEL SERIALIZABLE; SET LOCAL statement_timeout='30s'; SET LOCAL lock_timeout='5s'; CREATE TEMP TABLE _version_users AS SELECT * FROM users; CREATE TEMP TABLE _version_members AS SELECT * FROM tenant_members; DO $migration$ DECLARE p jsonb := {migration}::jsonb; item jsonb; old {TABLE}%ROWTYPE; BEGIN IF current_database()<>'{TARGET_DATABASE}' THEN RAISE EXCEPTION 'Wrong database'; END IF; FOR item IN SELECT value FROM jsonb_array_elements(p->'rows') LOOP IF NOT EXISTS(SELECT 1 FROM yawan_legacy_identity_mappings WHERE target_tenant_id={TARGET_TENANT} AND source_user_id=(item->'source_snapshot'->>'owner_user_id')::bigint AND target_user_id=item->>'owner_id') OR NOT EXISTS(SELECT 1 FROM yawan_legacy_asset_mappings WHERE target_tenant_id={TARGET_TENANT} AND source_kind='agent' AND source_id=(item->'source_snapshot'->>'resource_id')::bigint AND target_id=item->>'source_id') THEN RAISE EXCEPTION 'Durable mapping mismatch'; END IF; SELECT * INTO old FROM {TABLE} WHERE tenant_id={TARGET_TENANT} AND source_system='yawan-workbench' AND source_table='resourcerevision' AND legacy_id=(item->>'legacy_id')::bigint; IF FOUND THEN IF old.id<>item->>'id' OR old.source_sha256<>item->>'source_sha256' OR old.source_snapshot<>item->'source_snapshot' OR old.owner_id<>item->>'owner_id' OR old.source_id<>item->>'source_id' OR old.status<>item->>'status' OR old.proposed_snapshot<>item->'proposed_snapshot' THEN RAISE EXCEPTION 'History ledger conflict'; END IF; ELSE INSERT INTO {TABLE}(id,tenant_id,owner_id,kind,source_id,name,status,proposed_snapshot,review_note,created_at,reviewed_at, source_system,source_table,legacy_id,source_snapshot,source_sha256) VALUES(item->>'id',{TARGET_TENANT},item->>'owner_id','agents',item->>'source_id',item->>'name',item->>'status', item->'proposed_snapshot',item->>'review_note',(item->>'created_at')::timestamptz,(item->>'reviewed_at')::timestamptz, 'yawan-workbench','resourcerevision',(item->>'legacy_id')::bigint,item->'source_snapshot',item->>'source_sha256'); END IF; END LOOP; IF EXISTS(SELECT 1 FROM _version_users b FULL JOIN users u USING(id) WHERE to_jsonb(b) IS DISTINCT FROM to_jsonb(u)) OR EXISTS(SELECT 1 FROM _version_members b FULL JOIN tenant_members m USING(id) WHERE to_jsonb(b) IS DISTINCT FROM to_jsonb(m)) THEN RAISE EXCEPTION 'Identity changed'; END IF; END $migration$; SELECT json_build_object('rows',(SELECT count(*) FROM {TABLE} WHERE tenant_id={TARGET_TENANT}), 'identities_unchanged',true,'personal_resources_restored',false,'committed',{'true' if apply else 'false'}); {'COMMIT' if apply else 'ROLLBACK'}; """ def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--sqlite", type=Path, required=True) parser.add_argument("--out", type=Path, required=True) parser.add_argument("--apply", action="store_true") args = parser.parse_args() plan = make_plan(stopped_source(args.sqlite), target_mappings()) save_json(args.out.with_suffix(".plan.json"), plan) result = json.loads(sql_run(transaction(plan, args.apply))) save_json(args.out, result) print(json.dumps(result)) if __name__ == "__main__": main()