From cc6f504873aa9e9d1204699b0fdcb9a95a7b9eda Mon Sep 17 00:00:00 2001 From: ciregenz Date: Fri, 14 Aug 2026 15:20:11 -0700 Subject: [PATCH] [eric] apps: renaming an app writes the new name into its meta.json so agents stop reading the old one (ENG-308) --- backend/apps/outputs/outputs.py | 35 +++++++++++ .../tests/test_rename_reaches_meta_json.py | 60 +++++++++++++++++++ 2 files changed, 95 insertions(+) create mode 100644 backend/tests/test_rename_reaches_meta_json.py diff --git a/backend/apps/outputs/outputs.py b/backend/apps/outputs/outputs.py index 478f3f2c..eda46d84 100644 --- a/backend/apps/outputs/outputs.py +++ b/backend/apps/outputs/outputs.py @@ -186,6 +186,36 @@ async def read_workspace(workspace_id: str): return {"files": files, "meta": meta, "path": os.path.abspath(folder), "truncated": truncated} +def write_meta_json_fields(workspace_id: str, fields: dict) -> bool: + """Push a rename back onto disk. `meta.json` is what AGENTS read, so a name that only ever + reached the record meant the user said "the X app" while the agent saw the old one. Merges + into the existing file so nothing else in it is lost, and stays quiet if there is no workspace.""" + if not workspace_id or not fields: + return False + folder = os.path.join(WORKSPACE_DIR, workspace_id) + if not os.path.isdir(folder): + return False + meta_path = os.path.join(folder, "meta.json") + meta: dict = {} + if os.path.exists(meta_path): + try: + with open(meta_path) as f: + loaded = json.load(f) + if isinstance(loaded, dict): + meta = loaded + except (OSError, json.JSONDecodeError, ValueError): + meta = {} + if all(meta.get(k) == v for k, v in fields.items()): + return False + meta.update(fields) + try: + with open(meta_path, "w") as f: + json.dump(meta, f, indent=2) + return True + except OSError: + return False + + def sync_output_from_meta_json(workspace_id: str, fallback_name: str | None = None) -> bool: """Sync the Output row's name/description from meta.json (or fallback_name when meta.json has no name). Only overwrites placeholder values; user renames win.""" @@ -652,6 +682,11 @@ async def update_output(output_id: str, body: OutputUpdate): if body.thumbnail is not None: output.preview_updated_at = now save(output) + # A rename must reach meta.json too, or the UI and the file agents read drift apart (ENG-308). + p_sent = body.model_dump(exclude_unset=True) + p_meta = {k: getattr(output, k) for k in ("name", "description") if k in p_sent and getattr(output, k)} + if p_meta: + write_meta_json_fields(getattr(output, "workspace_id", "") or "", p_meta) return {"ok": True, "output": output.model_dump()} diff --git a/backend/tests/test_rename_reaches_meta_json.py b/backend/tests/test_rename_reaches_meta_json.py new file mode 100644 index 00000000..5b7180a8 --- /dev/null +++ b/backend/tests/test_rename_reaches_meta_json.py @@ -0,0 +1,60 @@ +"""Renaming an app must land in its meta.json (ENG-308, Haik 2026-08-14). + +`update_output` wrote the record and nothing else, so the UI showed the new name while the file +AGENTS read still held the old one: the user says "the X app" and the agent is looking at something +called something else. The pull direction (`sync_output_from_meta_json`) deliberately only fills +placeholders, so nothing was ever going to reconcile them. +""" + +import json +import os + +from backend.apps.outputs.outputs import WORKSPACE_DIR, write_meta_json_fields + + +def p_workspace(tmp_name: str) -> str: + ws = f"qa-rename-{tmp_name}" + folder = os.path.join(WORKSPACE_DIR, ws) + os.makedirs(folder, exist_ok=True) + return ws + + +def test_a_rename_lands_in_meta_json(): + ws = p_workspace("basic") + path = os.path.join(WORKSPACE_DIR, ws, "meta.json") + with open(path, "w") as f: + json.dump({"name": "Old Name", "description": "d", "keepme": 1}, f) + assert write_meta_json_fields(ws, {"name": "New Name"}) is True + meta = json.load(open(path)) + assert meta["name"] == "New Name" + assert meta["keepme"] == 1, "a rename must not drop the rest of the file" + assert meta["description"] == "d" + + +def test_writing_the_same_name_is_a_no_op(): + ws = p_workspace("noop") + path = os.path.join(WORKSPACE_DIR, ws, "meta.json") + with open(path, "w") as f: + json.dump({"name": "Same"}, f) + assert write_meta_json_fields(ws, {"name": "Same"}) is False + + +def test_a_missing_workspace_is_quiet_not_fatal(): + assert write_meta_json_fields("does-not-exist-anywhere", {"name": "x"}) is False + assert write_meta_json_fields("", {"name": "x"}) is False + + +def test_a_garbled_meta_json_is_rebuilt_rather_than_crashing(): + ws = p_workspace("garbled") + path = os.path.join(WORKSPACE_DIR, ws, "meta.json") + with open(path, "w") as f: + f.write("{not json at all") + assert write_meta_json_fields(ws, {"name": "Recovered"}) is True + assert json.load(open(path))["name"] == "Recovered" + + +def test_the_update_route_pushes_renames_to_disk(): + import inspect + from backend.apps.outputs import outputs as mod + src = inspect.getsource(mod.update_output) + assert "write_meta_json_fields" in src, "a rename that never reaches disk is the whole bug"