WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user