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