Skip to content

Commit 514fa26

Browse files
committed
fix(design): auto-schedule agents, keep CSS, screenshots for site UI
Active design agents now schedule every round; sanitizer retains presentation CSS; same-hotkey peers are excluded from copy checks; capture full-page PNG previews (Chromium in design-challenge image) and expose screenshotUrl via site-api for the public submissions UI.
1 parent f1d9957 commit 514fa26

15 files changed

Lines changed: 661 additions & 51 deletions

File tree

Cargo.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/db/src/design_store.rs

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,47 @@ pub async fn list_recent_design_harnesses(
328328
.await?)
329329
}
330330

331+
/// Active harnesses eligible for automatic per-round scheduling.
332+
///
333+
/// One row per miner (newest active), excluding eliminated-until-future rows.
334+
///
335+
/// # Errors
336+
/// SQL error.
337+
pub async fn list_active_design_harnesses(
338+
pool: &PgPool,
339+
max_eliminated_until_round: i64,
340+
) -> Result<Vec<DesignHarnessRow>, DbError> {
341+
let q = format!(
342+
"SELECT DISTINCT ON (miner_hotkey) {HARNESS_COLS} FROM design_harness \
343+
WHERE active = TRUE AND eliminated_until_round <= $1 \
344+
ORDER BY miner_hotkey ASC, created_at DESC"
345+
);
346+
Ok(sqlx::query_as::<_, DesignHarnessRow>(&q)
347+
.bind(max_eliminated_until_round)
348+
.fetch_all(pool)
349+
.await?)
350+
}
351+
352+
/// Mark every other harness for this miner inactive (1 active agent per hotkey).
353+
///
354+
/// # Errors
355+
/// SQL error.
356+
pub async fn deactivate_other_design_harnesses(
357+
pool: &PgPool,
358+
miner_hotkey: &str,
359+
keep_id: &str,
360+
) -> Result<(), DbError> {
361+
sqlx::query(
362+
"UPDATE design_harness SET active = FALSE, updated_at = now() \
363+
WHERE miner_hotkey = $1 AND id <> $2 AND active = TRUE",
364+
)
365+
.bind(miner_hotkey)
366+
.bind(keep_id)
367+
.execute(pool)
368+
.await?;
369+
Ok(())
370+
}
371+
331372
/// Set elimination cooldown.
332373
///
333374
/// # Errors

crates/design-challenge/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ publish = false
1010

1111
[dependencies]
1212
async-trait = "0.1"
13+
base64 = "0.22"
1314
bundle = { path = "../bundle" }
1415
challenge-agentic = { path = "../challenge-agentic" }
1516
challenge-common = { path = "../challenge-common" }
@@ -24,6 +25,7 @@ design-store = { path = "../design-store" }
2425
hex = "0.4"
2526
serde = { version = "1", features = ["derive"] }
2627
serde_json = "1"
28+
sha2 = "0.10"
2729
submission-gating = { path = "../submission-gating" }
2830
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
2931
tracing = "0.1"

crates/design-challenge/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
pub mod host_sim;
2121
mod orchestrator;
2222
pub mod score;
23+
mod screenshot;
2324

2425
pub use challenge_common::{
2526
emit_signed_leaf_set, public_key_from_secret, submit_signed_leaf_set, verify_leaf_sig,

crates/design-challenge/src/orchestrator.rs

Lines changed: 101 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,19 +17,22 @@ use challenge_common::{
1717
};
1818
use crypto::KEY_LEN;
1919
use design_challenge_task::{round_id_at, round_secs};
20-
use design_http::{mark_awaiting_admin, AdminAwardHook};
20+
use base64::Engine;
21+
use design_http::{mark_awaiting_admin, schedule_harness_for_round, AdminAwardHook};
2122
use design_prompts::{prompt_set_digest, select_prompts_for_round};
2223
use design_sandbox::{SandboxBackend, SandboxError};
2324
use design_sanitize::sanitize_bundle;
2425
use design_store::{
2526
DesignStore, FinalScore, RatingRow, RoundRow, RunStage, StageEvent, StorePatch,
2627
};
2728
use serde_json::json;
29+
use sha2::{Digest, Sha256};
2830
use submission_gating::{GatingState, GatingStore};
2931
use tokio::time::sleep;
3032
use tracing::{info, warn};
3133

3234
use crate::score::{not_attempted, score_window, to_leaf, window_start, WindowScorePlan};
35+
use crate::screenshot::capture_full_page_png;
3336
use crate::CHALLENGE_ID;
3437

3538
/// Cap harness log payload stored in stage-event detail (JSON).
@@ -254,11 +257,62 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
254257
}
255258
}
256259
}
257-
// Ensure current round row exists.
260+
// Ensure current round row exists and every active agent is queued.
258261
let _ = self.ensure_round(rid).await;
262+
if let Err(e) = self.schedule_active_for_round(rid).await {
263+
warn!(error = %e, round = rid, "schedule_active_for_round failed");
264+
}
259265
}
260266
}
261267

268+
/// Queue runs for every active (non-eliminated) harness in `rid`.
269+
///
270+
/// Submit only schedules the *next* round once; this roll-forward keeps
271+
/// registered agents participating across the rolling 10-round window.
272+
async fn schedule_active_for_round(&self, rid: u64) -> Result<(), String> {
273+
let harnesses = self
274+
.store
275+
.list_active_harnesses(rid)
276+
.await
277+
.map_err(|e| e.to_string())?;
278+
let epoch = chain::gather_schedule_state(self.chain.as_ref(), self.cfg.netuid)
279+
.map(|s| chain::current_epoch_pre_run_coinbase(&s, s.current_block))
280+
.unwrap_or(0);
281+
for h in harnesses {
282+
match schedule_harness_for_round(
283+
self.store.as_ref(),
284+
&h,
285+
rid,
286+
self.cfg.netuid,
287+
epoch,
288+
)
289+
.await
290+
{
291+
Ok(ids) if !ids.is_empty() => {
292+
info!(
293+
harness_id = %h.id,
294+
miner = %h.miner_hotkey,
295+
round = rid,
296+
runs = ids.len(),
297+
"scheduled active harness for round"
298+
);
299+
}
300+
Ok(_) => {}
301+
Err(e) => {
302+
// Quota / elimination are expected; do not abort the loop.
303+
warn!(
304+
harness_id = %h.id,
305+
miner = %h.miner_hotkey,
306+
round = rid,
307+
error = %e,
308+
"skip scheduling active harness"
309+
);
310+
}
311+
}
312+
}
313+
Ok(())
314+
}
315+
262316
/// Stuck sweeper.
263317
pub async fn run_sweeper(self: Arc<Self>) {
264318
loop {
@@ -705,7 +759,7 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
705759
// Sanitize reject is the miner's fault: terminal, no auto-retry.
706760
let sanitized = sanitize_bundle(&out.pages)
707761
.map_err(|e| RunFailure::new(ErrorClass::Miner, e.to_string()))?;
708-
let pages: Vec<_> = sanitized
762+
let mut pages: Vec<_> = sanitized
709763
.pages
710764
.iter()
711765
.map(|p| {
@@ -718,6 +772,29 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
718772
)
719773
})
720774
.collect();
775+
// Best-effort full-page screenshot of the styled index for the site UI
776+
// (replaces iframe previews). Failure never fails the run.
777+
if let Some(index) = sanitized.pages.iter().find(|p| p.path == "index.html") {
778+
let shot_dir = self.cfg.staging_root.join("screenshots").join(&run.id);
779+
if let Some(png) = capture_full_page_png(&index.sanitized_html, &shot_dir) {
780+
let mut h = Sha256::new();
781+
h.update(&png);
782+
let sha = hex::encode(h.finalize());
783+
let b64 = base64::engine::general_purpose::STANDARD.encode(&png);
784+
let bytes = u32::try_from(png.len()).unwrap_or(u32::MAX);
785+
pages.push((
786+
"index.png".into(),
787+
b64,
788+
String::new(),
789+
sha,
790+
bytes,
791+
));
792+
info!(run_id = %run.id, bytes, "captured design page screenshot");
793+
} else {
794+
warn!(run_id = %run.id, "design page screenshot unavailable");
795+
}
796+
let _ = std::fs::remove_dir_all(&shot_dir);
797+
}
721798
self.store
722799
.put_artifacts(&run.id, &pages)
723800
.await
@@ -726,7 +803,9 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
726803

727804
// Pre-LLM copy gate: byte/AST copy of an *earlier* harness → terminal
728805
// `rejected` without spending the LLM review.
729-
let gate_corpus = self.gate_corpus(&run.harness_id).await;
806+
let gate_corpus = self
807+
.gate_corpus(&run.harness_id, &harness.miner_hotkey)
808+
.await;
730809
if let Some(hit) = copy_gate(&harness.agent_py, harness.created_at_ms, &gate_corpus) {
731810
warn!(
732811
run_id = %run.id,
@@ -857,14 +936,21 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
857936
Ok(())
858937
}
859938

860-
/// Corpus for the pre-LLM copy gate (recent harnesses minus the candidate).
861-
async fn gate_corpus(&self, exclude_harness_id: &str) -> Vec<GateCorpusEntry> {
939+
/// Corpus for the pre-LLM copy gate (recent harnesses minus the candidate
940+
/// and any prior revisions from the same miner hotkey).
941+
async fn gate_corpus(
942+
&self,
943+
exclude_harness_id: &str,
944+
exclude_miner_hotkey: &str,
945+
) -> Vec<GateCorpusEntry> {
946+
let miner = exclude_miner_hotkey.to_ascii_lowercase();
862947
self.store
863948
.list_recent_harnesses(64)
864949
.await
865950
.unwrap_or_default()
866951
.into_iter()
867952
.filter(|h| h.id != exclude_harness_id)
953+
.filter(|h| h.miner_hotkey.to_ascii_lowercase() != miner)
868954
.map(|h| GateCorpusEntry {
869955
id: format!("harness:{}", h.id),
870956
source: h.agent_py,
@@ -902,11 +988,11 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
902988
.list_recent_harnesses(64)
903989
.await
904990
.map_err(|e| RunFailure::new(ErrorClass::AstInfra, e.to_string()))?;
905-
let cand_created = recent
906-
.iter()
907-
.find(|h| h.id == run.harness_id)
908-
.map(|h| h.created_at_ms)
909-
.unwrap_or(0);
991+
let cand = recent.iter().find(|h| h.id == run.harness_id);
992+
let cand_created = cand.map(|h| h.created_at_ms).unwrap_or(0);
993+
let cand_miner = cand
994+
.map(|h| h.miner_hotkey.to_ascii_lowercase())
995+
.unwrap_or_default();
910996
let mut corpus: Vec<CorpusEntry> = vec![CorpusEntry {
911997
// The published baseline is always in the corpus (same as prism):
912998
// it anchors originality judgments and keeps an empty recent-set
@@ -919,6 +1005,10 @@ impl<C: ChainClient + Send + Sync + 'static> Orchestrator<C> {
9191005
recent
9201006
.into_iter()
9211007
.filter(|h| h.id != run.harness_id)
1008+
// Same-hotkey revisions are self-improvement, not copying.
1009+
.filter(|h| {
1010+
cand_miner.is_empty() || h.miner_hotkey.to_ascii_lowercase() != cand_miner
1011+
})
9221012
// Prior art only (created_at ordered like the pre-LLM gate): a
9231013
// later byte-copy must never poison the original's review.
9241014
.filter(|h| {
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
//! Full-page screenshots of sanitized design pages for the public site.
2+
3+
use std::path::Path;
4+
use std::process::Command;
5+
use std::time::{SystemTime, UNIX_EPOCH};
6+
7+
use tracing::warn;
8+
9+
/// Capture a full-page PNG of `html` (best-effort).
10+
///
11+
/// Prefers the Playwright CLI (`playwright screenshot --full-page`), then
12+
/// falls back to headless Chrome viewport capture. Returns `None` when no
13+
/// browser tool is available or capture fails — runs must not fail for this.
14+
pub fn capture_full_page_png(html: &str, work_dir: &Path) -> Option<Vec<u8>> {
15+
let _ = std::fs::create_dir_all(work_dir);
16+
let stamp = SystemTime::now()
17+
.duration_since(UNIX_EPOCH)
18+
.map(|d| d.as_millis())
19+
.unwrap_or(0);
20+
let html_path = work_dir.join(format!("shot-{stamp}.html"));
21+
let png_path = work_dir.join(format!("shot-{stamp}.png"));
22+
if std::fs::write(&html_path, html).is_err() {
23+
return None;
24+
}
25+
let file_url = path_to_file_url(&html_path);
26+
let ok = try_playwright(&file_url, &png_path) || try_chrome(&file_url, &png_path);
27+
let _ = std::fs::remove_file(&html_path);
28+
if !ok {
29+
let _ = std::fs::remove_file(&png_path);
30+
return None;
31+
}
32+
let bytes = std::fs::read(&png_path).ok();
33+
let _ = std::fs::remove_file(&png_path);
34+
match bytes {
35+
Some(b) if !b.is_empty() && b.starts_with(b"\x89PNG") => Some(b),
36+
Some(_) => {
37+
warn!("screenshot tool wrote non-PNG output");
38+
None
39+
}
40+
None => None,
41+
}
42+
}
43+
44+
fn path_to_file_url(path: &Path) -> String {
45+
let abs = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
46+
format!("file://{}", abs.display())
47+
}
48+
49+
fn try_playwright(url: &str, out: &Path) -> bool {
50+
let bin = std::env::var("DESIGN_PLAYWRIGHT_BIN").unwrap_or_else(|_| "playwright".into());
51+
let status = Command::new(&bin)
52+
.args(["screenshot", "--full-page", url])
53+
.arg(out)
54+
.status();
55+
matches!(status, Ok(s) if s.success() && out.is_file())
56+
}
57+
58+
fn try_chrome(url: &str, out: &Path) -> bool {
59+
let candidates = [
60+
std::env::var("DESIGN_CHROME_BIN").unwrap_or_default(),
61+
"google-chrome".into(),
62+
"google-chrome-stable".into(),
63+
"chromium".into(),
64+
"chromium-browser".into(),
65+
];
66+
for bin in candidates.into_iter().filter(|b| !b.is_empty()) {
67+
// Chrome `--screenshot` is viewport-sized; still better than no preview.
68+
let status = Command::new(&bin)
69+
.args([
70+
"--headless=new",
71+
"--disable-gpu",
72+
"--hide-scrollbars",
73+
"--window-size=1280,4000",
74+
&format!("--screenshot={}", out.display()),
75+
url,
76+
])
77+
.status();
78+
if matches!(status, Ok(s) if s.success() && out.is_file()) {
79+
return true;
80+
}
81+
}
82+
false
83+
}

0 commit comments

Comments
 (0)