makepad/libs/asset/store/tests/http/client_e2e.rs
Admin e37c263b9c ai backbone: the hub era — makepad_ai deleted, every backend is a hub pipe; machine residency elections, job leases, ETA placement; the store only stores; creator pipelines run in the app (aicore)
Squashed from work:
- asset-ai: FastH3 4-step fast video backend; clip keyframes on the wire
- asset-ui: loop video chains — text→image→video that ends where it began
- h3: safetensors -> pruned-Q4_K GGUF quantizer for the 24GB DiT tiers
- h3_quant_gguf verify: row-error gates calibrated to the measured Q4_K floor
- asset-ai realtime: the feedback loop — the source anchors, the drifted frame inits
- asset-ai realtime: a feedback loop survives a resize and travels by default
- asset-ai realtime: the feedback loop frees itself from the feed handshake and pauses for its listener
- asset-ai realtime: the outbound encode leaves the loop's critical path
- asset-ai ocr: the ocr domain — Chandra 2 at page resolution, and the tower goes planner-owned
- llm slots: a lane can hold an image span — embedding prefill and a rope cursor of its own
- vision tower on CUDA: the encode leg gets its two missing kernels
- llm/ocr: one M-RoPE grid encoder for both image paths, and a livelock made an error
- vision tower on CUDA: the f16 GEMM keeps the precision it was throwing away
- live: a feed that moves box takes its trip with it — one seed image
- vision tower on CUDA: the tiled attention becomes bit-exact, and tensor cores go
- llm prefill on CUDA: the MMA attention kernel gets the tile a 4-to-1 model needs
- asset-ai ocr: the CUDA encode lane joins the integration — vision-parity sits beside run's three arms, and the kernels
- Merge branch 'ocr-perf-integration' into work
- asset-ai: the live anchor can follow the trip, and text leaves the 5090
- asset-ai: the camera moves the world, and the world starts still
- asset-import: the EA strategy classics, in the one 2D contract
- rtsmap: one seeded generator for tiled strategy maps
- asset-ui: one card for the strategy classics, with a pack dropdown
- asset-ai: music3 reference-audio path, ocr/h3 backends, registry
- asset: mp4 sample index for range-streaming, chat tools, import profiles
- cnc: tiberium is twelve growth frames, not twelve empty variants
- platform: native file and save dialogs, in-house on all three desktops
- chat: the scan holds out for a lane home
- chat: a full home queues you — take the free lane
- chat: the preload has a percentage, and the boundless cap stops showing
- llm cuda: the 32x2 attention tile — even GQA ratios stay on MMA
- sa3 gets a bake path: the sfx model's tables precomputed by a diffusion-side bin
- sqlite_query: anti-join regression test
- td import: HARV's second frame block is its harvesting cycle, not a turret
- asset-ui: sprite enhancement runs on the 32B dev DiT — distillation, not the prompt, was the ceiling
- ai-hub: makepad-asset-ai becomes makepad-ai-hub at libs/ai/hub, the chat pane becomes makepad-chat-ui, the service bin
- asset-ui: test health fixtures grow the realtime field they were born without
- ai-hub: one home at ~/.makepad — weights/ run/ cache/ logs/, the service cache migrates from ai_content by a single re
- ai-hub: subprocess workers die with the node — process groups everywhere, PDEATHSIG on linux, one KILL_ON_JOB_CLOSE Jo
- ai-hub: the hub object — AiHub::in_process, pipes vocabulary, and the local LLM engine generalized out of mpfiles (aic
- strict-json: the dependency-free JSON module gets its own crate; asset-client re-exports it so nothing downstream move
- ai-hub: the machine layer — node entries, the 0600 machine token, and the residency election that IS the lock (aicore
- ai-hub: MPHUB1 — the fabric beacon only dedicated nodes can send (aicore §4)
- ai-hub: job leases — work lives only while it is renewed (aicore §8)
- asset-creator: the pipeline library is born — specs, the deps gate, and the derived-state law (aicore §9)
- ai-hub: RAM residency facts — the CPU-side twin of residency.rs (aicore §3)
- ai-hub: ETA placement primitives — relative GPU throughput, the four-term estimate, and an observable breakdown (aicor
- ai-hub: leases go live on the wire — origin fields on submit, /job/<id>/keepalive, /bye, and the reaper that cancels w
- ai-hub: the chat providers move in — fleet qwen, openai, grok, claude/codex/grok CLIs, the responses driver, and the w
- asset-creator: the engine — one pipeline run against the hub, deps-gated, spliced, cancellable, resumable-by-construct
- ai-hub: the machine node mode — --machine binds loopback, registers in ~/.makepad/run, and exits on its own once idle
- asset-creator: makepad-creator-run — the detached client for runs that must outlive a window (aicore §9)
- ai-hub: a native Claude Messages-API provider — API-key or Claude Code OAuth, bounded SSE streaming, injected tools (a
- route + converse: off makepad_ai — the Agent seam moves to converse, route's cloud dispatcher rides the hub's Claude p
- asset-creator: the preset tables move in — fifteen chain-policy constants shared by every creator app (aicore §9 / P6)
- makepad_ai is deleted — every backend is a hub pipe, the agent seam lives with its consumers (aicore §14, decided 2026
- ai-hub: loads hold the machine residency election — set_model_state claims on Loaded and publishes the service port (a
- ai-hub: chats run the machine election — route to a serving holder, wait on a loading one, claim and publish when open
- ai-hub: pick_for_domain_eta — ETA-ranked placement over the shared hard-filter core (aicore §6 / P4)
- asset-creator: the engine picks a provider per stage at dispatch time — a chain's later stages see fresh fleet state (
- ai-hub: the fabric secret gates the service HTTP surface — bearer on everything but /health and the ticketed peer path
- vj: DREAM runs execute in the app — pipelines.rs becomes the run it used to watch (aicore §9 / F1)
- asset-creator: the runner — generate one thing and put it in the catalog, one implementation for every surface (aicore
- chat-ui: the session runs in the app — no broker anywhere on the chat path (aicore P8 / F5)
- asset-store: assets.query is a first-class query endpoint — the bounded SQL surface outlives the broker (aicore P8 / F
- asset-creator: CreatorTools — the chat tool pack for a store that only stores (aicore §9 / P8)
- asset-store: the shrink — the store stores (aicore P7)
- importer + asset-server host: the coordination era ends (aicore P7)
- store config purge + asset-ui goes fleet-direct; the derive protocol gets its route proof (aicore P7)
- client + chat dispatcher: the dead wire comes out (aicore P7/P8)
- ai-hub: 0.3.0 — the health version says which era a node runs
- ai-hub: the default fleet is 'gen' — apps hear the LAN without env plumbing
- ai-hub: the preload note percents the prefill, not the job bar
- ai-hub: conversations keep their KV — the wire mirror, the lane identity, the in-turn dynamic context (aicore §7)
- ai-hub: an open-think model is thinking from its first token
- libs: the zero-warning sweep — stitch casts say what they mean, xatlas keeps upstream's surface quietly
- zero-warning sweep, round two — the first full-workspace pass
- zero-warning sweep, round three — the model lanes and the deep examples
- zero-warning sweep, round four — the last stragglers
- zero-warning sweep, round five — vj and chat-ui
- zero-warning sweep, round six — three cascades

Co-authored-by: Claude <info@makepad.nl>
2026-09-01 16:46:31 +02:00

393 lines
16 KiB
Rust

//! THE wire-contract proof: the real `makepad-asset-client` against a
//! real `AssetServer`, over real sockets — connect, listing probe, publish,
//! search, detail, event subscription, jobs and the worker protocol, all in
//! one flow. No fixtures, no mocks, on either side.
//!
//! This also pins the root-authorization semantics: the bootstrap admin
//! token (the documented default credential) can provision and exercise the
//! whole server — upload, register, annotate, publish, alias, enqueue,
//! claim, cancel — without any explicit self-grants.
use makepad_asset_store::{AssetServer, ServerConfig};
use makepad_asset_client::{
ApiEndpoints, AssetClient, CatalogQuery, CatalogSubscriberConfig, CatalogSubscriptionEvent,
ClientConfig, ClientError, PublishFile, PublishRequest, PublishThumbnail,
RoomClaimDto,
};
use makepad_asset_data::{
AssetAlias, AssetId, AssetKind, AssetRevisionRef, ContentLock, FileRole, GameAlias,
GameRevisionManifest, LockEntry, MediaType, ThumbnailMedia, ThumbnailMeta,
};
use std::path::PathBuf;
use std::str::FromStr;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
static DIR_COUNTER: AtomicU64 = AtomicU64::new(0);
fn test_root(name: &str) -> PathBuf {
let n = DIR_COUNTER.fetch_add(1, Ordering::Relaxed);
std::env::temp_dir().join(format!(
"mp_asset_e2e_{}_{}_{}",
std::process::id(),
n,
name
))
}
fn start_server(name: &str) -> (AssetServer, String) {
let root = test_root(name);
let mut cfg = ServerConfig::new(root.clone());
cfg.control_addr = "127.0.0.1:0".parse().unwrap();
cfg.data_addr = "127.0.0.1:0".parse().unwrap();
cfg.bootstrap_admin = true;
cfg.log = false;
let server = AssetServer::start(cfg).expect("server start");
let token = std::fs::read_to_string(root.join("admin-token"))
.expect("admin token")
.trim()
.to_string();
(server, token)
}
fn connect(server: &AssetServer, token: &str, cache: &str) -> AssetClient {
let mut cfg = ClientConfig::new(test_root(cache));
cfg.token = Some(token.to_string());
let endpoints = ApiEndpoints {
control: server.control_addr(),
data: server.data_addr(),
};
AssetClient::connect(cfg, endpoints, Some(server.server_id()))
.expect("real connect (health + credentialed listing probe)")
}
#[test]
fn real_client_full_stack_roundtrip() {
let (server, token) = start_server("full_stack");
let mut client = connect(&server, &token, "full_stack_cache");
// Empty catalog: the listing route answers honestly.
let page = client.assets_page(None, None, 10).expect("listing");
assert!(page.assets.is_empty());
assert!(page.next.is_none());
// Subscribe to committed catalog events BEFORE publishing.
let mut sub_cfg = CatalogSubscriberConfig::default_v1();
sub_cfg.wait_ms = 2_000;
let mut subscriber = client.subscribe_catalog(sub_cfg).expect("subscribe");
// Publish a video artifact end to end with the ADMIN token (root
// bypass: no explicit grants were ever made).
let artifact = vec![0xAB; 6_000];
let thumb = vec![0xCD; 1_500];
let mut request = PublishRequest::new(
"gen",
AssetKind::Video,
"E2E neon clip",
PublishFile {
bytes: artifact.clone(),
media: MediaType::Mp4,
role: FileRole::Video,
media_millis: 5_200,
dims: None,
},
PublishThumbnail {
bytes: thumb.clone(),
media: ThumbnailMedia::Png,
width: 512,
height: 512,
views: Vec::new(),
},
);
request.alias = Some(AssetAlias::from_str("gen/e2e-neon").unwrap());
request.categories = vec!["demo".into()];
request.prompt = "a neon e2e clip".into();
let published = client.publish_artifact(&request).expect("publish via admin token");
// Search (server-side kind filter) finds it.
let mut query = CatalogQuery::browse(10);
query.kind = Some(AssetKind::Video);
let found = client.catalog_search(&query, None).expect("search");
assert!(found.hits.iter().any(|h| h.asset_id == published.asset_id));
let hit = found.hits.iter().find(|h| h.asset_id == published.asset_id).unwrap();
assert_eq!(hit.kind, Some(AssetKind::Video));
assert_eq!(hit.title, "E2E neon clip");
// Full detail: candidates carry the published revision.
let detail = client.asset_detail(&published.asset_id).expect("detail");
assert_eq!(
detail.latest_published().expect("published candidate").revision,
published.revision
);
// Manifest + blob bytes round-trip through the verified cache.
let manifest = client.fetch_asset_manifest(&published.revision).expect("manifest");
assert_eq!(manifest.asset_id, published.asset_id);
let bytes = client
.fetch_blob_bytes(&published.artifact_blob, Some(artifact.len() as u64))
.expect("blob");
assert_eq!(bytes, artifact);
// The same real client publishes and reads an exact game revision through
// the public `/v1/game-revisions/{grev}` contract. This specifically
// guards the Sandbox bootstrap path against fixture-only routes.
let game = client.register_game("gen", None).expect("register game");
let locked_alias = AssetAlias::from_str("gen/e2e-neon").unwrap();
let locked = AssetRevisionRef {
asset_id: published.asset_id,
revision: published.revision,
};
let lock = ContentLock {
game_id: game,
entries: vec![LockEntry {
alias: locked_alias,
asset_id: locked.asset_id,
revision: locked.revision,
}],
closure: vec![locked],
variant_sets: Vec::new(),
};
let lock_bytes = lock.to_canonical_bytes().expect("lock bytes");
let splash = b"game { model: gen/e2e-neon }".to_vec();
let game_toml = b"[game]\nname=\"E2E\"\n".to_vec();
let game_thumb = vec![0xE2; 1_024];
let splash_blob = client.upload_blob("gen", &splash).expect("game splash blob");
let manifest_blob = client
.upload_blob("gen", &game_toml)
.expect("game toml blob");
let lock_blob = client.upload_blob("gen", &lock_bytes).expect("game lock blob");
let thumb_blob = client
.upload_blob("gen", &game_thumb)
.expect("game thumbnail blob");
let game_manifest = GameRevisionManifest {
game_id: game,
name: "E2E generated world".into(),
description: "Real client/server game revision roundtrip".into(),
author: "Asset Server test".into(),
splash_blob,
manifest_blob,
lock_blob,
thumbnail: ThumbnailMeta {
blob: thumb_blob,
media: ThumbnailMedia::Png,
width: 512,
height: 512,
byte_len: game_thumb.len() as u64,
views: Vec::new(),
},
catalog_snapshot: None,
search_algorithm_version: 1,
engine_version: 1,
protocol_version: 1,
splash_byte_len: splash.len() as u64,
};
let game_revision = client
.stage_game_revision(&game, &game_manifest, &lock)
.expect("stage game revision");
assert_eq!(
client
.fetch_game_manifest(&game_revision)
.expect("fetch staged game revision"),
game_manifest
);
client
.publish_game_revision(&game, &game_revision)
.expect("publish game revision");
let game_alias = GameAlias::from_str("gen/games/e2e-world").unwrap();
client
.put_game_alias(&game_alias, &game, &game_revision)
.expect("set game alias");
let resolved_game = client.resolve_game_alias(&game_alias).expect("resolve game alias");
assert_eq!(resolved_game.game_id, game);
assert_eq!(resolved_game.head_revision, game_revision);
// Listing now carries the asset.
let page = client.assets_page(Some("gen"), None, 10).expect("listing 2");
assert_eq!(page.assets.len(), 1);
assert_eq!(page.assets[0].asset_id, published.asset_id);
assert!(page.assets[0].created_ms > 0);
// The event stream delivers the publication (kind-stamped: the client
// publish flow annotates BEFORE publishing).
let deadline = Instant::now() + Duration::from_secs(10);
let mut saw_publish = false;
while Instant::now() < deadline && !saw_publish {
for event in subscriber.poll() {
if let CatalogSubscriptionEvent::Events { events, .. } = event {
for ev in events {
if ev.asset_id == Some(published.asset_id)
&& ev.kind == makepad_asset_client::CatalogEventKind::AssetPublished
{
assert_eq!(ev.content_kind, Some(AssetKind::Video));
assert_eq!(ev.revision, Some(published.revision));
saw_publish = true;
}
}
}
}
std::thread::sleep(Duration::from_millis(50));
}
assert!(saw_publish, "asset_published event must reach the subscriber");
subscriber.shutdown();
// The jobs + worker protocol left the store (aicore P7): generation is
// client-driven now. Catalog, publish, events and downloads above are the
// whole remaining surface.
drop(server);
}
#[test]
fn publish_retry_recovers_a_published_revision_missing_its_alias() {
let (server, token) = start_server("publish_alias_recovery");
let mut client = connect(&server, &token, "publish_alias_recovery_cache");
let asset_id = AssetId::from_bytes([0x42; 16]);
let alias = AssetAlias::from_str("gen/history-recovery").unwrap();
let mut request = PublishRequest::new(
"gen",
AssetKind::Video,
"Recoverable clip",
PublishFile {
bytes: vec![0xA5; 6_000],
media: MediaType::Mp4,
role: FileRole::Video,
media_millis: 2_000,
dims: None,
},
PublishThumbnail {
bytes: vec![0x5A; 1_500],
media: ThumbnailMedia::Png,
width: 512,
height: 512,
views: Vec::new(),
},
);
request.asset_id = Some(asset_id);
// Models the importer crash/failure point seen in production: the asset
// and candidate are Published, but no alias was committed afterward.
let first = client.publish_artifact(&request).expect("initial publish without alias");
assert_eq!(first.asset_id, asset_id);
assert!(client.resolve_alias(&alias).is_err(), "alias must not exist yet");
// Exact same manifest/revision: retry must not re-stage Published. It
// resumes at the missing idempotent alias write instead.
request.alias = Some(alias.clone());
let recovered = client.publish_artifact(&request).expect("recover missing alias");
assert_eq!(recovered.asset_id, first.asset_id);
assert_eq!(recovered.revision, first.revision);
let resolved = client.resolve_alias(&alias).expect("recovered alias");
assert_eq!(resolved.asset_id, asset_id);
assert_eq!(resolved.head_revision, first.revision);
let detail = client.asset_detail(&asset_id).expect("detail");
assert_eq!(detail.candidates.len(), 1, "retry must not create a duplicate candidate");
assert_eq!(detail.latest_published().unwrap().revision, first.revision);
}
#[test]
fn non_root_principal_still_needs_grants() {
// The root bypass must not weaken scoping for everyone else: a fresh
// principal without grants is denied enqueue/publish paths.
let (server, admin_token) = start_server("scoped");
let admin = connect(&server, &admin_token, "scoped_admin_cache");
let _ = admin; // admin connect itself is the read-probe check
// Mint a token for an ungranted principal via raw client API calls is
// an admin-only flow the typed client does not wrap; use the admin to
// publish and then verify the ungranted principal cannot. Simplest
// hostile check without extra plumbing: a bogus (well-formed, unknown)
// token fails the connect probe with the uniform 401.
let mut cfg = ClientConfig::new(test_root("scoped_cache"));
cfg.token = Some(format!("mpat_{}", "77".repeat(32)));
let endpoints = ApiEndpoints {
control: server.control_addr(),
data: server.data_addr(),
};
match AssetClient::connect(cfg, endpoints, Some(server.server_id())) {
Err(ClientError::Unauthenticated) => {}
other => panic!("unknown token must be uniformly refused, got {other:?}"),
}
}
/// The rendezvous, end to end through the real client: two players press
/// Play on the same game and the second is sent to the first, not handed a
/// second claim. Then the unreachable-room escape, which is what keeps a
/// stale record from becoming a dead end for everyone who follows.
#[test]
fn real_clients_meet_in_one_room_and_never_dead_end_on_a_stale_one() {
let (server, token) = start_server("rooms");
let rik = connect(&server, &token, "rooms_rik_cache");
let sam = connect(&server, &token, "rooms_sam_cache");
// Nobody is playing. Both apps see the same nothing.
assert!(rik.rooms(Some("arcade")).expect("list").is_empty());
assert!(sam.rooms(None).expect("list").is_empty());
// Rik presses Play. No room, so he hosts and takes the claim.
let claimed = rik
.claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None)
.expect("claim");
let RoomClaimDto::Claimed { room, token: host_token } = claimed else {
panic!("the first press must take the claim");
};
// Sam presses Play on the same game. He is told where Rik is — and is
// NOT given a claim of his own, which is the whole point.
let second = sam
.claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, None)
.expect("claim");
let RoomClaimDto::Occupied { room: found } = second else {
panic!("the second press must be sent to the first");
};
assert_eq!(found, room);
assert_eq!(found.invite, "10.0.0.7:5000:5001#ab");
assert_eq!(found.host, "rik");
assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![found.clone()]);
// Rik stays alive, then leaves; his claim frees at once.
let beat = rik.room_heartbeat(&room.room, &host_token, 30_000).expect("heartbeat");
assert!(beat.expires_ms >= room.expires_ms);
rik.retire_room(&room.room, &host_token).expect("retire");
assert!(sam.rooms(Some("arcade")).expect("list").is_empty());
// Leaving twice is not an error — a host that leaves and then exits
// runs both paths.
rik.retire_room(&room.room, &host_token).expect("retire again");
// A room whose host has vanished without retiring: Sam reads it, fails
// to dial it, and says which room he failed on. He becomes the host
// instead of hitting the same wall forever.
let stale = rik
.claim_room("arcade", "10.0.0.7:5000:5001#ab", "rik", 30_000, None)
.expect("claim");
let RoomClaimDto::Claimed { room: dead, token: dead_token } = stale else {
panic!("claim");
};
let taken = sam
.claim_room("arcade", "10.0.0.9:6000:6001#cd", "sam", 30_000, Some(&dead.room))
.expect("replacing claim");
let RoomClaimDto::Claimed { room: live, .. } = taken else {
panic!("an unreachable room must yield its claim");
};
assert_eq!(live.host, "sam");
assert_eq!(rik.rooms(Some("arcade")).expect("list"), vec![live]);
// The displaced host learns the claim moved the next time it says it is
// alive — a plain NotFound, which means "claim again", not "you broke".
match rik.room_heartbeat(&dead.room, &dead_token, 30_000) {
Err(ClientError::NotFound { .. }) => {}
other => panic!("a replaced room must heartbeat as gone, got {other:?}"),
}
// Local refusals happen before anything reaches the wire.
assert!(matches!(
rik.claim_room("", "i", "h", 30_000, None),
Err(ClientError::InvalidInput { .. })
));
assert!(matches!(
rik.claim_room("g", "i", "h", 1, None),
Err(ClientError::InvalidInput { .. })
));
assert!(matches!(
rik.rooms(Some(&"x".repeat(200))),
Err(ClientError::InvalidInput { .. })
));
}