okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffaufrefactor(settings): decode profiles and legacy preferences in Gleam9a0526c

main
17.4 KiB389 linesraw
1"""Owner-contributed schema and image migrations survive real startup and restarts.
2
3Legacy ETF fixtures cannot be written by the current daemon. They are seeded
4while the shared daemon is stopped, then exercised through its normal readers.
5"""
6
7import base64
8import hashlib
9import json
10import sqlite3
11import unittest
12
13from harness import Albedo, Provider, exclusive, python, text
14
15
16PNG = "iVBORw0KGgoAAAANSUhEUgAAAA0AAAAD"
17# term_to_binary({1, {user_image, <<"legacy image">>, Image}}), where Image
18# is the legacy {image, <<"image/png">>, Base64, 13, 3, 24} tuple.
19LEGACY_TRANSCRIPT = base64.b64decode(
20    "g2gCYQFoA3cKdXNlcl9pbWFnZW0AAAAMbGVnYWN5IGltYWdlaAZ3BWltYWdlbQAAAAlp"
21    "bWFnZS9wbmdtAAAAIGlWQk9SdzBLR2dvQUFBQU5TVWhFVWdBQUFBMEFBQUFEYQ1hA2EY"
22)
23# A successful outcome before duration was added, carrying the same inline image.
24LEGACY_CELL = base64.b64decode(
25    "g2gCYQFoAncCb2toCHcHb3V0Y29tZW0AAAAObGVnYWN5LWNlbGwtMDB3CXN1Y2NlZWRl"
26    "ZG0AAAANbGVnYWN5IG91dHB1dG0AAAAIJ2xlZ2FjeSd3BWZhbHNlbAAAAAFoBncFaW1h"
27    "Z2VtAAAACWltYWdlL3BuZ20AAAAgaVZCT1J3MEtHZ29BQUFBTlNVaEVVZ0FBQUEwQUFB"
28    "QURhDWEDYRhqag=="
29)
30
31
32def legacy_cell(cell_id):
33    # Replace only this fixture's ETF binary field, including its length.
34    original = b"legacy-cell-00"
35    encoded = cell_id.encode()
36    return LEGACY_CELL.replace(
37        b"m" + len(original).to_bytes(4, "big") + original,
38        b"m" + len(encoded).to_bytes(4, "big") + encoded,
39    )
40
41
42# exclusive: seeds legacy global schema and restarts the daemon
43@exclusive
44class MigrationsTest(unittest.TestCase):
45    def test_installed_extensions_upgrade_legacy_tables_before_their_routes_run(self):
46        provider = Provider(
47            lambda request: (
48                python(
49                    "items = await work.list()\n"
50                    "assert not items\n"
51                    "item = await work.create('new scoped item')\n"
52                    "assert (await work.get(item.id)).title == 'new scoped item'\n"
53                    "legacy = [v for v in await vents(200) if v['message'] == 'legacy vent']\n"
54                    "assert len(legacy) == 1 and legacy[0]['title'] == ''\n"
55                    "await vent('bug', 'new vent', title='new title')\n"
56                    "assert any(v['title'] == 'new title' for v in await vents())\n"
57                    "print('upgraded extension routes usable')"
58                )
59                if request["messages"][-1].get("role") == "user"
60                else text("done")
61            )
62        )
63        self.addCleanup(provider.close)
64        with Albedo(provider) as app:
65            session = app.session()
66            database = app.home / "albedo.sqlite"
67            previous_cwds, previous_titles = [], []
68            legacy_work = None
69
70            def prepare(_app):
71                nonlocal legacy_work
72                with sqlite3.connect(database) as db:
73                    previous_cwds.extend(db.execute("SELECT cwd,id FROM work"))
74                    previous_titles.extend(
75                        db.execute("SELECT title,id FROM paperclips")
76                    )
77                    db.execute("DROP INDEX work_cwd_id")
78                    db.execute("ALTER TABLE work DROP COLUMN cwd")
79                    db.execute("ALTER TABLE paperclips DROP COLUMN title")
80                    legacy_work = db.execute(
81                        "INSERT INTO work(title) VALUES('legacy isolated item')"
82                    ).lastrowid
83                    db.execute(
84                        "INSERT INTO paperclips(topic,message,cwd) VALUES('bug','legacy vent',?)",
85                        (str(app.workspace),),
86                    )
87                    # the cwd-scoped era's index, which the scope migration drops
88                    db.execute(
89                        "CREATE INDEX IF NOT EXISTS paperclips_cwd ON paperclips(cwd)"
90                    )
91                    assert db.execute(
92                        "SELECT name FROM sqlite_master"
93                        " WHERE name='paperclips_cwd' AND type='index'"
94                    ).fetchone()
95                settings_path = app.home / "extensions.json"
96                settings = json.loads(settings_path.read_text())
97                settings.setdefault("enabled", {}).update(work=False, paperclips=False)
98                settings_path.write_text(json.dumps(settings))
99
100            def restore(_app):
101                with sqlite3.connect(database) as db:
102                    db.executemany("UPDATE work SET cwd=? WHERE id=?", previous_cwds)
103                    db.executemany(
104                        "UPDATE paperclips SET title=? WHERE id=?", previous_titles
105                    )
106
107            try:
108                app.restart(prepare=prepare)
109                # Installed storage still upgrades when its routes are disabled.
110                with sqlite3.connect(database) as db:
111                    self.assertEqual(
112                        db.execute(
113                            "SELECT cwd FROM work WHERE id=?", (legacy_work,)
114                        ).fetchone(),
115                        ("__albedo_legacy__",),
116                    )
117                    self.assertIsNotNone(
118                        db.execute(
119                            "SELECT name FROM sqlite_master WHERE name='work_cwd_id' AND type='index'"
120                        ).fetchone()
121                    )
122                    self.assertIsNone(
123                        db.execute(
124                            "SELECT name FROM sqlite_master WHERE name='paperclips_cwd' AND type='index'"
125                        ).fetchone()
126                    )
127                with app.api(f"/sessions/{session}?view=configuration") as response:
128                    json.load(response)
129                    revision = response.headers["ETag"]
130                app.api(
131                    f"/sessions/{session}?view=configuration",
132                    {"selection": {"extensions": {"work": True, "paperclips": True}}},
133                    method="PATCH",
134                    headers={"If-Match": revision},
135                ).close()
136                app.restart()
137                app.prompt(session, "use the upgraded extension ledgers").close()
138                app.idle(session)
139                results = [
140                    json.loads(part["value"])
141                    for entry in app.history(session)["items"]
142                    if entry["kind"] == "tool_result"
143                    and entry["tool"]["name"] == "python"
144                    for part in entry["content"]
145                    if part["kind"] == "json" and part["field"] == "result"
146                ]
147                self.assertEqual(len(results), 1)
148                self.assertEqual(results[0]["status"], "ok", results[0])
149                self.assertIn("upgraded extension routes usable", results[0]["output"])
150            finally:
151                # Restore other fixtures' fields erased by the legacy schema seed.
152                app.restart(prepare=restore)
153
154    def test_startup_migrates_legacy_images_once_with_a_consistent_backup(self):
155        probe = ""
156
157        def reply(request):
158            if request["messages"][-1].get("role") == "user" and probe:
159                return python(probe)
160            return text("done")
161
162        provider = Provider(reply)
163        self.addCleanup(provider.close)
164        with Albedo(provider) as app:
165            session = app.session()
166            database = app.home / "albedo.sqlite"
167            image_hash = hashlib.sha256(PNG.encode()).hexdigest()
168            cell_ids = [f"{session}-legacy-{n}" for n in range(20)]
169            seqs = []
170            backups_before = set(
171                app.home.glob("backups/albedo-before-image-store-*.sqlite")
172            )
173
174            def prepare(_app):
175                with sqlite3.connect(database) as db:
176                    db.execute(
177                        "DELETE FROM migrations WHERE name IN ('image_store','cell_images')"
178                    )
179                    db.execute(
180                        "INSERT INTO images(hash,data) VALUES(?,?) "
181                        "ON CONFLICT(hash) DO UPDATE SET data=excluded.data",
182                        (image_hash, PNG),
183                    )
184                    for _ in range(20):
185                        row = db.execute(
186                            "INSERT INTO transcript(session,payload) VALUES(?,?)",
187                            (session, LEGACY_TRANSCRIPT),
188                        )
189                        seqs.append(row.lastrowid)
190                    for cell_id in cell_ids:
191                        db.execute(
192                            "INSERT INTO cells(id,session,source,status,started,payload) "
193                            "VALUES(?,?,'legacy source','finished',1,?)",
194                            (cell_id, session, legacy_cell(cell_id)),
195                        )
196
197            app.restart(prepare=prepare)
198            backups = set(app.home.glob("backups/albedo-before-image-store-*.sqlite"))
199            created = backups - backups_before
200            self.assertEqual(len(created), 1)
201            with sqlite3.connect(next(iter(created))) as backup:
202                self.assertEqual(
203                    backup.execute("PRAGMA integrity_check").fetchone()[0], "ok"
204                )
205                self.assertEqual(
206                    backup.execute(
207                        "SELECT data,typeof(data) FROM images WHERE hash=?",
208                        (image_hash,),
209                    ).fetchone(),
210                    (PNG, "text"),
211                )
212                self.assertEqual(
213                    backup.execute(
214                        "SELECT payload FROM transcript WHERE seq=?", (seqs[0],)
215                    ).fetchone()[0],
216                    LEGACY_TRANSCRIPT,
217                )
218                self.assertEqual(
219                    backup.execute(
220                        "SELECT payload FROM cells WHERE id=?", (cell_ids[0],)
221                    ).fetchone()[0],
222                    legacy_cell(cell_ids[0]),
223                )
224
225            def migrated_state():
226                with sqlite3.connect(database) as db:
227                    self.assertEqual(
228                        db.execute("PRAGMA integrity_check").fetchone()[0], "ok"
229                    )
230                    self.assertEqual(
231                        db.execute("PRAGMA foreign_key_check").fetchall(), []
232                    )
233                    self.assertEqual(
234                        db.execute(
235                            "SELECT data,typeof(data) FROM images WHERE hash=?",
236                            (image_hash,),
237                        ).fetchone(),
238                        (base64.b64decode(PNG), "blob"),
239                    )
240                    transcript = db.execute(
241                        "SELECT seq,payload FROM transcript WHERE session=? ORDER BY seq",
242                        (session,),
243                    ).fetchall()
244                    self.assertEqual([row[0] for row in transcript], seqs)
245                    cells = db.execute(
246                        "SELECT id,source,payload FROM cells WHERE session=? ORDER BY id",
247                        (session,),
248                    ).fetchall()
249                    self.assertEqual([row[0] for row in cells], sorted(cell_ids))
250                    self.assertTrue(all(row[1] == "legacy source" for row in cells))
251                    for payload in [row[1] for row in transcript] + [
252                        row[2] for row in cells
253                    ]:
254                        self.assertNotIn(PNG.encode(), payload)
255                        self.assertIn(image_hash.encode(), payload)
256                    markers = db.execute(
257                        "SELECT name,applied_at FROM migrations "
258                        "WHERE name IN ('image_store','cell_images') ORDER BY name"
259                    ).fetchall()
260                    self.assertEqual(
261                        [row[0] for row in markers], ["cell_images", "image_store"]
262                    )
263                    return transcript, cells, markers
264
265            first = migrated_state()
266            app.restart()
267            self.assertEqual(migrated_state(), first)
268            self.assertEqual(
269                set(app.home.glob("backups/albedo-before-image-store-*.sqlite")),
270                backups,
271            )
272            # A normal provider request resolves the migrated transcript images.
273            probe = (
274                f"info = await cells.info({cell_ids[0]!r})\n"
275                "assert info['status'] == 'ok' and info['started']\n"
276                "assert info['duration'] is None\n"
277                f"assert 'legacy source' in await cells.read({cell_ids[0]!r})\n"
278                "print('legacy cell readable')"
279            )
280            app.prompt(session, "read the migrated history and cell").close()
281            app.idle(session)
282            serialized = json.dumps(provider.requests[-1]["request"])
283            self.assertIn("legacy image", serialized)
284            self.assertIn(PNG, serialized)
285            self.assertIn("legacy cell readable", serialized)
286
287    def test_session_preferences_import_once_without_removing_global_fields(self):
288        provider = Provider(lambda _request: text("done"))
289        self.addCleanup(provider.close)
290        with Albedo(provider) as app:
291            session = app.session()
292            database = app.home / "albedo.sqlite"
293            picker_path = app.home / "picker.json"
294            caps_path = app.home / "capabilities.json"
295            originals = {}
296
297            def prepare(_app):
298                with sqlite3.connect(database) as db:
299                    db.execute("DELETE FROM session_preferences_import")
300                for path in (picker_path, caps_path):
301                    originals[path] = (
302                        json.loads(path.read_text()) if path.exists() else {}
303                    )
304                picker = {
305                    **originals[picker_path],
306                    "pinned": [session],
307                    "archived": [session],
308                    "opens": {session: 7},
309                }
310                caps = {
311                    **originals[caps_path],
312                    "sessions": {session: {"skills": {"missing-legacy-skill": False}}},
313                }
314                picker_path.write_text(json.dumps(picker))
315                caps_path.write_text(json.dumps(caps))
316
317            app.restart(prepare=prepare)
318            with app.api(f"/sessions/{session}") as response:
319                preferences = json.load(response)["preferences"]
320            self.assertTrue(preferences["pinned"])
321            self.assertTrue(preferences["archived"])
322            self.assertEqual(preferences["opens"], 7)
323            self.assertEqual(
324                json.loads(picker_path.read_text()), originals[picker_path]
325            )
326            self.assertEqual(json.loads(caps_path.read_text()), originals[caps_path])
327
328            def imported_state():
329                with sqlite3.connect(database) as db:
330                    self.assertEqual(
331                        db.execute(
332                            "SELECT version FROM session_preferences_import"
333                        ).fetchall(),
334                        [(1,)],
335                    )
336                    return db.execute(
337                        "SELECT kind,preference_key,enabled FROM session_selection WHERE session=?",
338                        (session,),
339                    ).fetchall()
340
341            first = imported_state()
342            self.assertEqual(first, [("skills", "missing-legacy-skill", 0)])
343            app.restart()
344            self.assertEqual(imported_state(), first)
345            with app.api(f"/sessions/{session}") as response:
346                self.assertEqual(json.load(response)["preferences"], preferences)
347
348    def test_failed_preferences_import_keeps_legacy_files_for_retry(self):
349        provider = Provider(lambda _request: text("done"))
350        self.addCleanup(provider.close)
351        with Albedo(provider) as app:
352            session = app.session()
353            database = app.home / "albedo.sqlite"
354            picker_path = app.home / "picker.json"
355            original = picker_path.read_bytes() if picker_path.exists() else b"{}"
356            legacy = json.dumps({"pinned": [session], "opens": {session: 7}}).encode()
357
358            def prepare(_app):
359                with sqlite3.connect(database) as db:
360                    db.execute("DELETE FROM session_preferences_import")
361                    db.execute(
362                        "CREATE TRIGGER reject_preference_import BEFORE UPDATE OF pinned ON sessions BEGIN SELECT RAISE(ABORT, 'import fixture failure'); END"
363                    )
364                picker_path.write_bytes(legacy)
365
366            def restore(_app):
367                with sqlite3.connect(database) as db:
368                    db.execute("DROP TRIGGER IF EXISTS reject_preference_import")
369                picker_path.write_bytes(original)
370
371            try:
372                with self.assertRaises(AssertionError):
373                    app.restart(prepare=prepare)
374                self.assertEqual(picker_path.read_bytes(), legacy)
375                with sqlite3.connect(database) as db:
376                    self.assertEqual(
377                        db.execute(
378                            "SELECT version FROM session_preferences_import"
379                        ).fetchall(),
380                        [],
381                    )
382                    self.assertEqual(
383                        db.execute(
384                            "SELECT pinned,opens FROM sessions WHERE id=?", (session,)
385                        ).fetchone(),
386                        (0, 0),
387                    )
388            finally:
389                app.restart(prepare=restore)