feat: bake in the by-hand reliability ritual — agent-ready wait, stop-square verify, hard-refresh requeue

- waitForAgentReady: after (re)load, wait for the sidebar to hydrate before typing
- post-submit success check = square stop button (or emptied box); no square after
  Enter + submit fallback -> hard refresh (bypassCache via background) and requeue
- 'Something went wrong' surviving Try-again -> hard refresh + requeue (was: fail)
- requeue attempts capped at 3 per task (chrome.storage, survives reloads)
- server: 'requeue' status keeps task pending; INFLIGHT entries expire after 15 min
  so a reloaded tab gets its task re-served
- banks/djsim_batch3.jsonl: 18-task 90sDJsim asset bank (queued live)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-07-07 17:42:15 +10:00
parent 72a793932f
commit c21ea2b22d
4 changed files with 166 additions and 45 deletions

View File

@ -48,6 +48,11 @@ chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
{ url: msg.url, filename: msg.filename, conflictAction: 'uniquify', saveAs: false },
(id) => chrome.runtime.lastError ? rej(chrome.runtime.lastError) : res(id)));
sendResponse({ ok: dlId != null, id: dlId });
} else if (msg.type === 'hardReload') {
// Cache-bypass reload — the by-hand fix for a wedged Flow session.
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
chrome.tabs.reload(tabId, { bypassCache: true });
sendResponse({ ok: true });
} else if (msg.type === 'detach') {
if (attached.has(tabId)) { chrome.debugger.detach({ tabId }, () => {}); attached.delete(tabId); }
sendResponse({ ok: true });

18
banks/djsim_batch3.jsonl Normal file
View File

@ -0,0 +1,18 @@
{"id": "djsim_b3_001", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — op shop", "prompt": "Interior background art for a cluttered 1990s Australian op shop: racks of secondhand clothes, shelves of bric-a-brac, board games and mismatched crockery, a cardboard box of vinyl records on the worn carpet, hand-written price signs, buzzing fluorescent tubes, donation bags piled by the counter. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, warm dingy interior light, single scene, no characters, 16:9."}
{"id": "djsim_b3_002", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — pawn shop", "prompt": "Interior background art for a 1990s Australian pawn shop: glass display cabinets with watches and gold chains, electric guitars hanging on the wall, stacked VCRs, amplifiers and car stereos, a barred front window, hand-written lay-by signs, harsh fluorescent light. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
{"id": "djsim_b3_003", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — flea market", "prompt": "Background art for an outdoor Australian weekend flea market at dawn: trestle tables with milk crates of vinyl records, tarpaulins strung overhead, cardboard boxes of paperbacks and tools, pale early golden light, sausage sizzle smoke drifting in the background. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
{"id": "djsim_b3_004", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — video shop", "prompt": "Interior background art for a 1990s suburban Australian video rental shop: shelves of VHS tapes with faded covers, a wire ex-rental bargain bin, a CRT television playing static on the counter, movie posters on the walls, weekly specials whiteboard. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
{"id": "djsim_b3_005", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — department store", "prompt": "Interior background art for an old Australian department store record bar in 1990: carpeted floor, a listening booth with headphones on hooks, formica counters with racks of LPs and cassettes, slightly dated grandeur, warm tungsten downlights. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
{"id": "djsim_b3_006", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "dig backdrop — import record shop", "prompt": "Interior background art for an underground import record shop, 1990s: milk crates of 12-inch singles on trestle tables, road flight cases, one wall completely covered in rave flyers, a single neon strip light, dim basement atmosphere. 1990s point-and-click adventure game background art, VGA 256-color pixel art with visible dithering, single scene, no characters, 16:9."}
{"id": "djsim_b3_007", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — dive bar", "prompt": "Interior of a grimy Australian dive bar with a tiny corner DJ booth: beer-stained patterned carpet, one pool table under a low hanging light, three regulars hunched at the bar, faded band posters, moody tungsten lighting. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm cinematic lighting, 16:9."}
{"id": "djsim_b3_008", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — sports bar", "prompt": "Interior of a 1990s suburban Australian sports bar: TAB screens on the walls, a small DJ setup squeezed next to a trophy cabinet, patrons in fluoro polo shirts, keno slips on the tables, harsh fluorescent light over the bar. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, 16:9."}
{"id": "djsim_b3_009", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — cocktail lounge", "prompt": "Interior of a velvet-and-chrome 1990s cocktail lounge: small parquet dancefloor, mirror ball throwing spots of light, cigarette haze drifting through coloured uplights, curved booth seating, a long low bar. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm cinematic lighting, 16:9."}
{"id": "djsim_b3_010", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — warehouse rave", "prompt": "Interior of an illegal warehouse rave in 1994: corrugated iron walls, huge stacked speaker systems, green lasers cutting through fog, a packed crowd with hands up, strobe flashes, DJ silhouetted behind decks on road cases. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, neon green #3dff8b and hot pink #ff5cad accents in darkness, 16:9."}
{"id": "djsim_b3_011", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "venue — house party", "prompt": "Australian backyard house party at night: a Hills Hoist clothesline strung with fairy lights, DJ decks set up on a ping-pong table, kids dancing on the lawn, an esky full of ice, weatherboard house with the kitchen light on. Low-poly flat-shaded 3D diorama, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, warm string-light glow against deep blue night, 16:9."}
{"id": "djsim_b3_012", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — pub booker", "prompt": "Character bust portrait of a gruff Australian pub booker in his 50s: flannel shirt, cigarette tucked behind one ear, skeptical squint, beer gut. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
{"id": "djsim_b3_013", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — club promoter", "prompt": "Character bust portrait of a sharp-dressed 1990s nightclub promoter: slicked-back hair, open collar, gold chain, brick mobile phone in hand, confident smirk. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
{"id": "djsim_b3_014", "kind": "image", "model": "Nano Banana 2", "category": "djsim_batch3", "title": "NPC — milk bar owner", "prompt": "Character bust portrait of an elderly Australian milk bar owner in an apron: kind but unimpressed expression, reading glasses on a chain around the neck, arms crossed. Low-poly flat-shaded 3D character, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, isolated on a plain solid mid-gray #808080 background, centered, 1:1 square."}
{"id": "djsim_b3_015", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — bus ride (bus.mp4)", "prompt": "Interior of a 1990s Australian bus at golden hour, camera at seat level looking out the window as suburban shopfronts and telegraph poles slide past, a milk crate of vinyl records on the seat beside the window, gentle engine sway, warm afternoon light flickering through trees. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, muted 90s palette, 8 seconds, 16:9."}
{"id": "djsim_b3_016", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — warehouse walk-up (warehouse.mp4)", "prompt": "POV walking along train tracks at night toward an illegal warehouse party, muffled bass growing louder with each step, a corrugated iron door cracks open and green laser light spills out with fog. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, neon green #3dff8b glow against deep blue-black night, 8 seconds, 16:9."}
{"id": "djsim_b3_017", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — NYE 1999 finale (nye99.mp4)", "prompt": "New Year's Eve 1999: fireworks bursting over an Australian beachside town, silhouetted crowd cheering on the foreshore, DJ booth in the foreground with hands in the air, euphoric gold and pink bursts reflecting on the water. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, 8 seconds, 16:9."}
{"id": "djsim_b3_018", "kind": "video", "model": "Veo 3.1 - Fast", "category": "djsim_batch3", "title": "vignette — rain on the strip (rain.mp4)", "prompt": "Rain falling on a neon-lit 1990s Australian shopping strip at night, empty wet street reflecting pink #ff5cad and green #3dff8b shop neon, one figure hurrying past with a record bag held under their jacket, puddle ripples. Low-poly flat-shaded 3D animation, blocky matte geometry, un-photoreal early-3D look, 8 seconds, 16:9."}

View File

@ -21,11 +21,19 @@ let settings = {
submitSel: '',
resultSel: '', // scope for harvesting (e.g. the media library container)
pollInterval: 4000,
genTimeoutMs: 8 * 60 * 1000 // Veo clips can take minutes
genTimeoutMs: 8 * 60 * 1000, // Veo clips can take minutes
// Pacing — stay under Google's per-account rate/abuse flag ("unusual activity").
taskDelayMs: 20000, // base pause between tasks (jittered up to 2x)
restEvery: 15, // after this many gens, take a longer rest
restMs: 15 * 60 * 1000, // length of that scheduled rest
autoRefreshOnJam: true, // reload the tab on the abuse flag to clear a stuck session
cooldownUntil: 0 // epoch ms; set on rate-limit/rest, persists across reload
};
let pollTimeout = null;
let generationInProgress = false;
let successCount = 0;
let consecutiveRateLimits = 0;
function init() {
chrome.storage.local.get(settings, (data) => {
@ -145,25 +153,32 @@ function findAgentInput() {
return boxes.find(visible) || null;
}
// John's by-hand ritual, step 1: after a (re)load, don't type the instant the box
// exists — wait for the agent sidebar to finish hydrating, then re-find it.
async function waitForAgentReady(maxMs = 30000) {
const el = await waitFor(findAgentInput, maxMs);
if (!el) return null;
await sleep(1500);
const el2 = findAgentInput();
return visible(el2) ? el2 : null;
}
// The square STOP control that replaces send while a generation is queued —
// the "it stuck" signal you watch for by hand.
function findStopSquare() {
return [...document.querySelectorAll('button, [role="button"]')].find(b => visible(b) && !b.disabled &&
/\b(stop|cancel)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || '')));
}
// The submit control is the unlabeled arrow near the input. Find the nearest
// enabled button after the input, preferring aria-labels that read like "send".
function findSubmit(inputEl) {
if (settings.submitSel) { const el = document.querySelector(settings.submitSel); if (visible(el) && !el.disabled) return el; }
const scope = inputEl?.closest('form, [class*="composer"], [class*="session"], [class*="input"]') || document;
const btns = [...scope.querySelectorAll('button, [role="button"]')].filter(b => visible(b) && !b.disabled);
const byLabel = btns.find(b => /send|submit|create|generate|run/i.test((b.getAttribute('aria-label') || '') + b.innerText));
if (byLabel) return byLabel;
// else: the last enabled icon-only button in the composer (the → arrow)
const iconOnly = btns.filter(b => !b.innerText.trim() && b.querySelector('svg'));
return iconOnly[iconOnly.length - 1] || btns[btns.length - 1] || null;
}
// If a confirm/generate dialog appears (Always mode), click it. No-op under Never.
function clickConfirmIfPresent() {
const b = [...document.querySelectorAll('button, [role="button"]')]
.find(x => visible(x) && !x.disabled && /^(generate|create|confirm|continue|yes)\b/i.test(x.innerText.trim()));
if (b) { b.click(); return true; }
return false;
// ONLY a control explicitly labelled send/submit. No "last icon button" guess — that's what
// was clicking the + / menu and opening popups. If unsure, return null (trusted Enter handles it).
return btns.find(b => /\b(send|submit)\b/i.test((b.getAttribute('aria-label') || '') + ' ' + (b.getAttribute('title') || ''))) || null;
}
// Snapshot real media URLs currently on the page (the harvest surface).
@ -192,6 +207,14 @@ function flowError() {
(e.textContent || '').length < 90);
}
// The account-level abuse/rate flag ("unusual activity" / "Help Center"). Persistent —
// retrying makes it worse — so this triggers a long back-off, not a "Try again".
function persistentError() {
return [...document.querySelectorAll('*')].some(e => visible(e) &&
/unusual activity|help center|temporarily (blocked|unavailable)|too many requests|try again later/i.test(e.textContent || '') &&
(e.textContent || '').length < 180);
}
// Flow's "Try again" button, shown beside the "Something went wrong" banner.
// Clicking it re-runs the SAME prompt (no re-injection needed) — the manual fix.
function findTryAgain() {
@ -208,6 +231,7 @@ async function waitForResultOrError(baseCount, maxMs, maxRetries = 3) {
let retries = 0, cooldownUntil = 0;
while (Date.now() - t0 < maxMs) {
if (snapshotAssets().size > baseCount) return 'asset';
if (Date.now() - t0 > 6000 && persistentError()) return 'ratelimit'; // abuse flag: back off, don't retry
const now = Date.now();
if (now > cooldownUntil) {
const btn = findTryAgain();
@ -229,52 +253,101 @@ async function waitForResultOrError(baseCount, maxMs, maxRetries = 3) {
// ---- queue loop ----------------------------------------------------------
// Entry point (Start / reload). Kicks the first poll soon; the rest is paced.
function startPolling() {
if (pollTimeout) clearTimeout(pollTimeout);
if (settings.isRunning) scheduleNext(true);
}
// Schedule the next poll. Honours a persisted cooldown (rate-limit back-off or scheduled
// rest); otherwise waits a jittered human-ish gap so we don't machine-gun the account.
function scheduleNext(immediate = false) {
if (pollTimeout) clearTimeout(pollTimeout);
if (!settings.isRunning) return;
pollTimeout = setTimeout(async () => {
if (generationInProgress) return startPolling();
try {
const res = await fetch(`${settings.serverUrl}/next-task`);
if (res.status === 204) return startPolling();
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const task = await res.json();
if (task && task.id) { log('info', `Task ${task.id}: ${String(task.prompt).slice(0, 60)}`); await executeTask(task); }
else startPolling();
} catch (err) {
log('error', `Poll failed: ${err.message}`);
startPolling();
}
}, settings.pollInterval);
const now = Date.now();
let delay;
if (settings.cooldownUntil && now < settings.cooldownUntil) {
delay = settings.cooldownUntil - now;
log('system', `Cooling down ~${Math.max(1, Math.round(delay / 60000))} min before next…`);
} else if (immediate) {
delay = 2000;
} else {
const base = settings.taskDelayMs || 15000;
delay = base + Math.floor(Math.random() * base); // base .. 2*base, jittered
}
pollTimeout = setTimeout(pollOnce, delay);
}
async function pollOnce() {
if (!settings.isRunning) return;
if (generationInProgress) return scheduleNext();
try {
const res = await fetch(`${settings.serverUrl}/next-task`);
if (res.status === 204) return scheduleNext(); // queue drained
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const task = await res.json();
if (task && task.id) { log('info', `Task ${task.id}: ${String(task.prompt).slice(0, 60)}`); await executeTask(task); }
else scheduleNext();
} catch (err) {
log('error', `Poll failed: ${err.message}`);
scheduleNext();
}
}
// John's by-hand recovery: the thing that actually clears a wedged Flow session is a
// HARD refresh (cache bypass), not "Try again". Keep the task PENDING server-side,
// count attempts in storage (survives the reload), and reload the tab.
async function requeueWithReload(task, why) {
const key = 'requeue_' + task.id;
const n = ((await new Promise(r => chrome.storage.local.get({ [key]: 0 }, r)))[key] || 0) + 1;
if (n > 3) {
chrome.storage.local.remove(key);
log('error', `Task ${task.id}: ${why} — 3 hard-refresh retries burned, marking failed.`);
await reportCompletion(task.id, 'failed', `${why} (after 3 hard refreshes)`, []);
return;
}
chrome.storage.local.set({ [key]: n });
await reportCompletion(task.id, 'requeue', why, []); // server keeps it PENDING
log('system', `Task ${task.id}: ${why} — hard refresh ${n}/3, retrying after reload.`);
setCooldown(20000, 'Hard-refresh retry');
await sleep(800);
const r = await bg({ type: 'hardReload' });
if (!r || !r.ok) location.reload();
}
async function executeTask(task) {
generationInProgress = true;
chrome.storage.local.set({ status: 'working' });
try {
const input = await waitFor(findAgentInput, 20000);
if (!input) throw new Error('Agent input box not found — are you on the Flow project page?');
const input = await waitForAgentReady(30000);
if (!input) return await requeueWithReload(task, 'agent input never became ready');
const before = snapshotAssets();
const landed = await typeIntoEditor(input, task.prompt);
if (!landed) throw new Error('Prompt did not register in the editor (injection failed) — nothing submitted.');
if (!landed) return await requeueWithReload(task, 'prompt did not register in the editor');
log('info', `Prompt set (${landed} chars). Submitting…`);
await sleep(300);
await sleep(600); // John's ritual: paste, wait a sec, THEN enter
await submitEditor(input); // trusted Enter via the DevTools Protocol
await sleep(700);
const submit = findSubmit(input); // arrow button, as a fallback
if (submit) submit.click();
clickConfirmIfPresent(); // harmless under "Never"
await submitEditor(input); // trusted Enter via the DevTools Protocol — this submits
// Success signal = the square stop button appears (or the box empties). If neither,
// try the labelled submit button once, then hard-refresh — Enter into a wedged
// session never recovers on its own.
let took = await waitFor(() => findStopSquare() || editorText(input).length === 0, 6000, 300);
if (!took) {
const submit = findSubmit(input);
if (submit) { submit.click(); log('info', 'Enter fallback → clicked submit.'); }
took = await waitFor(() => findStopSquare() || editorText(input).length === 0, 6000, 300);
}
if (!took) return await requeueWithReload(task, 'submission never stuck (no stop button, prompt still in box)');
await sleep(1500);
log('info', 'Submitted. Waiting for generation…');
log('info', 'Submitted (stop square seen). Waiting for generation…');
// Wait for a new asset, a Flow error (fast-fail), or timeout. Images should be quick.
const timeoutMs = task.kind === 'image' ? 3 * 60 * 1000 : settings.genTimeoutMs;
const outcome = await waitForResultOrError(before.size, timeoutMs);
if (outcome === 'error') throw new Error('Flow errored ("Something went wrong") after retries — skipping.');
if (outcome === 'ratelimit') { await handleRateLimit(task); return; } // long back-off, don't hammer
if (outcome === 'error') return await requeueWithReload(task, '"Something went wrong" persisted through Try-again');
if (outcome !== 'asset') {
const wall = [...document.querySelectorAll('*')].some(e => visible(e) &&
/out of credits|no credits|insufficient|quota exceeded|run out of|daily limit|reached your limit/i.test(e.textContent || '') &&
@ -297,13 +370,18 @@ async function executeTask(task) {
}
if (fresh.length) log(saved === fresh.length ? 'success' : 'info', `Downloaded ${saved}/${fresh.length} → Downloads/flowrinse/${cat}/`);
await reportCompletion(task.id, 'success', null, fresh);
chrome.storage.local.remove('requeue_' + task.id);
successCount++; consecutiveRateLimits = 0;
if (settings.restEvery && successCount % settings.restEvery === 0) {
setCooldown(settings.restMs || 15 * 60 * 1000, `Scheduled rest after ${successCount} gens`);
}
} catch (err) {
log('error', `Task ${task.id} failed: ${err.message}`);
await reportCompletion(task.id, 'failed', err.message, []);
} finally {
generationInProgress = false;
chrome.storage.local.set({ status: settings.isRunning ? 'running' : 'idle' });
startPolling();
scheduleNext();
}
}
@ -319,6 +397,23 @@ async function reportCompletion(id, status, error, assets) {
}
}
// Persisted cooldown (survives a tab reload) — used for rate-limit back-off and rests.
function setCooldown(ms, why) {
settings.cooldownUntil = Date.now() + ms;
chrome.storage.local.set({ cooldownUntil: settings.cooldownUntil });
log('system', `${why} — pausing ${Math.round(ms / 60000)} min.`);
}
// Hit the account abuse flag: keep the task PENDING (retry later), back off (growing with
// consecutive hits), and refresh the tab to clear the stuck session.
async function handleRateLimit(task) {
consecutiveRateLimits++;
const mins = Math.min(60, 15 * consecutiveRateLimits); // 15 → 30 → 45 → 60
await reportCompletion(task.id, 'ratelimited', 'unusual activity — backing off', []);
setCooldown(mins * 60 * 1000, `⚠ Abuse flag ("unusual activity") ×${consecutiveRateLimits}`);
if (settings.autoRefreshOnJam) { await sleep(2500); location.reload(); }
}
function log(level, text) {
console.log(`[Flow][${level}] ${text}`);
chrome.storage.local.get({ logs: [] }, (r) => {

View File

@ -110,7 +110,9 @@ class Handler(BaseHTTPRequestHandler):
return self._send(404, {"error": "not found"})
done = done_ids()
for t in load_queue():
if t["id"] not in done and t["id"] not in INFLIGHT:
# in-flight entries expire after 15 min: a hard-refreshed tab that never
# reported back must get its task re-served, not stranded
if t["id"] not in done and (t["id"] not in INFLIGHT or time.time() - INFLIGHT[t["id"]] > 900):
INFLIGHT[t["id"]] = time.time()
remaining = sum(1 for x in load_queue() if x["id"] not in done)
print(f"[SERVER] -> {t['id']} ({t.get('kind')}) | {remaining} left")
@ -122,10 +124,11 @@ class Handler(BaseHTTPRequestHandler):
rec = json.loads(self.rfile.read(n).decode() or "{}")
if self.path == "/task-completed":
rec["ts"] = time.time()
INFLIGHT.pop(rec.get("id"), None)
with open(RESULTS_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
if rec.get("status") not in ("ratelimited", "requeue"): # both stay PENDING, retried after cooldown/reload
rec["ts"] = time.time()
with open(RESULTS_FILE, "a") as f:
f.write(json.dumps(rec) + "\n")
print(f"[SERVER] <- {rec.get('id')} {str(rec.get('status')).upper()} | {len(rec.get('assets') or [])} asset(s)")
return self._send(200, {"ok": True})