← back to Carnegie Reprice
carnegie rollout: recreate deleted supervise.sh + harden worker (dupe-guard, crash-guard); launchd keep-alive
9e92acf112b01117addf66c2a1c48959f8cda22d · 2026-08-18 14:35:15 -0700 · Steve Abrams
Files touched
A archive-redirect.mjsM rebuild-line.mjsM supervise.sh
Diff
commit 9e92acf112b01117addf66c2a1c48959f8cda22d
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Aug 18 14:35:15 2026 -0700
carnegie rollout: recreate deleted supervise.sh + harden worker (dupe-guard, crash-guard); launchd keep-alive
---
archive-redirect.mjs | 127 +++++++++++++++++++++++++++++++++++++++++++++++++++
rebuild-line.mjs | 12 +++--
supervise.sh | 15 +++---
3 files changed, 144 insertions(+), 10 deletions(-)
diff --git a/archive-redirect.mjs b/archive-redirect.mjs
new file mode 100644
index 0000000..897cb27
--- /dev/null
+++ b/archive-redirect.mjs
@@ -0,0 +1,127 @@
+// archive-redirect.mjs — TK-10686 post-create step: archive the OLD Carnegie multi-variant
+// products and 301-redirect each old handle -> its new first-colorway standalone product (target A).
+// Steve APPROVED the plan 2026-08-18 (pending-approval/...-archive-redirect-gmc-GATED.md).
+//
+// SAFETY (mirrors reprice.mjs):
+// - dry-run by default: enumerates, joins, writes archive-run-map.json + preview, touches NOTHING.
+// - --apply : gated live write (archive old product + create redirect). Reversible.
+// - --rollback archive-run-map.json : un-archive every product + delete every created redirect.
+// - COMPLETENESS GATE: an old product is only archived if EVERY colorway of its pattern
+// (carnegie_catalog rows) already exists as a created new product (rebuild-ledger.jsonl w/ product_id).
+// Any pattern with missing replacements is SKIPPED and reported — never archived half-covered.
+// - NEW split products (tagged split-batch:TK-10686) are never touched.
+import { execFileSync } from 'node:child_process';
+import fs from 'node:fs';
+
+const DIR = new URL('.', import.meta.url).pathname;
+const ENV = `${process.env.HOME}/Projects/secrets-manager/.env`;
+function env(k){ const m=fs.readFileSync(ENV,'utf8').split('\n').find(l=>l.startsWith(k+'=')); return m?m.slice(k.length+1).trim().replace(/^["']|["']$/g,''):''; }
+const TOKEN=env('SHOPIFY_ADMIN_TOKEN');
+let SHOP=env('SHOPIFY_STORE_DOMAIN')||env('SHOPIFY_STORE'); if(SHOP&&!SHOP.includes('.'))SHOP+='.myshopify.com';
+const API=`https://${SHOP}/admin/api/2024-10`;
+const H={'X-Shopify-Access-Token':TOKEN,'Content-Type':'application/json'};
+const MODE=process.argv[2]||'dry'; // dry | apply | rollback
+const ROLLBACK_FILE=process.argv[3];
+const TAG='split-batch:TK-10686';
+const LEDGER=`${DIR}rebuild-ledger.jsonl`;
+const sleep=ms=>new Promise(r=>setTimeout(r,ms));
+
+const PSQL=['/opt/homebrew/opt/postgresql@14/bin/psql','/usr/local/opt/postgresql@14/bin/psql','psql']
+ .find(p=>{try{execFileSync(p,['--version'],{stdio:'ignore'});return true;}catch{return false;}})||'psql';
+function q(sql){ const out=execFileSync(PSQL,['postgresql:///dw_unified?host=/tmp','-At','-F','|','-c',sql],{encoding:'utf8',maxBuffer:128*1024*1024}); return out.trim()?out.trim().split('\n').map(r=>r.split('|')):[]; }
+
+async function shop(path,opts={},tries=5){
+ for(let i=0;i<tries;i++){ const res=await fetch(`${API}${path}`,{headers:H,...opts});
+ if(res.status===429){await sleep(2000*(i+1));continue;}
+ if(!res.ok)throw new Error(`HTTP ${res.status} ${path} :: ${(await res.text()).slice(0,200)}`);
+ return res; }
+ throw new Error(`exhausted retries ${path}`);
+}
+async function allCarnegie(){ const out=[]; let url=`/products.json?vendor=Carnegie&limit=250`;
+ while(url){ const res=await shop(url); const j=await res.json(); out.push(...(j.products||[]));
+ const link=res.headers.get('link')||''; const m=link.match(/<[^>]*[?&]page_info=([^>&]+)[^>]*>;\s*rel="next"/);
+ url=m?`/products.json?limit=250&page_info=${m[1]}`:null; await sleep(600); }
+ return out;
+}
+
+// ---- ROLLBACK ----
+if(MODE==='rollback'){
+ if(!ROLLBACK_FILE||!fs.existsSync(ROLLBACK_FILE))throw new Error('rollback needs archive-run-map.json');
+ const map=JSON.parse(fs.readFileSync(ROLLBACK_FILE,'utf8'));
+ console.log(`[rollback] un-archiving ${map.archived.length} products + deleting ${map.redirects.length} redirects…`);
+ for(const a of map.archived){ await shop(`/products/${a.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:a.old_id,status:'active'}})}); await sleep(550); }
+ for(const r of map.redirects){ if(r.redirect_id){ try{await shop(`/redirects/${r.redirect_id}.json`,{method:'DELETE'});}catch{} await sleep(400);} }
+ console.log(`[rollback] done`); process.exit(0);
+}
+
+// ---- BUILD: pattern -> colorways expected (carnegie_catalog) ----
+const patColorways=new Map(); // pattern_name -> [{dw_sku,color_number}]
+for(const [pat,sku,cn] of q(`select pattern_name, dw_sku, color_number from carnegie_catalog order by pattern_name, dw_sku`)){
+ if(!patColorways.has(pat))patColorways.set(pat,[]); patColorways.get(pat).push({sku,cn});
+}
+// ---- BUILD: dw_sku -> new product (from ledger, only rows with a product_id) ----
+const newBySku=new Map(); // dw_sku -> {product_id, handle}
+if(fs.existsSync(LEDGER)) for(const l of fs.readFileSync(LEDGER,'utf8').split('\n')){ if(!l.trim())continue; try{const r=JSON.parse(l); if(r.product_id&&r.dw_sku)newBySku.set(r.dw_sku,{id:r.product_id,handle:r.handle});}catch{} }
+// pattern -> first-colorway new handle (lowest color_number that exists as a new product)
+function firstColorwayNewHandle(pat){ const cw=patColorways.get(pat)||[]; for(const c of cw){ const np=newBySku.get(c.sku); if(np)return np.handle; } return null; }
+function patternComplete(pat){ const cw=patColorways.get(pat)||[]; if(!cw.length)return false; return cw.every(c=>newBySku.has(c.sku)); }
+
+// ---- ENUMERATE old multi-variant Carnegie products (NOT tagged split-batch, has a "Color" option) ----
+const products=await allCarnegie();
+const old=products.filter(p=>{
+ const tags=(p.tags||'').includes(TAG); if(tags)return false; // exclude new split products
+ const hasColor=(p.options||[]).some(o=>/^colou?r$/i.test(o.name)); // original had a Color option
+ return hasColor;
+});
+console.log(`[enum] live Carnegie products: ${products.length} | old multi-variant (Color option, not split-tagged): ${old.length}`);
+
+// map each old product to its pattern via its non-sample variant SKUs -> carnegie_catalog pattern
+const skuPattern=new Map(); for(const [pat,sku] of q(`select pattern_name, dw_sku from carnegie_catalog`)) skuPattern.set(sku,pat);
+const ready=[], blocked=[], nopattern=[];
+for(const p of old){
+ const skus=(p.variants||[]).map(v=>v.sku).filter(Boolean).filter(s=>!/sample/i.test(s));
+ const pats=[...new Set(skus.map(s=>skuPattern.get(s)).filter(Boolean))];
+ const pat=pats[0];
+ if(!pat){ nopattern.push({handle:p.handle,id:p.id}); continue; }
+ const target=firstColorwayNewHandle(pat);
+ const complete=patternComplete(pat) && target;
+ const rec={old_id:p.id, old_handle:p.handle, pattern:pat, expected:(patColorways.get(pat)||[]).length,
+ present:(patColorways.get(pat)||[]).filter(c=>newBySku.has(c.sku)).length, target_handle:target};
+ (complete?ready:blocked).push(rec);
+}
+
+const stamp=q(`select to_char(now(),'YYYYMMDD-HH24MISS')`)[0][0];
+const preview={ ts:new Date().toISOString(), mode:MODE, redirect_target:'A (old -> new first-colorway product)',
+ old_total:old.length, ready:ready.length, blocked:blocked.length, no_pattern:nopattern.length,
+ ready_sample:ready.slice(0,8), blocked_sample:blocked.slice(0,8), no_pattern_sample:nopattern.slice(0,5) };
+fs.writeFileSync(`${DIR}archive-preview-${stamp}.json`,JSON.stringify(preview,null,2));
+console.log(`\n=== ARCHIVE+REDIRECT PLAN (${MODE.toUpperCase()}) — target A ===`);
+console.log(` old multi-variant products : ${old.length}`);
+console.log(` READY (all colorways created): ${ready.length}`);
+console.log(` BLOCKED (replacements incomplete): ${blocked.length}`);
+console.log(` no-pattern (review): ${nopattern.length}`);
+for(const r of ready.slice(0,8)) console.log(` ARCHIVE ${r.old_handle.padEnd(34)} -> /products/${r.target_handle}`);
+if(blocked.length) console.log(` ⚠ ${blocked.length} blocked e.g. ${blocked.slice(0,3).map(b=>`${b.old_handle}(${b.present}/${b.expected})`).join(', ')}`);
+
+if(MODE!=='apply'){ console.log(`\n[dry-run] nothing written. preview: archive-preview-${stamp}.json. Re-run 'apply' AFTER creates complete + Steve go.`); process.exit(0); }
+
+// ---- APPLY (gated): archive READY old products + create redirect ----
+if(!ready.length){ console.log('[apply] nothing READY yet (creates incomplete) — refusing to archive half-covered patterns.'); process.exit(0); }
+console.log(`\n[apply] archiving ${ready.length} old products + creating redirects…`);
+const runmap={ts:new Date().toISOString(),archived:[],redirects:[]};
+let ok=0,err=0;
+for(const r of ready){
+ try{
+ await shop(`/products/${r.old_id}.json`,{method:'PUT',body:JSON.stringify({product:{id:r.old_id,status:'archived'}})});
+ runmap.archived.push({old_id:r.old_id,old_handle:r.old_handle});
+ const rr=await shop(`/redirects.json`,{method:'POST',body:JSON.stringify({redirect:{path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`}})});
+ const rid=(await rr.json())?.redirect?.id||null;
+ runmap.redirects.push({redirect_id:rid,path:`/products/${r.old_handle}`,target:`/products/${r.target_handle}`});
+ ok++;
+ }catch(e){ err++; console.log(` ERR ${r.old_handle}: ${String(e.message||e).slice(0,120)}`); }
+ if((ok+err)%25===0)console.log(` ${ok+err}/${ready.length} (ok ${ok}, err ${err})`);
+ await sleep(600);
+}
+fs.writeFileSync(`${DIR}archive-run-map.json`,JSON.stringify(runmap,null,2));
+console.log(`[apply] done: ${ok} archived+redirected, ${err} err. Rollback: node archive-redirect.mjs rollback archive-run-map.json`);
+console.log(`[apply] ${blocked.length} patterns still blocked — re-run apply after remaining creates land.`);
diff --git a/rebuild-line.mjs b/rebuild-line.mjs
index 9ce791b..a9db665 100644
--- a/rebuild-line.mjs
+++ b/rebuild-line.mjs
@@ -186,10 +186,16 @@ async function main(){
ledgerAppend({dw_sku:sku, error:'create: '+e.message.slice(0,200), at:new Date().toISOString()}); continue;
}
- const mf = await setMetafields(product.id, built);
- // storage guard: if we EXPECTED images but the product has none, that's the cap symptom
+ // DUPE-GUARD: ledger the create IMMEDIATELY so a crash/kill before metafields can never re-create it.
+ ledgerAppend({dw_sku:sku, product_id:product.id, handle:built.handle, at:new Date().toISOString(), phase:'created'});
+
+ // CRASH-GUARD: metafields/verify must never throw the whole shard down.
+ let mf={ok:0,err:'skipped'};
let attached = product.images ? product.images.length : 0;
- if(built.imgCount>0 && attached===0){ attached = await verifyImages(product.id); }
+ try{
+ mf = await setMetafields(product.id, built);
+ if(built.imgCount>0 && attached===0){ attached = await verifyImages(product.id); }
+ }catch(e){ mf={ok:0,err:('mf/verify: '+String(e.message||e)).slice(0,120)}; }
const imageStarved = built.imgCount>0 && attached<=0;
imgFailStreak = imageStarved ? imgFailStreak+1 : 0;
diff --git a/supervise.sh b/supervise.sh
old mode 100644
new mode 100755
index 5860a2c..1bdf631
--- a/supervise.sh
+++ b/supervise.sh
@@ -1,25 +1,26 @@
#!/bin/bash
-# supervise.sh — keep the sharded Carnegie rollout workers alive until the ledger is complete.
-# Fully detached; auto-restarts a shard worker if it dies. Stops when 0 SKUs remain or HALT flag set.
+# supervise.sh — keep the 3-shard Carnegie split rollout workers alive until the ledger is complete.
+# Run under launchd (com.steve.carnegie-rollout, KeepAlive SuccessfulExit=false).
+# Exits 0 when 0 SKUs remain OR a HALT flag is set (so launchd does NOT restart after clean completion).
cd "$(dirname "$0")" || exit 1
SHARDS=3
-NODE=$(command -v node)
+NODE="$(command -v node)"; [ -z "$NODE" ] && NODE=/opt/homebrew/bin/node
log(){ echo "[$(date -u +%H:%M:%S)] $*" >> supervise.log; }
log "supervisor start (pid $$), $SHARDS shards"
while true; do
if [ -f rebuild-HALTED.flag ]; then log "HALT flag present — supervisor exiting"; exit 0; fi
- remaining=$("$NODE" rebuild-line.mjs --status 2>/dev/null | head -1 | sed -E 's/.* ([0-9]+) remaining.*/\1/')
+ remaining="$("$NODE" rebuild-line.mjs --status 2>/dev/null | head -1 | sed -E 's/.* ([0-9]+) remaining.*/\1/')"
if [ "$remaining" = "0" ]; then log "0 remaining — rollout complete, supervisor exiting"; exit 0; fi
for n in $(seq 1 $SHARDS); do
pidfile="worker-$n.pid"
alive=0
- if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile")" 2>/dev/null; then alive=1; fi
+ if [ -f "$pidfile" ] && kill -0 "$(cat "$pidfile" 2>/dev/null)" 2>/dev/null; then alive=1; fi
if [ $alive -eq 0 ]; then
- nohup "$NODE" rebuild-line.mjs --shard "$n/$SHARDS" >> "rebuild-shard-$n.log" 2>&1 &
+ "$NODE" rebuild-line.mjs --shard "$n/$SHARDS" >> "rebuild-shard-$n.log" 2>&1 &
echo $! > "$pidfile"
- log "started shard $n/$SHARDS pid $! (remaining $remaining)"
+ log "started shard $n/$SHARDS pid $! (remaining ${remaining:-?})"
sleep 3
fi
done
← 774a09f auto-data-snapshot: 2026-08-18T14:32:35 (8 data files) — arc
·
back to Carnegie Reprice
·
supervise: self-announce completion (CNCP card + marker) for e09c5af →