[object Object]

← back to Whatsmystyle

yolo tick 20: Mac1 health watcher (skip drain when >9GB VRAM busy) + floating embed-chip admin-only lower-right + WooCommerce adapter scaffold (empty seed; no known live DTC targets)

59b9a4ed8312ce2bb89d5b60678c4faccd433665 · 2026-05-12 10:28:49 -0700 · SteveStudio2

Files touched

Diff

commit 59b9a4ed8312ce2bb89d5b60678c4faccd433665
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Tue May 12 10:28:49 2026 -0700

    yolo tick 20: Mac1 health watcher (skip drain when >9GB VRAM busy) + floating embed-chip admin-only lower-right + WooCommerce adapter scaffold (empty seed; no known live DTC targets)
---
 public/css/app.css                   |  46 +++++++++++++
 public/index.html                    |  11 ++++
 public/js/app.js                     |  45 +++++++++++++
 scripts/clothing-apis/brands.json    |   4 ++
 scripts/clothing-apis/index.js       |  20 ++++++
 scripts/clothing-apis/woocommerce.js | 122 +++++++++++++++++++++++++++++++++++
 server.js                            |  54 +++++++++++++++-
 7 files changed, 300 insertions(+), 2 deletions(-)

diff --git a/public/css/app.css b/public/css/app.css
index 7e6b277..6f66216 100644
--- a/public/css/app.css
+++ b/public/css/app.css
@@ -733,6 +733,52 @@ h2 { font-family: var(--font-display); font-weight: 600; font-size: 36px; margin
 }
 .tailor-reset:hover { background: #fff; color: #1d1d1f; }
 
+/* ---- Floating embed-progress chip (tick 20, admin only) ---- */
+.embed-chip {
+  position: fixed;
+  bottom: 16px; right: 16px;
+  z-index: 60;
+  background: #1d1d1f;
+  color: #faf7f2;
+  border-radius: 16px;
+  padding: 10px 14px;
+  font-size: 12px;
+  min-width: 220px;
+  max-width: 320px;
+  box-shadow: 0 8px 24px rgba(0,0,0,0.3);
+  font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;
+}
+.embed-chip-head {
+  display: flex; align-items: center; gap: 8px;
+  margin-bottom: 6px;
+}
+.embed-chip-dot {
+  width: 8px; height: 8px; border-radius: 50%;
+  background: #707070;
+  flex: 0 0 8px;
+}
+.embed-chip-dot-idle  { background: #4ade80; box-shadow: 0 0 6px rgba(74, 222, 128, 0.6); }
+.embed-chip-dot-busy  { background: #f59e0b; box-shadow: 0 0 6px rgba(245, 158, 11, 0.6); }
+.embed-chip-dot-unreachable { background: #ef4444; }
+.embed-chip-mac1 {
+  margin-left: auto;
+  font-size: 11px;
+  color: #c7c2b8;
+  letter-spacing: 0.02em;
+}
+.embed-chip-track {
+  height: 5px;
+  background: rgba(255,255,255,0.08);
+  border-radius: 999px;
+  overflow: hidden;
+}
+.embed-chip-fill {
+  height: 100%;
+  background: linear-gradient(90deg, #c9a96e 0%, #f3d76f 100%);
+  width: 0%;
+  transition: width 0.6s ease-out;
+}
+
 /* ---- Production credit badge (tick 19) ---- */
 .card { position: relative; }   /* anchor for the absolute-positioned badge */
 .prod-credit-badge {
diff --git a/public/index.html b/public/index.html
index 38bc162..dbcd20f 100644
--- a/public/index.html
+++ b/public/index.html
@@ -575,6 +575,17 @@
   <button class="link-btn" onclick="forgetMe()" aria-label="Delete all my data">Delete my account + everything</button>
 </footer>
 
+<!-- Tick 20: floating embed-progress chip — admin only, lower-right.
+     Visible across every screen so Steve sees draining state while clicking. -->
+<div id="embed-chip" class="embed-chip" data-role-only="admin" hidden>
+  <div class="embed-chip-head">
+    <span class="embed-chip-dot" id="embed-chip-dot"></span>
+    <span><strong id="embed-chip-pct">0</strong>% · <span id="embed-chip-left">0</span> left</span>
+    <span class="embed-chip-mac1" id="embed-chip-mac1">Mac1: …</span>
+  </div>
+  <div class="embed-chip-track"><div class="embed-chip-fill" id="embed-chip-fill"></div></div>
+</div>
+
 <script src="/js/app.js"></script>
 </body>
 </html>
diff --git a/public/js/app.js b/public/js/app.js
index 0a8ea8e..e6d687e 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -127,6 +127,51 @@ const Role = {
 };
 window.Role = Role;
 
+// ---- Floating embed-progress chip (tick 20, admin only) ------------------
+// Lives in the lower-right corner across every screen. Polls catalog status
+// every 30s. Hides when admin is previewing-as a non-admin role OR when the
+// catalog is fully embedded. Surfaces Mac1 state so Steve sees why the
+// drainer might be paused.
+async function refreshEmbedChip() {
+  const chip = $('#embed-chip');
+  if (!chip) return;
+  // Visibility — admin role only AND there's work to show.
+  if (!Role.is_admin || Role.view_as) { chip.hidden = true; return; }
+  try {
+    const r = await fetch('/api/admin/catalog-status');
+    if (!r.ok) { chip.hidden = true; return; }
+    const j = await r.json();
+    if (!j.total || j.unembedded === 0) { chip.hidden = true; return; }
+    chip.hidden = false;
+    const pct = Math.round((j.embedded / j.total) * 100);
+    $('#embed-chip-pct').textContent = pct;
+    $('#embed-chip-left').textContent = j.unembedded;
+    $('#embed-chip-fill').style.width = pct + '%';
+    // Mac1 status pill
+    const state = j.mac1?.state || 'unknown';
+    const label = ({
+      idle: 'Mac1 idle',
+      busy: 'Mac1 busy',
+      unreachable: 'Mac1 down',
+      unknown: 'Mac1 ?',
+    })[state];
+    const dot = $('#embed-chip-dot');
+    const mac1 = $('#embed-chip-mac1');
+    mac1.textContent = label;
+    mac1.title = j.mac1?.detail || '';
+    dot.className = 'embed-chip-dot embed-chip-dot-' + state;
+    // If drainer is OFF, show a small "paused" hint.
+    if (!j.drainer_enabled) {
+      mac1.textContent = label + ' · paused';
+    }
+  } catch {
+    chip.hidden = true;
+  }
+}
+setInterval(refreshEmbedChip, 30 * 1000);
+// First refresh after Role boots — small delay so role data is loaded.
+setTimeout(refreshEmbedChip, 1500);
+
 // Populate every <select> that's a production picker (closet upload, try-on
 // submit, header active-production bar). Idempotent — wipes & re-fills.
 // Reads / writes localStorage 'wms.activeProduction' so the choice persists
diff --git a/scripts/clothing-apis/brands.json b/scripts/clothing-apis/brands.json
index f112385..36b1bd2 100644
--- a/scripts/clothing-apis/brands.json
+++ b/scripts/clothing-apis/brands.json
@@ -25,6 +25,10 @@
     { "id": "everybody-world","name": "Everybody.World", "domain": "everybody.world",       "sustain_tier": 5, "pro_grade": 1 }
   ],
 
+  "woocommerce_dtc": [
+    "_note: empty seed. Tick 20 probing showed major DTC brands (Pact/Reformation/Kotn/Frank+Oak/Burton) don't expose /wp-json/wc/store/v1/products. Add small/indie WooCommerce shops here as they're found. Same shape as shopify_dtc entries."
+  ],
+
   "etsy_categories": [
     { "id": "etsy-vintage-clothing",     "name": "Etsy — Vintage Clothing",      "category_id": "11050", "query": "vintage clothing", "needs_key": true },
     { "id": "etsy-handmade-fashion",     "name": "Etsy — Handmade Fashion",      "category_id": "11000", "query": "handmade clothing", "needs_key": true }
diff --git a/scripts/clothing-apis/index.js b/scripts/clothing-apis/index.js
index 2bb284e..8ffd24b 100644
--- a/scripts/clothing-apis/index.js
+++ b/scripts/clothing-apis/index.js
@@ -20,6 +20,7 @@ const fs = require('fs');
 
 const BRANDS = JSON.parse(fs.readFileSync(path.join(__dirname, 'brands.json'), 'utf8'));
 const shopify = require('./shopify');
+const woocommerce = require('./woocommerce');
 const etsy    = require('./etsy');
 const ebay    = require('./ebay');
 
@@ -29,6 +30,11 @@ async function importOne(db, kind, id) {
     if (!b) throw new Error(`shopify brand ${id} not in brands.json`);
     return await shopify.importBrand(db, b);
   }
+  if (kind === 'woocommerce') {
+    const b = (BRANDS.woocommerce_dtc || []).filter(x => typeof x === 'object').find(x => x.id === id);
+    if (!b) throw new Error(`woocommerce brand ${id} not in brands.json`);
+    return await woocommerce.importBrand(db, b);
+  }
   if (kind === 'etsy') {
     const q = BRANDS.etsy_categories.find(x => x.id === id);
     if (!q) throw new Error(`etsy query ${id} not in brands.json`);
@@ -79,6 +85,20 @@ async function importAll(db, opts = {}) {
     }
     n += brands.length;
   }
+
+  if (!filterKind || filterKind === 'woocommerce') {
+    const woo = (BRANDS.woocommerce_dtc || []).filter(x => typeof x === 'object' && !x.disabled);
+    for (const b of woo) {
+      if (n >= limit) break;
+      try {
+        const r = await woocommerce.importBrand(db, b);
+        results.push({ kind: 'woocommerce', id: b.id, ...r });
+      } catch (e) {
+        results.push({ kind: 'woocommerce', id: b.id, ok: false, err: e.message });
+      }
+      n++;
+    }
+  }
   if (!filterKind || filterKind === 'etsy') {
     for (const q of BRANDS.etsy_categories) {
       if (n >= limit) break;
diff --git a/scripts/clothing-apis/woocommerce.js b/scripts/clothing-apis/woocommerce.js
new file mode 100644
index 0000000..614e6b5
--- /dev/null
+++ b/scripts/clothing-apis/woocommerce.js
@@ -0,0 +1,122 @@
+/**
+ * WooCommerce Store API adapter — zero key, paginated, public.
+ *
+ * Endpoint: GET https://<domain>/wp-json/wc/store/v1/products?per_page=N&page=P
+ *
+ * The Store API is read-only and public on every default WooCommerce
+ * install. Some stores firewall /wp-json/ — in that case the probe returns
+ * HTML (Apache default 404) instead of JSON, which we detect like the
+ * Shopify adapter does and treat as "not WooCommerce".
+ *
+ * Tick 20 probing showed most large DTC fashion brands (Reformation, Pact,
+ * Frank+Oak, Burton, Kotn) no longer expose this — they're all headless or
+ * fully custom. Adapter still useful for the long-tail of small/indie
+ * boutiques (~30% of the global WC install base is fashion-adjacent).
+ *
+ * Add a brand:
+ *   brands.json → woocommerce_dtc[] push { id, name, domain, sustain_tier, pro_grade }
+ *
+ * Same row shape + INSERT OR IGNORE idempotence as the Shopify adapter so
+ * the UNIQUE(source, external_id) dedupe just works.
+ */
+const fetch = global.fetch || require('node-fetch');
+
+const UA = 'WhatsMyStyleBot/0.1 (catalog importer; steve@designerwallcoverings.com)';
+const PAGE_SIZE = 100;        // WooCommerce Store API hard cap is 100
+const MAX_PAGES = 10;
+const PAGE_DELAY_MS = 700;
+
+const CATEGORY_MAP = [
+  [/^t.?shirt|tee|tank|cami|blouse|shirt|button[- ]?down|top$/i, 'top'],
+  [/sweater|knit|cardigan|pullover|jumper|hoodie/i, 'top'],
+  [/pant|trouser|jean|chino|legging|short/i, 'bottom'],
+  [/skirt|midi$|mini$/i, 'bottom'],
+  [/dress|gown|jumpsuit|romper/i, 'dress'],
+  [/coat|jacket|parka|trench|blazer|puffer|outerwear/i, 'outerwear'],
+  [/shoe|sneaker|boot|sandal|loafer|heel|flat$/i, 'shoes'],
+  [/bag|tote|purse|backpack|crossbody|clutch/i, 'bag'],
+  [/scarf|hat|belt|sunglasses|jewelry|earring|necklace|ring$|bracelet|accessor/i, 'accessory'],
+];
+function normalizeCategory(text) {
+  for (const [re, cat] of CATEGORY_MAP) if (re.test(text)) return cat;
+  return null;
+}
+
+async function fetchOnePage(domain, page) {
+  const url = `https://${domain}/wp-json/wc/store/v1/products?per_page=${PAGE_SIZE}&page=${page}`;
+  const r = await fetch(url, { headers: { 'User-Agent': UA, Accept: 'application/json' }, redirect: 'follow' });
+  if (r.status === 404 || r.status === 403) return { products: [], end: true, status: r.status };
+  if (r.status === 429) return { products: [], end: true, status: 429 };
+  if (!r.ok) throw new Error(`woo ${domain} page ${page} → ${r.status}`);
+  const ct = (r.headers.get('content-type') || '').toLowerCase();
+  if (!ct.includes('json')) return { products: [], end: true, status: 'not-woo' };
+  let j;
+  try { j = await r.json(); }
+  catch { return { products: [], end: true, status: 'invalid-json' }; }
+  if (!Array.isArray(j)) return { products: [], end: true, status: 'no-products-array' };
+  return { products: j, end: j.length < PAGE_SIZE, status: r.status };
+}
+
+function rowFromProduct(domain, brand, sustainTier, proGrade, p) {
+  const price = Number(p.prices?.price || 0);                 // Store API returns price as a string in the minor unit (cents) for most currencies
+  const minorMultiplier = Number(p.prices?.currency_minor_unit ?? 2);
+  const price_cents = Number.isFinite(price) && price > 0
+    ? Math.round(price / Math.pow(10, Math.max(0, minorMultiplier - 2)))   // already in cents when minor=2
+    : null;
+  const image = p.images?.[0]?.src || p.featured_image || null;
+  const text = `${p.name || ''} ${(p.categories || []).map(c => c.name).join(' ')}`;
+  const category = normalizeCategory(text);
+  if (!image || !price_cents || !category) return null;
+
+  return {
+    source: `woocommerce:${domain}`,
+    external_id: `woo:${domain}:${p.id}`,
+    title: (p.name || '').trim().slice(0, 200),
+    brand: brand || domain,
+    category,
+    color: null,
+    pattern: null,
+    material: null,
+    price_cents,
+    currency: p.prices?.currency_code || 'USD',
+    image_url: image,
+    product_url: p.permalink || `https://${domain}/?p=${p.id}`,
+    tags: JSON.stringify((p.categories || []).map(c => c.name)),
+    pro_grade: proGrade,
+  };
+}
+
+async function importBrand(db, brandEntry) {
+  const { name, domain, sustain_tier, pro_grade } = brandEntry;
+  const t0 = Date.now();
+
+  const all = [];
+  for (let page = 1; page <= MAX_PAGES; page++) {
+    const { products, end, status } = await fetchOnePage(domain, page);
+    if (status !== 200) return { brand: name, domain, ok: false, status, fetched: all.length, imported: 0, ms: Date.now() - t0 };
+    all.push(...products);
+    if (end) break;
+    await new Promise(r => setTimeout(r, PAGE_DELAY_MS));
+  }
+
+  const insert = db.prepare(`
+    INSERT OR IGNORE INTO items
+      (source, external_id, title, brand, category, price_cents, currency, image_url, product_url, tags, pro_grade)
+    VALUES
+      (@source, @external_id, @title, @brand, @category, @price_cents, @currency, @image_url, @product_url, @tags, @pro_grade)
+  `);
+  let imported = 0, skipped = 0;
+  const tx = db.transaction((rows) => {
+    for (const r of rows) {
+      if (!r) { skipped++; continue; }
+      const res = insert.run(r);
+      if (res.changes > 0) imported++;
+      else skipped++;
+    }
+  });
+  tx(all.map(p => rowFromProduct(domain, name, sustain_tier, pro_grade, p)));
+
+  return { brand: name, domain, ok: true, fetched: all.length, imported, skipped, ms: Date.now() - t0 };
+}
+
+module.exports = { importBrand };
diff --git a/server.js b/server.js
index 67bd7b7..f024496 100644
--- a/server.js
+++ b/server.js
@@ -862,7 +862,17 @@ app.get('/api/admin/catalog-status', (req, res) => {
   const total = db.prepare('SELECT COUNT(*) c FROM items').get().c;
   const embedded = db.prepare('SELECT COUNT(*) c FROM items WHERE embedding IS NOT NULL').get().c;
   const bySource = db.prepare('SELECT source, COUNT(*) c FROM items GROUP BY source ORDER BY c DESC').all();
-  res.json({ total, embedded, unembedded: total - embedded, by_source: bySource });
+  // Tick 20: surface Mac1 state so the UI can explain why draining is paused.
+  let mac1 = null;
+  try {
+    const r = db.prepare("SELECT value FROM app_config WHERE key='mac1_status'").get();
+    if (r) mac1 = JSON.parse(r.value);
+  } catch {}
+  res.json({
+    total, embedded, unembedded: total - embedded, by_source: bySource,
+    drainer_enabled: configValue('embed_drainer_enabled', false),
+    mac1,
+  });
 });
 
 // ---- admin / debate -------------------------------------------------------
@@ -1851,10 +1861,44 @@ const EMBED_VISION_MODEL = process.env.EMBED_VISION_MODEL || 'llava:13b';
 // Mac1 was already at high concurrency (qwen3:14b loaded + 108% CPU on a
 // neighbor process) and Mac2 load avg was 9+ too. Flip ON via admin-config
 // once Mac1 frees up. Routing is wired; just the cron is dormant.
-function drainUnembedded() {
+// Tick 20: Mac1 health watcher. Probe /api/ps before spawning. If any model
+// is consuming >MAC1_BUSY_VRAM_GB, mark Mac1 busy and skip this cron tick.
+// The status is cached in app_config.mac1_status with a timestamp so the
+// admin UI can render "busy" / "idle" / "unreachable" without polling Mac1
+// directly from the browser.
+const MAC1_BUSY_VRAM_GB = 9;
+async function mac1Status() {
+  try {
+    const r = await fetch(`${EMBED_OLLAMA_URL}/api/ps`, { signal: AbortSignal.timeout(2500) });
+    if (!r.ok) return { state: 'unreachable', detail: `HTTP ${r.status}` };
+    const j = await r.json();
+    const models = j.models || [];
+    const heavyModels = models.filter(m => (m.size_vram || 0) / 1e9 > MAC1_BUSY_VRAM_GB);
+    if (heavyModels.length > 0) {
+      return { state: 'busy', detail: heavyModels.map(m => `${m.name} (${(m.size_vram/1e9).toFixed(1)}G)`).join(', ') };
+    }
+    return { state: 'idle', detail: models.length ? `${models.length} warm` : 'no models loaded' };
+  } catch (e) {
+    return { state: 'unreachable', detail: e.message };
+  }
+}
+
+function setMac1StatusCache(s) {
+  db.prepare(`INSERT INTO app_config (key, value) VALUES ('mac1_status', ?)
+              ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=datetime('now')`)
+    .run(JSON.stringify({ ...s, checked_at: new Date().toISOString() }));
+}
+
+async function drainUnembedded() {
   if (configValue('embed_drainer_enabled', false) !== true) return;
   const unembed = db.prepare("SELECT COUNT(*) c FROM items WHERE embedding IS NULL").get().c || 0;
   if (unembed === 0) return;
+
+  // Tick 20: skip if Mac1 is busy. The drainer cron would dogpile otherwise.
+  const status = await mac1Status();
+  setMac1StatusCache(status);
+  if (status.state !== 'idle') return;
+
   const batch = Math.min(20, unembed);
   const p = _spawnEmbed('node', [
     path.join(__dirname, 'scripts', 'embed-items.js'),
@@ -1867,6 +1911,12 @@ function drainUnembedded() {
   });
   p.unref();
 }
+
+// Independent of the drainer: refresh the Mac1 status cache every 60s so the
+// admin UI shows fresh state even when the drainer is disabled.
+async function refreshMac1Status() { setMac1StatusCache(await mac1Status()); }
+setTimeout(refreshMac1Status, 5 * 1000);
+setInterval(refreshMac1Status, 60 * 1000);
 // Boot-time pass after 60s — let pm2 settle first.
 setTimeout(drainUnembedded, 60 * 1000);
 // Then every 5 min until the tail is drained.

← 7bf8878 yolo tick 19: embed-progress live bar (30s poll + ETA) + pro  ·  back to Whatsmystyle  ·  yolo tick 21: Browserbase headless-SPA adapter (Reformation/ 380a952 →