eigent/server/scripts/space_migration_preflight.py
Tong Chen 97d7554438
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Pre-commit / pre-commit (push) Has been cancelled
Test / Run Web + Local Brain Smoke (push) Has been cancelled
Test / Run Frontend Guardrails (push) Has been cancelled
Test / Run Python Tests (push) Has been cancelled
release: Eigent 1.0.0 (#1695)
Co-authored-by: Douglas <douglas.ym.lai@gmail.com>
Co-authored-by: Douglas Lai <115660088+Douglasymlai@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Tao Sun <168447269+fengju0213@users.noreply.github.com>
Co-authored-by: Weijie Bai <happy.regina.bai@gmail.com>
2026-06-17 00:29:35 +08:00

145 lines
4.8 KiB
Python

# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
"""Preflight checks for the Space layer migration.
This script is intentionally read-only. It surfaces user id mappings and
cross-user project id collisions before the Alembic migration materializes
Legacy Space and Project rows.
"""
from __future__ import annotations
import argparse
import json
import pathlib
import sys
from sqlalchemy import create_engine, text
_server_root = pathlib.Path(__file__).resolve().parents[1]
if str(_server_root) not in sys.path:
sys.path.insert(0, str(_server_root))
from app.core.environment import env_not_empty # noqa: E402
def fetch_rows(engine, statement: str) -> list[dict]:
with engine.connect() as connection:
return [dict(row._mapping) for row in connection.execute(text(statement))]
def table_exists(engine, name: str) -> bool:
with engine.connect() as connection:
return bool(
connection.execute(
text(
"SELECT 1 FROM information_schema.tables "
"WHERE table_schema = current_schema() AND table_name = :n"
),
{"n": name},
).first()
)
def main() -> int:
parser = argparse.ArgumentParser(description="Space migration preflight")
parser.add_argument(
"--check-mappings-only",
action="store_true",
help="Only report user id mapping blockers.",
)
parser.add_argument(
"--canonicalize-only",
action="store_true",
help="Alias for --check-mappings-only kept for the V2.6 rollout docs.",
)
args = parser.parse_args()
engine = create_engine(env_not_empty("database_url"))
has_chat_history = table_exists(engine, "chat_history")
has_trigger = table_exists(engine, "trigger")
missing_tables = [
name
for name, present in (("chat_history", has_chat_history), ("trigger", has_trigger))
if not present
]
user_source_sql_parts: list[str] = []
if has_chat_history:
user_source_sql_parts.append(
"SELECT 'chat_history' AS source, CAST(user_id AS TEXT) AS source_id "
"FROM chat_history WHERE user_id IS NOT NULL"
)
if has_trigger:
user_source_sql_parts.append(
"SELECT 'trigger' AS source, CAST(user_id AS TEXT) AS source_id "
"FROM trigger WHERE user_id IS NOT NULL"
)
user_sources: list[dict] = (
fetch_rows(engine, " UNION ".join(user_source_sql_parts))
if user_source_sql_parts
else []
)
mapping_blockers = [
row
for row in user_sources
if row["source"] == "trigger" and not str(row["source_id"]).isdigit()
]
report: dict[str, object] = {
"missing_tables": missing_tables,
"user_sources": user_sources,
"mapping_blockers": mapping_blockers,
}
if not (args.check_mappings_only or args.canonicalize_only):
collision_sql_parts: list[str] = []
if has_chat_history:
collision_sql_parts.append(
"SELECT CAST(user_id AS TEXT) AS user_id, "
"COALESCE(project_id, task_id) AS project_id "
"FROM chat_history WHERE COALESCE(project_id, task_id) IS NOT NULL"
)
if has_trigger:
collision_sql_parts.append(
"SELECT CAST(user_id AS TEXT) AS user_id, project_id "
"FROM trigger WHERE project_id IS NOT NULL"
)
if collision_sql_parts:
report["project_id_collisions"] = fetch_rows(
engine,
f"""
WITH source_projects AS (
{" UNION ".join(collision_sql_parts)}
)
SELECT project_id, COUNT(DISTINCT user_id) AS user_count
FROM source_projects
GROUP BY project_id
HAVING COUNT(DISTINCT user_id) > 1
ORDER BY user_count DESC, project_id ASC
""",
)
else:
report["project_id_collisions"] = []
print(json.dumps(report, indent=2, sort_keys=True))
return 1 if mapping_blockers else 0
if __name__ == "__main__":
raise SystemExit(main())