Skip to content

Dynamic images: load at runtime, journal the bytes, replay offline#146

Open
ctate wants to merge 17 commits into
mainfrom
feat/dynamic-images
Open

Dynamic images: load at runtime, journal the bytes, replay offline#146
ctate wants to merge 17 commits into
mainfrom
feat/dynamic-images

Conversation

@ctate

@ctate ctate commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator

Images were static — registered from app.zon assets at launch. This is the first full dynamic media pipeline: apps load images at runtime from disk or the network, show them in views, and recorded sessions replay byte-identically with the network absent.

  • Cmd.imageLoad in the TS tier and fx.loadImage in Zig, mirroring the audio vocabulary: app-chosen model-owned ImageId, local-then-cache-then-network source cascade with atomic content-addressed cache install, one terminal result event carrying the full outcome taxonomy (the registry's own error classes plus the fetch taxonomy — fourteen states, never a silent drop).
  • Loaded bytes are journaled as effect results through a content-addressed blob store beside the session journal (blobs/<sha256-prefix>, hash + length in the record, same bytes twice = one blob). Replay resolves, verifies, and re-registers from blobs alone — proven fingerprint-identical with the fixture server stopped.
  • Conscious break (changelog fragment): journal format v5 → v6 for the new image record kind; v5 readers refuse loudly at the preamble per the v4 precedent. The TS cmd wire adds opcode 0x10 additively within cmd_format_version 2 — no existing record changes bytes, older hosts refuse the opcode by name.
  • Markup gains the <image> leaf (registry element 67) binding a dynamic ImageId from the model, with the avatar-style negative-value teaching in both engines and the model contract wired through native check.
  • Encoded sources are bounded (1.25 MiB, whole-failure too_large, never truncated); decode and registration run at drain on the loop thread through the existing registered-image storage — no new pools.
  • New docs page (docs/dynamic-images) with a transpiler-verified TS-first sample, the limits, and the replay guarantee; ~25 new tests across executor, blob store, replay, markup, and TS conformance/e2e.

ctate added 7 commits July 17, 2026 15:58
… cascade

- New .image effect kind in effects.zig: local-path-then-url resolution with a content-addressed url cache (imageCachePath mirrors audioCachePath under images/), decode + registration through the registered-image seam at drain time, and one terminal Msg carrying loaded dimensions or the same error classes the runtime API raises
- Journal format v6: the .image effect-result record kind with outcome/dimensions and the blob-store content-address fields; replay feeds recorded terminals verbatim and re-registers journaled bytes best-effort
- Fake-executor seams (pendingImageLoad*, feedImageBytes, feedImageResult) plus effects_image_tests.zig covering the request shape, the full decode-register path, every failure class, cancel, and the real executor's local/network/cache-hit cascade against a loopback fixture
- session_blobs.zig: blobs/ beside the journal, one file per distinct payload named by SHA-256 prefix (the audio-cache hashing convention) — dedup by construction, verified on read; DirBlobStore for the app runner, MemoryBlobStore for tests
- The recorder moves an image record's encoded source bytes into the blob store at effect-result time and journals hash + length; replay resolves the blob, verifies address and length, and feeds the recorded bytes — byte-identical, offline, refusing loudly (ReplayMissingBlob) when the store is absent or damaged
- Session tests: record-replay equality with fingerprint checkpoints over two loads sharing one blob, a journaled decode failure, and a regenerating rejection; plus the no-blob-store recording refusal and the journal codec round-trip
- New element code 67 (image, widget_kind image, pictorial a11y class) reusing attr 38's binding grammar broadened to avatar+image; the binding is required on the leaf (an unbound image is dead markup, the icon-without-name policy)
- Both engines mirror the avatar guard: binding-only, integer-only, and negative model values fail the build with the teaching message instead of trapping in the u64 cast; teaching messages renamed to the shared image_binding_* vocabulary
- Pin test updated for the new element fingerprint (print-pins: elements 67, element_names 55); validator/interpreter/compiled/contract tests cover resolve, the 0 sentinel, every misuse, and hand-view parity; LSP docs, SKILL.md tables, and the docs vocab JSON carry the new entry
- SDK: ImageState/ImageEventArm/ImageEventKind, ImageSource/ImageRoute, the image_load Cmd member and factory keyed by the app's numeric ImageId (any number expression — ids are model data)
- Transpiler: emitImageLoadCmd with the audio source-object grammar, the four-field arm shape check (state matched by member name, width/height/status numbers), and compile-time id/bounds gates; rt.zig gains opcode 0x10 additive within cmd_format_version 2 (the 0x0D-0x0F precedent) and the cmdImageLoad builder
- Host bridge: an id-keyed image table routing the ONE terminal by field name, the spawn discipline for duplicate live ids (bridge-side "rejected" at the post-cycle boundary), and image_cache_dir wiring deriving the content-addressed cache path from the URL like audio; covered by wire-layout, conformance, run-fidelity, and host e2e suites plus the SKILL.md vocabulary
- New docs page (/dynamic-images) covering Cmd.imageLoad TS-first with the Zig toggle, the source cascade and content-addressed cache, the honest result vocabulary and fixed limits, and the byte-identical offline replay guarantee via the session blob store
- Native UI's Images section now leads with the first-class load path and shows the <image> markup leaf; the TypeScript Cmd table gains the imageLoad row
- Changelog fragment states the journal v6 format break plainly (v5 journals refused at the preamble; re-record)
- The live-preview host embeds the runtime and markup engines, which now carry the image effect kind and the <image> element (schema code 67)
- The core declares the ImageState alias itself (the named-alias rule the arm check enforces) and tracks the last state as a plain enum field
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
native-sdk Ready Ready Preview, Comment Jul 18, 2026 10:54pm

ctate added 4 commits July 18, 2026 14:19
- src/root.zig and src/runtime/root.zig now carry EffectImageResult, EffectImageOutcome, imageCachePath, max_effect_image_path_bytes, max_effect_image_bytes, and effect_image_blob_hash_len beside the audio exports they mirror, so an external Zig app can write an Effects.imageMsg Msg arm and reason about the image bounds without module-internal paths.
- A compile-shaped export pin in effects_image_tests.zig (the media-surface pin's convention) holds both roots to the effects module's types, builds the Msg arm through the public constructor, and derives a cache path through the public root.
- runSessionReplay built its null platform with the image decoder OFF, so production replay dropped every journaled load's pixels (and replayed screenshots lost images) while the branch's tests enabled the decoder by hand; the runner now installs the recording host's own context-free codec (CGImageSource / gdk-pixbuf / WIC) into the headless services via platform.installHeadlessImageCodec, decoding JOURNALED blob-store bytes only — the network stays absent.
- A codec-less null-platform build falls back to the null platform's strict test-PNG decoder, stated honestly in the installer's doc; the comptime platform dispatch keeps the desktop arms out of test-tier analysis so the unit suite links without host shims.
- The offline blob-store replay test now installs its decode seam through the SAME call the runner makes instead of flipping image_decode manually (the exact gap that let this slip), and a file-contains step pins the runner wiring plus all three per-platform installers.
- A gallery screen (or one Cmd.batch) with 16 loads already in flight reached the bridge table's freeImageIndex panic, contradicting the documented exactly-one-result contract; a full table now routes pushImageReject like the sibling refusals — the engine's own slot-exhaustion vocabulary, and consistent with audio, whose single channel replaces in place and never crashes.
- The e2e suite fills all 16 entries with dynamic model-owned ids, proves the 17th dispatch answers state "rejected" through the event arm, and feeds one of the 16 live loads to its loaded terminal to show the refusal touched nothing else.
- The contract is a positive integer BELOW 2^53, but the emitter allowed literals <= 2 ** 53 and the host allowed dynamic ids <= 9007199254740992.0; 2^53 is the first f64 that aliases a neighbor (2^53 + 1), so both tiers now reject it — the emitter via Number.isSafeInteger with the NS1030 message naming the bound, the host via a strict < with the aliasing rationale in place.
- Boundary tests on both sides: conformance pins 2^53 - 1 building and 2^53 stopping at compile time (NS1030), and the e2e suite drives model-owned dynamic ids through the bridge — 2^53 - 1 parks a live load with the id intact, 2^53 answers state "rejected" without touching it.
ctate added 4 commits July 18, 2026 15:53
- The image event arm is now five fields matched by name (id/state/width/height/status): the engine's EffectImageResult always carried the id, but the arm dropped it, leaving two concurrent loads sharing one arm uncorrelatable in update.
- Bridge rejections echo the refused id too (0 for an id the wire cannot carry exactly — there is no honest integer to echo for one), staged as tag+id pairs at the same post-cycle boundary.
- Every tier that pins the arm shape learns the fifth field: SDK types, emitter conformance, the host's comptime shape check, the wire docs, the e2e fixtures (including a new concurrent-loads test distinguishing completions by the echoed id), and the docs page whose sample now adopts cover: msg.id.
- The reject staging buffer was fixed at the 16-entry table size with a panic past it, but the count is the app's to choose: a Cmd.batch of 17 loads against a full table crashed on the seventeenth instead of yielding 17 rejected results.
- Staging still exists because the walk cannot dispatch mid-command (the wire bytes are frame-arena resident and a nested cycle's frame reset would free them), so the stage now spills lazily: the table-sized inline buffer covers everyday cycles allocation-free, and the first overflow takes one engine-allocator block sized by the wire's own arithmetic bound (an image_load record is at least 30 bytes, so cmd.len / 30 caps the count without a second record parser to keep in sync).
- Delivery is unchanged — every reject still dispatches after the issuing cycle's frame reset, nows first, spawn rejects next, image rejects last, in record order — and the new e2e drives a 17-load batch against a full table to 17 boundary rejections with the 16 live loads untouched.
- The documented "cancelled" outcome was unreachable from TS: Cmd.cancel is string-keyed and never inspects the numeric image table, so a stale load pinned its id against same-id retries until it terminated. Cmd.imageCancel(id) routes to the fx.cancel(id) path the engine already covers — loud like spawn's cancel (the load's own event arm delivers state "cancelled" and the entry retires, freeing the id), a no-op for an id with no live load, mirroring how audioStop is audio's close rather than an overload of the silent string-keyed cancel.
- Wire record image_cancel [op 0x11][id f64 LE], additive within cmd_format_version 2; full tier coverage — SDK factory, emitter with imageLoad's NS1030 id-literal gates, conformance, wire-layout pin, host decode, and an e2e proving cancelled delivery plus a same-id retry parking a fresh load.
- The 0x11 opcode overlap with feat/menu-bar-lifecycle is known cross-branch and handled by the merge-order reconcile.
- Recording's dedup probe treated ANY existing hash-named file as the bytes, so a damaged blob let future recordings seal successfully while replay refused them. The probe now reads the existing file back chunk-wise against the incoming bytes (equality against the caller's bytes IS the hash check — their address is the file's name, and no blob-sized buffer is staged) and skips the write only on an exact match.
- A mismatch — damage, truncation, an unreadable file — is a repairable state while the true bytes are in hand: the write falls through to the same atomic partial+rename a fresh install uses, with a debug-scoped log line, never a hard failure. Cost: a dedup hit is one bounded read (<= the 1.25 MiB blob budget) instead of one existence probe.
- MemoryBlobStore had the same blind-dedup shape and repairs in place identically; tests pin corrupt-then-rewrite repair on both stores (dir store covering flipped bytes and truncation through the atomic path) alongside the existing one-blob-for-same-bytes dedup pins.
ctate added 2 commits July 18, 2026 17:43
- A rejected image terminal journals from both sides of the executor seam, but replay skipped every one as regenerable loop-side validation - a worker-refused load (a host-less URL that passes the loop's scheme check, an executor that could not start) parked forever in the fake executor and diverged the replay.
- Loop-side validation refusals now mark themselves with the record's exit reason (the .host records' convention) and keep regenerating; unmarked rejections are executor truth and feed like every other worker terminal.
- Covered by a record/replay test that drives a host-less URL through the real executor's worker and asserts the rejected Msg delivers fingerprint-identical offline.
- Every install wrote <cache_path>.partial, so two concurrent loads of one URL truncated each other's temp and a rename could publish a half-written file into the cache name; the temp now carries the slot generation (operation-unique), keeping the rename atomic with last-whole-file-wins.
- A failed install deletes its own temp, so the cache directory never accumulates this process's debris; only a hard crash mid-install can leave one, in the OS-purgeable caches directory.
- Pinned by a temp-name uniqueness test on the extracted imageCachePartialPath seam plus a two-loads-one-url executor test asserting both terminals, an intact cache entry, and no .partial debris.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant