//// Kernel image wire decoding and old journal records must survive format //// changes; synthetic corrupted and legacy records cannot arise in E2E. import albedo/daemon/images import albedo/daemon/store import albedo/harness/extensions/python/cells import albedo/harness/extensions/python/kernel as python import albedo/harness/extensions/python/migrations/cell_images import albedo/openai_api/types import gleam/dynamic import gleam/dynamic/decode import gleam/erlang/atom import gleam/json import gleam/list import gleam/option.{None, Some} import gleeunit/should import sqlight /// A PNG signature and IHDR header for a 2x3 image: enough for the header /// check a stored record gets on load. const png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAAD" pub fn the_kernel_decoder_reads_images_at_the_boundary_test() -> Result( python.Outcome, json.DecodeError, ) { let frame = fn(images) { json.object([ #("id", json.string("cell")), #("status", json.string("ok")), #("output", json.string("")), #("value", json.string("")), #("truncated", json.bool(False)), ..images ]) |> json.to_string } // A kernel from before images sends no field at all. let assert Ok(plain) = json.parse(frame([]), python.outcome_decoder()) #(plain.images, plain.image_errors) |> should.equal(#([], [])) // Whole PNGs, IEND included: the daemon refuses an image cut short. let small = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAAElFTkSuQmCC" let wide = "iVBORw0KGgoAAAANSUhEUgAAAAUAAAADCAIAAADUVFKvAAAAAElFTkSuQmCC" let images = json.array([small, "bm90IGFuIGltYWdl", wide, "not base64!"], json.string) let assert Ok(mixed) = json.parse(frame([#("images", images)]), python.outcome_decoder()) // Both lists keep the order the cell showed its images in. mixed.images |> list.map(fn(image) { let #(_, width, height, _) = types.image_meta(image) #(width, height) }) |> should.equal([#(2, 3), #(5, 3)]) mixed.image_errors |> should.equal([ "image 2: not a PNG, JPEG, or WebP albedo can read", "image 4: not a PNG, JPEG, or WebP albedo can read", ]) // A wrong shape is a broken kernel, not an unreadable image. let assert Error(_) = json.parse( frame([#("images", json.array([1], json.int))]), python.outcome_decoder(), ) } /// The record shapes albedo stored before tool results carried images. type LegacyInput { ToolOutput(String, String) } type LegacyOutcome { Outcome(String, python.Status, String, String, Bool) } pub fn records_saved_before_images_still_load_test() -> Nil { let assert Ok(input) = unpack_input(term_to_binary(#(1, ToolOutput("call", "text")))) input |> should.equal(types.ToolOutput("call", "text", [])) let saved = Outcome("cell", python.Succeeded, "out", "'v'", False) let assert Ok(Ok(outcome)) = unpack_cell(term_to_binary(#(1, Ok(saved)))) outcome |> should.equal(python.Outcome( "cell", python.Succeeded, "out", "'v'", False, [], [], None, )) } pub fn a_journaled_image_is_checked_again_on_load_test() -> Nil { // Journaled before cells were timed, so its duration is unknown. let assert Ok(Ok(outcome)) = unpack_cell(journaled_png(2)) outcome.duration |> should.equal(None) // The payload is 2 pixels wide; a record claiming 9 was altered. unpack_cell(journaled_png(9)) |> should.equal(Error(Nil)) } /// A journaled cell outcome carrying one PNG, built term by term. fn journaled_png(width: Int) -> BitArray { let atom = fn(name) { atom.to_dynamic(atom.create(name)) } let image = dynamic.array([ atom("image"), dynamic.string("image/png"), dynamic.string(png), dynamic.int(width), dynamic.int(3), dynamic.int(24), ]) let outcome = dynamic.array([ atom("outcome"), dynamic.string("cell"), atom("succeeded"), dynamic.string(""), dynamic.string(""), dynamic.bool(False), dynamic.list([image]), dynamic.list([]), ]) term_to_binary(#(1, Ok(outcome))) } /// Legacy cell records are not producible by the current daemon wire format. pub fn old_cell_images_migrate_without_losing_their_readers_test() -> Nil { let path = temporary_database() let assert Ok(ledger) = store.start(path, images.schema) cell_images.run(ledger, path <> ".missing-backup") |> should.equal(Ok(0)) let assert Ok(_) = cells.initialise(ledger) let assert Ok(_) = store.write( ledger, "INSERT INTO cells(id,session,source,status,payload) VALUES('cell','s','show_image(...)','finished',?)", [sqlight.blob(journaled_png(2))], ) let backup = path <> ".backup" cell_images.run(ledger, backup) |> should.equal(Ok(1)) cell_images.run(ledger, backup) |> should.equal(Ok(0)) let assert Ok(cell) = cells.get(ledger, "cell") let assert Some(Ok(outcome)) = cell.outcome let assert [stored] = outcome.images let assert types.StoredData(read: read, ..) = types.image_data(stored) read() |> should.equal(Ok(png)) let assert Ok([kind]) = store.read( ledger, "SELECT typeof(data) FROM images", [], decode.field(0, decode.string, decode.success), ) kind |> should.equal("blob") store.close(ledger) cleanup(path) cleanup(backup) } @external(erlang, "albedo_runtime_test_support", "temporary_database") fn temporary_database() -> String @external(erlang, "albedo_runtime_test_support", "cleanup") fn cleanup(path: String) -> Nil @external(erlang, "erlang", "term_to_binary") fn term_to_binary(value: a) -> BitArray fn unpack_input(bytes: BitArray) -> Result(types.Input, Nil) { unpack_with(bytes, fn(_) { Error(Nil) }) } @external(erlang, "albedo_conversation", "unpack") fn unpack_with( bytes: BitArray, read: fn(String) -> Result(String, Nil), ) -> Result(types.Input, Nil) @external(erlang, "albedo_native", "unpack_cell") fn unpack_cell( bytes: BitArray, ) -> Result(Result(python.Outcome, python.Error), Nil)