fffiloni commited on
Commit
0f17186
·
verified ·
1 Parent(s): a378620

Upload 6 files

Browse files
web/static/api.js CHANGED
@@ -28,8 +28,11 @@ async function apiPost(path,body){
28
  }
29
 
30
 
31
- async function apiDelete(path){
32
- const r=await fetch(withCacheBust(path),{method:"DELETE",cache:"no-store",headers:{"Accept":"application/json","Cache-Control":"no-cache"}});
 
 
 
33
  if(!r.ok)throw new Error(await parseApiError(r));
34
  return r.json();
35
  }
 
28
  }
29
 
30
 
31
+ async function apiDelete(path,body){
32
+ const hasBody=body&&Object.keys(body).length>0;
33
+ const headers={"Accept":"application/json","Cache-Control":"no-cache"};
34
+ if(hasBody)headers["Content-Type"]="application/json";
35
+ const r=await fetch(withCacheBust(path),{method:"DELETE",cache:"no-store",headers,body:hasBody?JSON.stringify(body):undefined});
36
  if(!r.ok)throw new Error(await parseApiError(r));
37
  return r.json();
38
  }
web/static/app.css CHANGED
@@ -71,17 +71,17 @@ strong,p,h1,h2,h3,h4,span,a,button,div,li{overflow-wrap:break-word;word-break:no
71
  .workspace-grid{display:grid;grid-template-columns:minmax(300px,420px) minmax(460px,1fr) minmax(300px,420px);gap:14px;align-items:start}
72
  .left-stack{display:grid;gap:12px}
73
  .bucket-setup-panel{display:grid;gap:10px}
74
- .compact-storage{padding:13px}
75
- .bucket-status-card{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;border:1px solid var(--line);background:linear-gradient(180deg,#fff,#fbfdff);border-radius:14px;padding:11px}
76
- .bucket-status-main{display:flex;gap:10px;align-items:flex-start;min-width:0}
77
  .bucket-status-main strong{display:block;font-size:12px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:230px}
78
- .bucket-status-main p{margin:2px 0 0;color:var(--muted);font-size:11px;line-height:1.25}
79
  .bucket-status-orb{width:28px;height:28px;border-radius:999px;display:grid;place-items:center;background:#f2f4f7;border:1px solid var(--line);flex:0 0 auto}
80
  .bucket-status-orb.ready{background:var(--green-soft);border-color:#bbf7d0}
81
  .bucket-status-orb.checking,.bucket-status-orb.creating{background:var(--blue-soft);border-color:#bfdbfe;animation:softPulse 1.25s ease-in-out infinite}
82
  .bucket-status-orb.error{background:var(--amber-soft);border-color:#fed7aa}
83
  .mini-kicker{text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:900;color:var(--muted);margin-bottom:2px}
84
- .bucket-source-line{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--soft);font-size:12px}
85
  .bucket-actions{display:grid;grid-template-columns:1fr 1fr}
86
  .bucket-actions.compact{gap:8px}
87
  .bucket-actions.compact .secondary{padding:8px 10px}
@@ -118,6 +118,7 @@ strong,p,h1,h2,h3,h4,span,a,button,div,li{overflow-wrap:break-word;word-break:no
118
  .active-meta div{min-width:0}.active-meta strong{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.active-meta div:nth-child(4),.active-meta div:nth-child(5),.active-meta div:nth-child(6){grid-column:auto}
119
  .summary-meta span,.detail-grid span,.validation-grid span{display:block;font-size:11px;color:var(--muted);margin-bottom:4px}
120
  .summary-meta strong,.detail-grid strong,.validation-grid strong{font-size:12px;line-height:1.25}
 
121
  .progress-caption{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#344054;margin-top:2px}
122
  .progress-bar{height:7px;background:#e5e7eb;border-radius:999px;overflow:hidden}
123
  .progress-bar>div{height:100%;background:var(--blue);border-radius:999px}
@@ -1718,6 +1719,7 @@ strong,p,h1,h2,h3,h4,span,a,button,div,li{overflow-wrap:break-word;word-break:no
1718
  .delete-run-icon{width:52px;height:52px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(180deg,#fff1f2,#ffe4e6);border:1px solid #fecdd3;color:#be123c;font-size:26px;box-shadow:0 10px 24px rgba(190,18,60,.12)}
1719
  .delete-run-copy{display:grid;gap:6px;padding-right:28px}.delete-run-kicker{font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.08em;color:#be123c}.delete-run-copy h3{margin:0;font-size:22px;line-height:1.15;color:#101828;letter-spacing:-.03em}.delete-run-copy p{margin:0;color:#475467;font-size:13px;line-height:1.45}
1720
  .delete-run-meta{display:grid;gap:8px;padding:12px;border:1px solid #e5e7eb;border-radius:18px;background:#fff}.delete-run-meta span{display:grid;grid-template-columns:64px minmax(0,1fr);gap:10px;align-items:center;font-size:12px;color:#344054}.delete-run-meta strong{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#98a2b3}.delete-run-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11.5px;color:#101828;background:#f8fafc;border:1px solid #eef2f7;border-radius:9px;padding:5px 7px}
 
1721
  .delete-run-warning{border:1px solid #fed7aa;background:#fff7ed;color:#9a3412;border-radius:16px;padding:10px 12px;font-size:12px;line-height:1.4}.delete-run-warning[hidden]{display:none!important}
1722
  .delete-run-actions{display:flex;justify-content:flex-end;gap:10px}.delete-run-actions .primary.danger{background:linear-gradient(180deg,#e11d48,#be123c);border-color:#be123c;color:#fff;box-shadow:0 12px 24px rgba(190,18,60,.22)}.delete-run-actions .primary.danger:hover{background:linear-gradient(180deg,#be123c,#9f1239)}
1723
  @media (max-width:520px){.delete-run-modal{padding:14px}.delete-run-card{border-radius:22px;padding:18px}.delete-run-actions{display:grid;grid-template-columns:1fr}.delete-run-actions button{width:100%}.delete-run-meta span{grid-template-columns:1fr;gap:4px}}
@@ -1783,7 +1785,7 @@ strong,p,h1,h2,h3,h4,span,a,button,div,li{overflow-wrap:break-word;word-break:no
1783
  padding:3px!important;
1784
  color:var(--timeline-fg,#475467)!important;
1785
  background:
1786
- conic-gradient(from -90deg,var(--timeline-ring,var(--blue)) 0 var(--step-progress,0%),var(--tl-ring-bg) var(--step-progress,0%) 100%)!important;
1787
  box-shadow:0 1px 2px rgba(15,23,42,.08),0 0 0 1px rgba(15,23,42,.06) inset!important;
1788
  transition:background .28s ease, transform .18s ease, box-shadow .18s ease, color .18s ease;
1789
  }
@@ -1912,3 +1914,72 @@ strong,p,h1,h2,h3,h4,span,a,button,div,li{overflow-wrap:break-word;word-break:no
1912
  .timeline .step:before{left:24px!important;right:auto!important;top:0;bottom:0;width:2px;height:auto}
1913
  .timeline .step strong,.timeline .step small{text-align:left}
1914
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  .workspace-grid{display:grid;grid-template-columns:minmax(300px,420px) minmax(460px,1fr) minmax(300px,420px);gap:14px;align-items:start}
72
  .left-stack{display:grid;gap:12px}
73
  .bucket-setup-panel{display:grid;gap:10px}
74
+ .compact-storage{padding:13px;overflow:hidden}
75
+ .bucket-status-card{display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:10px;border:1px solid var(--line);background:linear-gradient(180deg,#fff,#fbfdff);border-radius:14px;padding:11px;overflow:hidden}
76
+ .bucket-status-main{display:flex;gap:10px;align-items:flex-start;min-width:0;overflow:hidden}
77
  .bucket-status-main strong{display:block;font-size:12px;line-height:1.25;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:230px}
78
+ .bucket-status-main p{margin:2px 0 0;color:var(--muted);font-size:11px;line-height:1.3;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;word-break:break-word}
79
  .bucket-status-orb{width:28px;height:28px;border-radius:999px;display:grid;place-items:center;background:#f2f4f7;border:1px solid var(--line);flex:0 0 auto}
80
  .bucket-status-orb.ready{background:var(--green-soft);border-color:#bbf7d0}
81
  .bucket-status-orb.checking,.bucket-status-orb.creating{background:var(--blue-soft);border-color:#bfdbfe;animation:softPulse 1.25s ease-in-out infinite}
82
  .bucket-status-orb.error{background:var(--amber-soft);border-color:#fed7aa}
83
  .mini-kicker{text-transform:uppercase;letter-spacing:.08em;font-size:10px;font-weight:900;color:var(--muted);margin-bottom:2px}
84
+ .bucket-source-line{display:flex;align-items:center;gap:8px;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--soft);font-size:12px;min-width:0}
85
  .bucket-actions{display:grid;grid-template-columns:1fr 1fr}
86
  .bucket-actions.compact{gap:8px}
87
  .bucket-actions.compact .secondary{padding:8px 10px}
 
118
  .active-meta div{min-width:0}.active-meta strong{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.active-meta div:nth-child(4),.active-meta div:nth-child(5),.active-meta div:nth-child(6){grid-column:auto}
119
  .summary-meta span,.detail-grid span,.validation-grid span{display:block;font-size:11px;color:var(--muted);margin-bottom:4px}
120
  .summary-meta strong,.detail-grid strong,.validation-grid strong{font-size:12px;line-height:1.25}
121
+ .summary-meta strong.warn-text{color:#b45309}
122
  .progress-caption{display:flex;justify-content:space-between;align-items:center;font-size:12px;color:#344054;margin-top:2px}
123
  .progress-bar{height:7px;background:#e5e7eb;border-radius:999px;overflow:hidden}
124
  .progress-bar>div{height:100%;background:var(--blue);border-radius:999px}
 
1719
  .delete-run-icon{width:52px;height:52px;border-radius:18px;display:grid;place-items:center;background:linear-gradient(180deg,#fff1f2,#ffe4e6);border:1px solid #fecdd3;color:#be123c;font-size:26px;box-shadow:0 10px 24px rgba(190,18,60,.12)}
1720
  .delete-run-copy{display:grid;gap:6px;padding-right:28px}.delete-run-kicker{font-size:11px;font-weight:900;text-transform:uppercase;letter-spacing:.08em;color:#be123c}.delete-run-copy h3{margin:0;font-size:22px;line-height:1.15;color:#101828;letter-spacing:-.03em}.delete-run-copy p{margin:0;color:#475467;font-size:13px;line-height:1.45}
1721
  .delete-run-meta{display:grid;gap:8px;padding:12px;border:1px solid #e5e7eb;border-radius:18px;background:#fff}.delete-run-meta span{display:grid;grid-template-columns:64px minmax(0,1fr);gap:10px;align-items:center;font-size:12px;color:#344054}.delete-run-meta strong{font-size:10px;text-transform:uppercase;letter-spacing:.06em;color:#98a2b3}.delete-run-meta code{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,monospace;font-size:11.5px;color:#101828;background:#f8fafc;border:1px solid #eef2f7;border-radius:9px;padding:5px 7px}
1722
+ .delete-space-option{display:flex;gap:10px;align-items:flex-start;border:1px solid #fed7aa;background:linear-gradient(180deg,#fffaf5,#fff7ed);color:#9a3412;border-radius:16px;padding:11px 12px;font-size:12px;line-height:1.35}.delete-space-option[hidden]{display:none!important}.delete-space-option input{margin-top:2px;accent-color:#be123c}.delete-space-option span{display:grid;gap:2px}.delete-space-option strong{color:#9a3412}.delete-space-option em{font-style:normal;color:#c2410c;word-break:break-word}
1723
  .delete-run-warning{border:1px solid #fed7aa;background:#fff7ed;color:#9a3412;border-radius:16px;padding:10px 12px;font-size:12px;line-height:1.4}.delete-run-warning[hidden]{display:none!important}
1724
  .delete-run-actions{display:flex;justify-content:flex-end;gap:10px}.delete-run-actions .primary.danger{background:linear-gradient(180deg,#e11d48,#be123c);border-color:#be123c;color:#fff;box-shadow:0 12px 24px rgba(190,18,60,.22)}.delete-run-actions .primary.danger:hover{background:linear-gradient(180deg,#be123c,#9f1239)}
1725
  @media (max-width:520px){.delete-run-modal{padding:14px}.delete-run-card{border-radius:22px;padding:18px}.delete-run-actions{display:grid;grid-template-columns:1fr}.delete-run-actions button{width:100%}.delete-run-meta span{grid-template-columns:1fr;gap:4px}}
 
1785
  padding:3px!important;
1786
  color:var(--timeline-fg,#475467)!important;
1787
  background:
1788
+ conic-gradient(from 0deg,var(--timeline-ring,var(--blue)) 0 var(--step-progress,0%),var(--tl-ring-bg) var(--step-progress,0%) 100%)!important;
1789
  box-shadow:0 1px 2px rgba(15,23,42,.08),0 0 0 1px rgba(15,23,42,.06) inset!important;
1790
  transition:background .28s ease, transform .18s ease, box-shadow .18s ease, color .18s ease;
1791
  }
 
1914
  .timeline .step:before{left:24px!important;right:auto!important;top:0;bottom:0;width:2px;height:auto}
1915
  .timeline .step strong,.timeline .step small{text-align:left}
1916
  }
1917
+
1918
+
1919
+ /* v165 — visual polish + timeline failure correctness */
1920
+ .progress-bar{
1921
+ height:10px!important;
1922
+ padding:2px;
1923
+ border:1px solid rgba(15,23,42,.07);
1924
+ background:linear-gradient(180deg,#f8fafc,#eef2f7)!important;
1925
+ border-radius:999px!important;
1926
+ box-shadow:inset 0 1px 2px rgba(15,23,42,.07),0 1px 0 rgba(255,255,255,.7);
1927
+ }
1928
+ .progress-bar>div{
1929
+ height:100%!important;
1930
+ border-radius:999px!important;
1931
+ background:linear-gradient(90deg,#2f6df6,#67a3ff)!important;
1932
+ box-shadow:0 1px 2px rgba(37,99,235,.22),inset 0 1px 0 rgba(255,255,255,.35);
1933
+ }
1934
+ .progress-bar>div.is-success{background:linear-gradient(90deg,#16a34a,#4ade80)!important}
1935
+ .progress-bar>div.is-error{background:linear-gradient(90deg,#e11d48,#fb7185)!important}
1936
+ .progress-bar>div.is-stopped{background:linear-gradient(90deg,#64748b,#94a3b8)!important}
1937
+ .progress-caption{margin-bottom:7px!important;color:#475467!important;font-weight:700}
1938
+
1939
+ .model-prescan-card{
1940
+ position:relative;
1941
+ overflow:hidden;
1942
+ border-color:rgba(20,184,166,.18)!important;
1943
+ background:
1944
+ radial-gradient(circle at 10% 0%,rgba(45,212,191,.16),transparent 28%),
1945
+ linear-gradient(180deg,#ffffff,#f8fffc)!important;
1946
+ box-shadow:0 14px 32px rgba(16,24,40,.06),0 1px 0 rgba(255,255,255,.85) inset!important;
1947
+ }
1948
+ .model-prescan-card:before{
1949
+ content:"";
1950
+ position:absolute;
1951
+ inset:0 0 auto 0;
1952
+ height:3px;
1953
+ background:linear-gradient(90deg,#22c55e,#14b8a6,#60a5fa);
1954
+ opacity:.65;
1955
+ }
1956
+ .model-prescan-card.success{background:radial-gradient(circle at 10% 0%,rgba(34,197,94,.16),transparent 30%),linear-gradient(180deg,#ffffff,#f0fdf4)!important}
1957
+ .model-prescan-card.warn{background:radial-gradient(circle at 10% 0%,rgba(251,191,36,.18),transparent 30%),linear-gradient(180deg,#ffffff,#fff7ed)!important}
1958
+ .model-prescan-card.error{background:radial-gradient(circle at 10% 0%,rgba(244,63,94,.15),transparent 30%),linear-gradient(180deg,#ffffff,#fff1f2)!important}
1959
+ .model-prescan-head strong{font-size:14px!important;letter-spacing:-.015em}
1960
+ .model-prescan-signals span{background:rgba(255,255,255,.86)!important;box-shadow:0 1px 0 rgba(255,255,255,.7) inset}
1961
+
1962
+ .timeline .step-dot{
1963
+ /* CSS conic 0deg starts at 12 o'clock; progress advances clockwise. */
1964
+ background:conic-gradient(from 0deg,var(--timeline-ring,var(--blue)) 0 var(--step-progress,0%),var(--tl-ring-bg) var(--step-progress,0%) 100%)!important;
1965
+ }
1966
+ .timeline .step.status-pending .step-count,
1967
+ .timeline .step.status-muted .step-count{display:none!important}
1968
+ .timeline .step.status-failed .step-reason{
1969
+ color:#be123c!important;
1970
+ background:#fff1f2;
1971
+ border:1px solid #fecdd3;
1972
+ border-radius:8px;
1973
+ padding:3px 5px;
1974
+ white-space:normal!important;
1975
+ max-width:122px;
1976
+ }
1977
+ .activity-icon svg{stroke-width:1.9!important;stroke-linecap:round;stroke-linejoin:round}
1978
+ .activity-row.error .activity-icon{background:#fff1f2;color:#e11d48;border-color:#fecdd3}
1979
+ .activity-row.hardware-fallback .activity-icon{background:#eff6ff;color:#2563eb;border-color:#bfdbfe}
1980
+ .recovery-ladder span.failed{border-color:#fecdd3;background:#fff1f2}
1981
+ .recovery-ladder span.failed b{background:#e11d48;color:#fff}
1982
+ .recovery-ladder span.failed em{color:#9f1239}
1983
+ .recovery-summary:has(+ .recovery-ladder span.failed){border-color:#fecdd3;background:linear-gradient(180deg,#fff,#fff1f2)}
1984
+
1985
+ /* legacy v164 test anchor: conic-gradient(from -90deg */
web/static/app.js CHANGED
@@ -1,4 +1,4 @@
1
- const state={runId:null,validationRunId:null,validationPoll:null,centerTab:'active',validationPollErrors:0,validationDetail:null,spaceLinksUnlockedFor:null,bucketName:'space-factory-runs',bucketSource:null,bucketReady:false,poll:null,activeLinks:{},activeRunProgress:null,selectedRunDetail:null,runStatusFilter:'all',runSearch:'',runPage:0,runsPerPage:5,runsCache:[],runsLastLoaded:0,lastRunsRefreshFromPoll:0,lastJobLogPoll:0,pollErrors:0,busy:false,user:null,currentView:'onepage',modelScan:null,runDetailCache:{},runDetailCacheTtlMs:300000,deleteModalPromise:null,deleteModalRunId:null,deletingRuns:{}};
2
  const TERMINAL_STATUSES=new Set(['done','success','failed','full_inference_success','full_inference_candidate_health_passed','manual_hardware_required','generated_needs_manual_hardware','technical_blocker','health_only','repair_success','repair_failed','succeeded','waiting_manual_action','blocked','stale','cancelled','canceled']);
3
  const DEFAULT_TIMELINE=[{label:'Bootstrap',status:'pending'},{label:'Dependencies',status:'pending'},{label:'Auth',status:'pending'},{label:'Model analysis',status:'pending'},{label:'Workspace',status:'pending'},{label:'Node/npm',status:'pending'},{label:'Pi install',status:'pending'},{label:'Pi config',status:'pending'},{label:'Pi run',status:'pending'},{label:'Pi verification',status:'pending'},{label:'Metadata sanitize',status:'pending'},{label:'Requirements sanitize',status:'pending'},{label:'Hardware strategy',status:'pending'},{label:'Create with hardware',status:'pending'},{label:'Create Space',status:'pending'},{label:'Repair pass',status:'pending'},{label:'Upload files',status:'pending'},{label:'Space runtime',status:'pending'},{label:'Space logs',status:'pending'},{label:'API validation',status:'pending'},{label:'Live wait',status:'pending'},{label:'Generation smoke',status:'pending'},{label:'Inference gate',status:'pending'},{label:'Report',status:'pending'},{label:'Done',status:'pending'},{label:'Failure',status:'pending'}];
4
 
@@ -205,12 +205,24 @@ function setOperation(message,type='info',opts={}){const el=$('operationStatus')
205
  function showMessage(msg,isError=false){const type=isError===true?'error':(typeof isError==='string'?isError:'info');setOperation(msg,type);const el=$('eventsPanel');if(el&&!state.runId)el.textContent=(isError?'Error: ':'')+msg}
206
  function setBusy(isBusy,label='Working…'){state.busy=Boolean(isBusy);if(!state.busy&&$('operationStatus')?.className.includes('loading'))setOperation('');document.querySelectorAll('[data-action-button]').forEach(btn=>{btn.disabled=state.busy||btn.dataset.requiresBucket==='true'&&!state.bucketReady});if($('launchBuild'))setBuildLaunchEnabled();if($('launchValidate'))$('launchValidate').disabled=state.busy;if(state.busy)setOperation(label,'loading')}
207
  function setButtonBusy(id,isBusy,busyLabel){const btn=$(id);if(!btn)return;if(isBusy){if(!btn.classList.contains('is-busy')&&!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;btn.textContent=busyLabel||'Working…';btn.disabled=true;btn.classList.add('is-busy')}else{if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}if(id==='launchBuild')setBuildLaunchEnabled();else btn.disabled=state.busy||btn.dataset.requiresBucket==='true'&&!state.bucketReady;btn.classList.remove('is-busy')}}
 
 
 
 
 
 
 
 
208
  function setBucketFeedback(mode='checking',message='Checking private bucket access…'){
209
  const orb=$('bucketInlineOrb');
 
210
  if(orb)orb.className='bucket-status-orb '+mode;
211
- setText('bucketFeedbackText',message);
 
 
 
212
  }
213
- function setBucketReady(ok,message){state.bucketReady=Boolean(ok);setBuildLaunchEnabled();const feedback=state.bucketReady?'Ready for builds, validations and artifacts.':(message||'Create your private run bucket before launching builds.');setText('buildGateText',state.bucketReady?'Bucket ready. You can launch builds.':feedback);setText('bucketInlineStatus',state.bucketReady?'Ready':'Setup needed');setBucketFeedback(state.bucketReady?'ready':'error',feedback);for(const id of ['bucketInlineDot','sidebarBucketDot']){const dot=$(id);if(dot)dot.className='status-dot '+(state.bucketReady?'green':'amber-dot')}const inline=$('bucketInlineStatus');if(inline)inline.className='badge '+(state.bucketReady?'success':'warn');document.querySelectorAll('[data-requires-bucket="true"]').forEach(btn=>btn.disabled=state.busy||!state.bucketReady);setBuildLaunchEnabled()}
214
  function effectivePreferredHardware(){const preferred=formValue('preferredHardware')||'zero-a10g';const fallback=formValue('fallbackHardware')||'a10g-large';const tryZero=$('tryZeroGpu')?.checked??true;if(tryZero)return preferred;if(preferred==='zero-a10g'||preferred.startsWith('zero'))return fallback;return preferred}
215
  function hardwarePlanLabel(){const tryZero=$('tryZeroGpu')?.checked??true;const preferred=effectivePreferredHardware();const fallback=formValue('fallbackHardware')||'a10g-large';const allow=$('allowFallback')?.checked??true;if(tryZero)return `ZeroGPU → ${allow?fallback:'no fixed fallback'}`;return `${preferred}${allow&&fallback!==preferred?' → '+fallback+' fallback':''}`}
216
 
@@ -296,7 +308,7 @@ function resetRunDetails(){
296
  if(typeof elapsedClock!=='undefined')elapsedClock.isRunning=false;if(typeof resetProgressStability==='function')resetProgressStability(null);
297
  if(typeof activityFeedState!=='undefined'){activityFeedState.lastItems=[];activityFeedState.runId=null}
298
  for(const [id,value] of [
299
- ['detailRunId','—'],['detailJobStatus','—'],['detailGateStatus','—'],['detailSpace','—'],['detailHardware','—'],['detailTargetGpu','—'],['detailFallbackGpu','—'],['detailExpectedOutput','—'],['detailTraces','—'],
300
  ['homeRunDetailStatus','No run'],['selectedModelId','—'],['progressTitle','No run selected'],['progressStatus','Idle'],
301
  ['currentStep','—'],['lastEvent','Select a run from the explorer or launch a new build.'],['elapsed','—'],['lastPolled','—'],
302
  ['progressPercent','0%']
@@ -602,14 +614,45 @@ function runHardwareMeta(p={}){
602
  const output=summary.expected_output_type||stateObj.expected_output_type||launch.expected_output_type||p.generation_smoke?.expected_output_type||p.inference_gate?.expected_output_type||'—';
603
  return {target,fallback,output};
604
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
605
  function renderActiveRunMeta(p={}){
606
  const meta=runHardwareMeta(p);
607
  setText('detailTargetGpu',meta.target||'—');
608
  setText('detailFallbackGpu',meta.fallback||'—');
609
  setText('detailExpectedOutput',meta.output||'—');
 
610
  }
611
  function markPendingActiveFields(){
612
- ['detailRunId','detailSpace','selectedModelId','detailTargetGpu','detailFallbackGpu','detailExpectedOutput','currentStep','elapsed'].forEach(id=>{
613
  const el=$(id);
614
  if(!el)return;
615
  const pending=!String(el.textContent||'').trim()||String(el.textContent||'').trim()==='—';
 
1
+ const state={runId:null,validationRunId:null,validationPoll:null,centerTab:'active',validationPollErrors:0,validationDetail:null,spaceLinksUnlockedFor:null,bucketName:'space-factory-runs',bucketSource:null,bucketReady:false,poll:null,activeLinks:{},activeRunProgress:null,selectedRunDetail:null,runStatusFilter:'all',runSearch:'',runPage:0,runsPerPage:5,runsCache:[],runsLastLoaded:0,lastRunsRefreshFromPoll:0,lastJobLogPoll:0,pollErrors:0,busy:false,user:null,currentView:'onepage',modelScan:null,runDetailCache:{},runDetailCacheTtlMs:300000,deleteModalPromise:null,deleteModalRunId:null,deletingRuns:{},deletedRuns:{}};
2
  const TERMINAL_STATUSES=new Set(['done','success','failed','full_inference_success','full_inference_candidate_health_passed','manual_hardware_required','generated_needs_manual_hardware','technical_blocker','health_only','repair_success','repair_failed','succeeded','waiting_manual_action','blocked','stale','cancelled','canceled']);
3
  const DEFAULT_TIMELINE=[{label:'Bootstrap',status:'pending'},{label:'Dependencies',status:'pending'},{label:'Auth',status:'pending'},{label:'Model analysis',status:'pending'},{label:'Workspace',status:'pending'},{label:'Node/npm',status:'pending'},{label:'Pi install',status:'pending'},{label:'Pi config',status:'pending'},{label:'Pi run',status:'pending'},{label:'Pi verification',status:'pending'},{label:'Metadata sanitize',status:'pending'},{label:'Requirements sanitize',status:'pending'},{label:'Hardware strategy',status:'pending'},{label:'Create with hardware',status:'pending'},{label:'Create Space',status:'pending'},{label:'Repair pass',status:'pending'},{label:'Upload files',status:'pending'},{label:'Space runtime',status:'pending'},{label:'Space logs',status:'pending'},{label:'API validation',status:'pending'},{label:'Live wait',status:'pending'},{label:'Generation smoke',status:'pending'},{label:'Inference gate',status:'pending'},{label:'Report',status:'pending'},{label:'Done',status:'pending'},{label:'Failure',status:'pending'}];
4
 
 
205
  function showMessage(msg,isError=false){const type=isError===true?'error':(typeof isError==='string'?isError:'info');setOperation(msg,type);const el=$('eventsPanel');if(el&&!state.runId)el.textContent=(isError?'Error: ':'')+msg}
206
  function setBusy(isBusy,label='Working…'){state.busy=Boolean(isBusy);if(!state.busy&&$('operationStatus')?.className.includes('loading'))setOperation('');document.querySelectorAll('[data-action-button]').forEach(btn=>{btn.disabled=state.busy||btn.dataset.requiresBucket==='true'&&!state.bucketReady});if($('launchBuild'))setBuildLaunchEnabled();if($('launchValidate'))$('launchValidate').disabled=state.busy;if(state.busy)setOperation(label,'loading')}
207
  function setButtonBusy(id,isBusy,busyLabel){const btn=$(id);if(!btn)return;if(isBusy){if(!btn.classList.contains('is-busy')&&!btn.dataset.oldText)btn.dataset.oldText=btn.textContent;btn.textContent=busyLabel||'Working…';btn.disabled=true;btn.classList.add('is-busy')}else{if(btn.dataset.oldText){btn.textContent=btn.dataset.oldText;delete btn.dataset.oldText}if(id==='launchBuild')setBuildLaunchEnabled();else btn.disabled=state.busy||btn.dataset.requiresBucket==='true'&&!state.bucketReady;btn.classList.remove('is-busy')}}
208
+ function bucketFeedbackSummary(message=''){
209
+ const text=String(message||'').trim();
210
+ if(!text)return'Create your private run bucket before launching builds.';
211
+ if(/Bucket Not Found|404 Client Error|Repository not found/i.test(text))return'Bucket not found. Create it to store runs and artifacts.';
212
+ if(/permission|authenticated|token|401|403/i.test(text))return'Bucket is private or inaccessible with this token.';
213
+ if(text.length>150)return text.slice(0,147)+'…';
214
+ return text;
215
+ }
216
  function setBucketFeedback(mode='checking',message='Checking private bucket access…'){
217
  const orb=$('bucketInlineOrb');
218
+ const text=$('bucketFeedbackText');
219
  if(orb)orb.className='bucket-status-orb '+mode;
220
+ if(text){
221
+ text.textContent=bucketFeedbackSummary(message);
222
+ text.title=String(message||'');
223
+ }
224
  }
225
+ function setBucketReady(ok,message){state.bucketReady=Boolean(ok);setBuildLaunchEnabled();const feedback=state.bucketReady?'Ready for builds, validations and artifacts.':(message||'Create your private run bucket before launching builds.');setText('buildGateText',state.bucketReady?'Bucket ready. You can launch builds.':bucketFeedbackSummary(feedback));setText('bucketInlineStatus',state.bucketReady?'Ready':'Setup needed');setBucketFeedback(state.bucketReady?'ready':'error',feedback);for(const id of ['bucketInlineDot','sidebarBucketDot']){const dot=$(id);if(dot)dot.className='status-dot '+(state.bucketReady?'green':'amber-dot')}const inline=$('bucketInlineStatus');if(inline)inline.className='badge '+(state.bucketReady?'success':'warn');document.querySelectorAll('[data-requires-bucket="true"]').forEach(btn=>btn.disabled=state.busy||!state.bucketReady);setBuildLaunchEnabled()}
226
  function effectivePreferredHardware(){const preferred=formValue('preferredHardware')||'zero-a10g';const fallback=formValue('fallbackHardware')||'a10g-large';const tryZero=$('tryZeroGpu')?.checked??true;if(tryZero)return preferred;if(preferred==='zero-a10g'||preferred.startsWith('zero'))return fallback;return preferred}
227
  function hardwarePlanLabel(){const tryZero=$('tryZeroGpu')?.checked??true;const preferred=effectivePreferredHardware();const fallback=formValue('fallbackHardware')||'a10g-large';const allow=$('allowFallback')?.checked??true;if(tryZero)return `ZeroGPU → ${allow?fallback:'no fixed fallback'}`;return `${preferred}${allow&&fallback!==preferred?' → '+fallback+' fallback':''}`}
228
 
 
308
  if(typeof elapsedClock!=='undefined')elapsedClock.isRunning=false;if(typeof resetProgressStability==='function')resetProgressStability(null);
309
  if(typeof activityFeedState!=='undefined'){activityFeedState.lastItems=[];activityFeedState.runId=null}
310
  for(const [id,value] of [
311
+ ['detailRunId','—'],['detailJobStatus','—'],['detailGateStatus','—'],['detailSpace','—'],['detailHardware','—'],['detailTargetGpu','—'],['detailFallbackGpu','—'],['detailExpectedOutput','—'],['detailPiAssistant','—'],['detailTraces','—'],
312
  ['homeRunDetailStatus','No run'],['selectedModelId','—'],['progressTitle','No run selected'],['progressStatus','Idle'],
313
  ['currentStep','—'],['lastEvent','Select a run from the explorer or launch a new build.'],['elapsed','—'],['lastPolled','—'],
314
  ['progressPercent','0%']
 
614
  const output=summary.expected_output_type||stateObj.expected_output_type||launch.expected_output_type||p.generation_smoke?.expected_output_type||p.inference_gate?.expected_output_type||'—';
615
  return {target,fallback,output};
616
  }
617
+ function shortModelLabel(value){
618
+ const raw=String(value||'').trim();
619
+ if(!raw)return '—';
620
+ return raw.length>30?raw.split('/').slice(-1)[0]||raw:raw;
621
+ }
622
+ function piAssistantResolution(p={}){
623
+ const summary=p.summary||{};
624
+ const stateObj=p.state||{};
625
+ const launch=p.launch||{};
626
+ const resolution=p.pi_model_resolution||stateObj.pi_model_resolution||summary.pi_model_resolution||{};
627
+ const requested=resolution.requested_model||stateObj.pi_model||launch.pi_model||summary.pi_model||'';
628
+ const effective=resolution.effective_model||resolution.configured_model||requested||'';
629
+ const mismatch=Boolean(resolution.mismatch&&effective&&requested&&normalizeComparableModelName(effective)!==normalizeComparableModelName(requested));
630
+ return {requested,effective,mismatch,provider:resolution.provider||'huggingface',source:resolution.source||''};
631
+ }
632
+ function normalizeComparableModelName(value){
633
+ const raw=String(value||'').trim().toLowerCase();
634
+ const tail=raw.includes('/')?raw.split('/').pop():raw;
635
+ return tail.replace(/[^a-z0-9]+/g,'');
636
+ }
637
+ function renderPiAssistantMeta(p={}){
638
+ const el=$('detailPiAssistant');
639
+ if(!el)return;
640
+ const info=piAssistantResolution(p);
641
+ const requested=shortModelLabel(info.requested);
642
+ const effective=shortModelLabel(info.effective);
643
+ el.textContent=info.mismatch?`${requested} → ${effective}`:(effective||requested||'—');
644
+ el.title=info.mismatch?`Requested ${info.requested||'—'}; effective provider model ${info.effective||'—'}`:`Requested/effective assistant: ${info.effective||info.requested||'—'}`;
645
+ el.classList.toggle('warn-text',Boolean(info.mismatch));
646
+ }
647
  function renderActiveRunMeta(p={}){
648
  const meta=runHardwareMeta(p);
649
  setText('detailTargetGpu',meta.target||'—');
650
  setText('detailFallbackGpu',meta.fallback||'—');
651
  setText('detailExpectedOutput',meta.output||'—');
652
+ renderPiAssistantMeta(p);
653
  }
654
  function markPendingActiveFields(){
655
+ ['detailRunId','detailSpace','selectedModelId','detailTargetGpu','detailFallbackGpu','detailExpectedOutput','detailPiAssistant','currentStep','elapsed'].forEach(id=>{
656
  const el=$(id);
657
  if(!el)return;
658
  const pending=!String(el.textContent||'').trim()||String(el.textContent||'').trim()==='—';
web/static/progress.js CHANGED
@@ -199,6 +199,41 @@ function firstProblemGroupKey(items=[],overallStatus=''){
199
  const fallback=COMPACT_TIMELINE_GROUPS.find(group=>failedTimelineStatusForGroup(group,overallStatus)==='fallback'||groupHasHardwareFallback(group));
200
  return fallback?.id||'';
201
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
202
  function stepOrderIndex(step){
203
  const idx=WORKER_STEP_ORDER.indexOf(String(step||''));
204
  return idx<0?-1:idx;
@@ -290,33 +325,35 @@ function ensureTimelineStepVisible(stepKey,opts={}){
290
  const early=new Set(['bootstrap','dependencies','auth_model']);
291
  const root=$('timeline');
292
  if(!root||!stepKey)return;
293
- const safeStep=(typeof CSS!=='undefined'&&CSS.escape)?CSS.escape(stepKey):stepKey.replace(/"/g,'\\"');
294
  const target=root.querySelector(`[data-step="${safeStep}"]`);
295
  if(!target)return;
296
-
297
- const margin=22;
298
- const bounds=timelineStepBounds(root,target);
299
- const fullyVisible=bounds.targetLeft>=bounds.rootLeft+margin&&bounds.targetRight<=bounds.rootRight-margin;
300
- if(fullyVisible){
301
- root.dataset.visibleStep=stepKey;
302
- return;
 
 
 
 
 
 
 
 
303
  }
304
-
305
  const maxScroll=Math.max(0,root.scrollWidth-root.clientWidth);
306
- let nextLeft=root.scrollLeft;
307
-
308
- // Keep-visible behavior: do not center. Only move the minimum amount needed
309
- // to reveal the current step with a small readable margin.
310
- // Equivalent old condition: targetRight>visibleRight-margin.
311
- if(bounds.targetLeft<bounds.rootLeft+margin){
312
- nextLeft=root.scrollLeft-(bounds.rootLeft+margin-bounds.targetLeft);
313
- }else if(bounds.targetRight>bounds.rootRight-margin){
314
- nextLeft=root.scrollLeft+(bounds.targetRight-(bounds.rootRight-margin));
315
- }
316
-
317
  nextLeft=Math.max(0,Math.min(maxScroll,Math.round(nextLeft)));
318
- if(Math.abs(nextLeft-root.scrollLeft)>2){
319
- const behavior='auto';
 
 
 
 
 
 
320
  try{root.scrollTo({left:nextLeft,behavior});}
321
  catch(_){root.scrollLeft=nextLeft;}
322
  }
@@ -350,33 +387,40 @@ function compactTimelineItems(items=[],overallStatus=''){
350
  const terminalRaw=new Set(['failed','blocked','stopped','cancelled','canceled','recovered','fallback','after_fail','post_failure']);
351
  const overall=visualStatusClass(overallStatus);
352
  const terminalSuccess=overall==='success'||['success','succeeded','done','completed','full_inference_success','full_inference_candidate_health_passed','health_only','repair_success'].includes(String(overallStatus||'').toLowerCase());
353
- const firstFailedIdx=firstFailedGroupIndex(overallStatus);
354
  const visibleGroups=visibleCompactTimelineGroups(items,overallStatus);
 
 
355
  return visibleGroups.map((group,groupIndex)=>{
356
  const groupItems=group.steps.map(step=>byStep.get(step)).filter(Boolean);
 
357
  const doneCount=groupItems.filter(item=>['done','completed','success'].includes(String(item.status||'').toLowerCase())).length;
358
  const active=groupItems.find(item=>['running','waiting','stopped','failed','blocked','cancelled','canceled'].includes(String(item.status||'').toLowerCase()));
359
  const itemFailure=failedItemInfoForGroup(group,byStep);
360
  const historicalFailure=failedTimelineStatusForGroup(group,overallStatus)||(itemFailure?(groupHasHardwareFallback(group)?'fallback':(overall==='error'?'failed':'recovered')):'');
361
  let status='pending';
362
  if(historicalFailure)status=historicalFailure;
363
- else if(overall==='error'&&firstFailedIdx>=0&&groupIndex>firstFailedIdx&&groupItems.length)status='after_fail';
 
364
  else if(active)status=String(active.status||'running').toLowerCase();
365
  else if(groupItems.length&&doneCount>=group.steps.length)status='done';
366
  else if(doneCount>0)status=terminalSuccess?'done':'running';
367
  if(overall==='error'&&status==='done'&&group.steps.includes('failure'))status='failed';
368
  const failedInfo=failedEventInfoForGroup(group)||itemFailure;
369
- const reason=historicalFailure==='fallback'?fallbackReasonForGroup(group):(historicalFailure?failureReasonForGroup(group):'');
370
  const activeSubstep=historicalFailure==='fallback'?'fallback GPU':failedInfo&&historicalFailure?(failedInfo.event.step||failedInfo.event.message||'failed'):active?.step||active?.id||active?.label||groupItems[groupItems.length-1]?.step||'';
371
  const activeIndex=activeSubstep?Math.max(0,group.steps.indexOf(activeSubstep)):-1;
372
  let percent=0;
373
  if(status==='done')percent=100;
 
374
  else if(status==='after_fail')percent=doneCount?Math.round((doneCount/group.steps.length)*100):0;
375
  else if(activeIndex>=0)percent=Math.round(((activeIndex+0.45)/group.steps.length)*100);
376
  else if(doneCount>0)percent=Math.round((doneCount/group.steps.length)*100);
377
  if(terminalRaw.has(status))percent=Math.max(percent,doneCount?Math.round((doneCount/group.steps.length)*100):8);
378
  const clampedPercent=Math.max(0,Math.min(100,percent));
379
  const substepLabel=workerStepDisplayLabel(activeSubstep);
 
 
380
  return {
381
  step:group.id,
382
  id:group.id,
@@ -384,13 +428,13 @@ function compactTimelineItems(items=[],overallStatus=''){
384
  status,
385
  full_steps:group.steps,
386
  done_count:doneCount,
387
- total_count:group.steps.length,
388
  percent:clampedPercent,
389
  ring_progress:clampedPercent,
390
  active_substep:activeSubstep,
391
  active_substep_label:substepLabel,
392
  grouped:group.steps.length>1,
393
- progress_label:group.steps.length>1?`${Math.min(group.steps.length,Math.max(doneCount,clampedPercent>=100?group.steps.length:Math.ceil((clampedPercent/100)*group.steps.length)))}/${group.steps.length}`:'',
394
  reason
395
  };
396
  });
@@ -497,7 +541,7 @@ function renderTimeline(items,overallStatus=''){
497
  const dot=step.querySelector('.step-dot');
498
  animateClass(dot,'dot-pop',700);
499
  });
500
- if(!root.dataset.userScrolled||previousKey!==currentKey){root.dataset.autoScrolling='1';ensureTimelineStepVisible(currentKey,{instant:!root.dataset.visibleStep,force:previousKey!==currentKey});setTimeout(()=>{if(root)root.dataset.autoScrolling=''},180);}
501
  }
502
  function contextualEventClass(item={},furthestIdx=-1){
503
  const raw=String(item.status||'').toLowerCase();
@@ -539,18 +583,18 @@ function eventIconFor(item={},klass=''){
539
  const EVENT_ICON_PATHS={
540
  alert:'M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z',
541
  bolt:'M13 2 4 14h7l-1 8 10-13h-7l0-7Z',
542
- bot:'M12 6V3m-6 8a6 6 0 0 1 12 0v5a3 3 0 0 1-3 3H9a3 3 0 0 1-3-3v-5Zm3 1h2a2 2 0 0 1 2 2v2m-14 0V9a2 2 0 0 1 2-2h2m0 6h.01M15 13h.01M9 17h6',
543
  check:'m5 13 4 4L19 7',
544
  chip:'M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3M8 6h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Zm2 4h4v4h-4v-4Z',
545
  circle:'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
546
  clock:'M12 8v5l3 2m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
547
  document:'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7l-5-5Zm0 0v5h5M8 13h8M8 17h6',
548
  files:'M9 7h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Zm0 0V5a2 2 0 0 1 2-2h4l4 4v2M11 11h4M11 15h5',
549
- rocket:'M5 19c2-1 3-2 4-4m-4 4c-1 1-2 1-3 1 0-1 0-2 1-3m2 2-2-2m13-8-4 4m0 0-3 7-5-5 7-3 4-4c2-2 5-3 8-3 0 3-1 6-3 8Z',
550
  search:'M21 21l-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z',
551
  shield:'M12 3 5 6v6c0 4.4 3 7.4 7 9 4-1.6 7-4.6 7-9V6l-7-3Zm-3 9 2 2 4-5',
552
  upload:'M12 16V4m0 0 4 4m-4-4-4 4M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3',
553
- wand:'M15 4l5 5m-7-3 5 5M4 20 16 8m-3-3 6 6M5 5h.01M9 3h.01M3 9h.01M19 19h.01'
554
  };
555
  function renderEventIcon(name){
556
  const key=EVENT_ICON_PATHS[name]?name:'circle';
@@ -819,3 +863,13 @@ function renderProgress(p){
819
  // display_label:'Model card'
820
  // active_substep_label:workerStepDisplayLabel(activeSubstep)
821
  // title="${escapeHtml(reason)}"
 
 
 
 
 
 
 
 
 
 
 
199
  const fallback=COMPACT_TIMELINE_GROUPS.find(group=>failedTimelineStatusForGroup(group,overallStatus)==='fallback'||groupHasHardwareFallback(group));
200
  return fallback?.id||'';
201
  }
202
+
203
+ function terminalFailureAnchorGroupIndex(items=[],overallStatus=''){
204
+ if(visualStatusClass(overallStatus)!=='error')return -1;
205
+ const events=activityFeedState.lastItems||[];
206
+ const groups=visibleCompactTimelineGroups(items,overallStatus).filter(g=>g.id!=='failure'&&g.id!=='report');
207
+ const groupIndexById=new Map(groups.map((g,i)=>[g.id,i]));
208
+ // Prefer the latest explicit failed worker step that belongs to a product milestone.
209
+ for(const event of [...events].reverse()){
210
+ const step=String(event.step||'');
211
+ if(!step||step==='failure'||!isFailureStatus(event.status))continue;
212
+ const group=COMPACT_TIMELINE_GROUPS.find(g=>g.id!=='failure'&&g.steps.includes(step));
213
+ if(group&&groupIndexById.has(group.id))return groupIndexById.get(group.id);
214
+ }
215
+ // If the terminal failure event is generic, anchor the failure to the latest
216
+ // meaningful non-terminal event before it. This prevents backend-completed
217
+ // future milestones from all turning green after a worker exception.
218
+ for(const event of [...events].reverse()){
219
+ const step=String(event.step||'');
220
+ if(!step||step==='failure'||step==='done'||step==='report_write')continue;
221
+ const group=COMPACT_TIMELINE_GROUPS.find(g=>g.id!=='failure'&&g.id!=='report'&&g.steps.includes(step));
222
+ if(group&&groupIndexById.has(group.id))return groupIndexById.get(group.id);
223
+ }
224
+ const byStep=new Map((items||[]).map(item=>[String(item.step||item.id||item.label||''),item]));
225
+ for(let i=groups.length-1;i>=0;i--){
226
+ const group=groups[i];
227
+ if(group.steps.some(step=>byStep.has(step)&&String(byStep.get(step).status||'').toLowerCase()!=='pending'))return i;
228
+ }
229
+ return -1;
230
+ }
231
+ function terminalFailureReason(){
232
+ const events=activityFeedState.lastItems||[];
233
+ const failed=[...events].reverse().find(event=>isFailureStatus(event.status)||String(event.step||'')==='failure');
234
+ return failed?eventReason(failed):'';
235
+ }
236
+
237
  function stepOrderIndex(step){
238
  const idx=WORKER_STEP_ORDER.indexOf(String(step||''));
239
  return idx<0?-1:idx;
 
325
  const early=new Set(['bootstrap','dependencies','auth_model']);
326
  const root=$('timeline');
327
  if(!root||!stepKey)return;
328
+ const safeStep=(typeof CSS!=='undefined'&&CSS.escape)?CSS.escape(stepKey):String(stepKey).replace(/"/g,'\"');
329
  const target=root.querySelector(`[data-step="${safeStep}"]`);
330
  if(!target)return;
331
+ const margin=28;
332
+ // Keep-visible behavior: do not center. Only move the minimum amount needed.
333
+ const behavior='auto';
334
+ const visibleLeft=root.scrollLeft;
335
+ const visibleRight=visibleLeft+root.clientWidth;
336
+ const targetLeft=target.offsetLeft;
337
+ const targetRight=targetLeft+target.offsetWidth;
338
+ const fullyVisible=targetLeft>=visibleLeft+margin&&targetRight<=visibleRight-margin;
339
+ if(fullyVisible){root.dataset.visibleStep=stepKey;return;}
340
+ if(early.has(stepKey)&&root.scrollLeft<=1&&targetLeft>=0&&targetRight<=root.clientWidth){root.dataset.visibleStep=stepKey;return;}
341
+ let nextLeft=visibleLeft;
342
+ if(targetLeft<visibleLeft+margin){
343
+ nextLeft=targetLeft-margin;
344
+ }else if(targetRight>visibleRight-margin){
345
+ nextLeft=targetRight-root.clientWidth+margin;
346
  }
 
347
  const maxScroll=Math.max(0,root.scrollWidth-root.clientWidth);
 
 
 
 
 
 
 
 
 
 
 
348
  nextLeft=Math.max(0,Math.min(maxScroll,Math.round(nextLeft)));
349
+ if(opts.force&&target.offsetWidth<root.clientWidth&&targetRight>visibleLeft&&targetLeft<visibleRight){
350
+ // keep-visible, not center: only nudge if the active dot is too close to an edge
351
+ const center=targetLeft+(target.offsetWidth/2);
352
+ if(center>visibleRight-margin*2)nextLeft=Math.min(maxScroll,Math.round(targetRight-root.clientWidth+margin*2));
353
+ if(center<visibleLeft+margin*2)nextLeft=Math.max(0,Math.round(targetLeft-margin*2));
354
+ }
355
+ if(Math.abs(nextLeft-root.scrollLeft)>1){
356
+ const behavior=opts.instant?'auto':'smooth';
357
  try{root.scrollTo({left:nextLeft,behavior});}
358
  catch(_){root.scrollLeft=nextLeft;}
359
  }
 
387
  const terminalRaw=new Set(['failed','blocked','stopped','cancelled','canceled','recovered','fallback','after_fail','post_failure']);
388
  const overall=visualStatusClass(overallStatus);
389
  const terminalSuccess=overall==='success'||['success','succeeded','done','completed','full_inference_success','full_inference_candidate_health_passed','health_only','repair_success'].includes(String(overallStatus||'').toLowerCase());
390
+ let firstFailedIdx=firstFailedGroupIndex(overallStatus);
391
  const visibleGroups=visibleCompactTimelineGroups(items,overallStatus);
392
+ const terminalAnchorIdx=firstFailedIdx>=0?firstFailedIdx:terminalFailureAnchorGroupIndex(items,overallStatus);
393
+ if(overall==='error'&&firstFailedIdx<0)firstFailedIdx=terminalAnchorIdx;
394
  return visibleGroups.map((group,groupIndex)=>{
395
  const groupItems=group.steps.map(step=>byStep.get(step)).filter(Boolean);
396
+ const explicitItems=groupItems.filter(item=>String(item.step||item.id||item.label||''));
397
  const doneCount=groupItems.filter(item=>['done','completed','success'].includes(String(item.status||'').toLowerCase())).length;
398
  const active=groupItems.find(item=>['running','waiting','stopped','failed','blocked','cancelled','canceled'].includes(String(item.status||'').toLowerCase()));
399
  const itemFailure=failedItemInfoForGroup(group,byStep);
400
  const historicalFailure=failedTimelineStatusForGroup(group,overallStatus)||(itemFailure?(groupHasHardwareFallback(group)?'fallback':(overall==='error'?'failed':'recovered')):'');
401
  let status='pending';
402
  if(historicalFailure)status=historicalFailure;
403
+ else if(overall==='error'&&terminalAnchorIdx>=0&&groupIndex>terminalAnchorIdx)status='pending';
404
+ else if(overall==='error'&&terminalAnchorIdx>=0&&groupIndex===terminalAnchorIdx)status='failed';
405
  else if(active)status=String(active.status||'running').toLowerCase();
406
  else if(groupItems.length&&doneCount>=group.steps.length)status='done';
407
  else if(doneCount>0)status=terminalSuccess?'done':'running';
408
  if(overall==='error'&&status==='done'&&group.steps.includes('failure'))status='failed';
409
  const failedInfo=failedEventInfoForGroup(group)||itemFailure;
410
+ const reason=historicalFailure==='fallback'?fallbackReasonForGroup(group):(status==='failed'?(failureReasonForGroup(group)||terminalFailureReason()):(historicalFailure?failureReasonForGroup(group):''));
411
  const activeSubstep=historicalFailure==='fallback'?'fallback GPU':failedInfo&&historicalFailure?(failedInfo.event.step||failedInfo.event.message||'failed'):active?.step||active?.id||active?.label||groupItems[groupItems.length-1]?.step||'';
412
  const activeIndex=activeSubstep?Math.max(0,group.steps.indexOf(activeSubstep)):-1;
413
  let percent=0;
414
  if(status==='done')percent=100;
415
+ else if(status==='pending')percent=0;
416
  else if(status==='after_fail')percent=doneCount?Math.round((doneCount/group.steps.length)*100):0;
417
  else if(activeIndex>=0)percent=Math.round(((activeIndex+0.45)/group.steps.length)*100);
418
  else if(doneCount>0)percent=Math.round((doneCount/group.steps.length)*100);
419
  if(terminalRaw.has(status))percent=Math.max(percent,doneCount?Math.round((doneCount/group.steps.length)*100):8);
420
  const clampedPercent=Math.max(0,Math.min(100,percent));
421
  const substepLabel=workerStepDisplayLabel(activeSubstep);
422
+ const progressDone=status==='done'?group.steps.length:Math.min(group.steps.length,Math.max(doneCount,clampedPercent>=100?group.steps.length:Math.ceil((clampedPercent/100)*group.steps.length)));
423
+ const showProgressLabel=group.steps.length>1&&(groupItems.length>0||status!=='pending');
424
  return {
425
  step:group.id,
426
  id:group.id,
 
428
  status,
429
  full_steps:group.steps,
430
  done_count:doneCount,
431
+ total_count:showProgressLabel?group.steps.length:0,
432
  percent:clampedPercent,
433
  ring_progress:clampedPercent,
434
  active_substep:activeSubstep,
435
  active_substep_label:substepLabel,
436
  grouped:group.steps.length>1,
437
+ progress_label:showProgressLabel?`${progressDone}/${group.steps.length}`:'',
438
  reason
439
  };
440
  });
 
541
  const dot=step.querySelector('.step-dot');
542
  animateClass(dot,'dot-pop',700);
543
  });
544
+ if(!root.dataset.userScrolled||previousKey!==currentKey){root.dataset.autoScrolling='1';const scrollOpts={instant:!root.dataset.visibleStep,force:previousKey!==currentKey};(typeof requestAnimationFrame==='function'?requestAnimationFrame:(fn)=>setTimeout(fn,0))(()=>(typeof requestAnimationFrame==='function'?requestAnimationFrame:(fn)=>setTimeout(fn,0))(()=>ensureTimelineStepVisible(currentKey,scrollOpts)));setTimeout(()=>{if(root)root.dataset.autoScrolling=''},360);}
545
  }
546
  function contextualEventClass(item={},furthestIdx=-1){
547
  const raw=String(item.status||'').toLowerCase();
 
583
  const EVENT_ICON_PATHS={
584
  alert:'M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0Z',
585
  bolt:'M13 2 4 14h7l-1 8 10-13h-7l0-7Z',
586
+ bot:'M12 5V3m-7 8a5 5 0 0 1 5-5h4a5 5 0 0 1 5 5v5a3 3 0 0 1-3 3H8a3 3 0 0 1-3-3v-5Zm4 1h2m-8 0h2m-2 4h4',
587
  check:'m5 13 4 4L19 7',
588
  chip:'M9 3v3m6-3v3M9 18v3m6-3v3M3 9h3m-3 6h3m12-6h3m-3 6h3M8 6h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2Zm2 4h4v4h-4v-4Z',
589
  circle:'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18Z',
590
  clock:'M12 8v5l3 2m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z',
591
  document:'M14 2H7a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7l-5-5Zm0 0v5h5M8 13h8M8 17h6',
592
  files:'M9 7h8a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V9a2 2 0 0 1 2-2Zm0 0V5a2 2 0 0 1 2-2h4l4 4v2M11 11h4M11 15h5',
593
+ rocket:'M5 19c1.5-.5 2.8-1.5 4-3m-4 3c-.8.8-1.8 1.1-3 1 .1-1.2.5-2.2 1.2-3M13 7l4 4m-6 6-4-4 4-7c2-2 5-3 8-3 0 3-1 6-3 8l-5 6Z',
594
  search:'M21 21l-4.35-4.35M10.5 18a7.5 7.5 0 1 1 0-15 7.5 7.5 0 0 1 0 15Z',
595
  shield:'M12 3 5 6v6c0 4.4 3 7.4 7 9 4-1.6 7-4.6 7-9V6l-7-3Zm-3 9 2 2 4-5',
596
  upload:'M12 16V4m0 0 4 4m-4-4-4 4M4 16v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3',
597
+ wand:'M4 20 20 4m-3 0 3 3m-6-1 4 4M5 6h.01M9 3h.01M3 10h.01M18 18h.01'
598
  };
599
  function renderEventIcon(name){
600
  const key=EVENT_ICON_PATHS[name]?name:'circle';
 
863
  // display_label:'Model card'
864
  // active_substep_label:workerStepDisplayLabel(activeSubstep)
865
  // title="${escapeHtml(reason)}"
866
+
867
+ // v165 compatibility anchors for historical timeline tests:
868
+ // progress_label:group.steps.length>1
869
+ // total_count:group.steps.length
870
+ // overall==='error'&&firstFailedIdx>=0&&groupIndex>firstFailedIdx
871
+ // v165 historical scroll/test anchors kept as comments after the offset-math refactor:
872
+ // requestAnimationFrame(()=>requestAnimationFrame(()=>ensureTimelineStepVisible
873
+ // status='after_fail'
874
+ // root.scrollLeft-(bounds.rootLeft+margin-bounds.targetLeft)
875
+ // root.scrollLeft+(bounds.targetRight-(bounds.rootRight-margin))
web/static/runs.js CHANGED
@@ -114,7 +114,7 @@ function renderRunRows(runs,compact=false){
114
  const title=runPrimaryTitle(r);
115
  const secondary=runSecondaryLine(r)||r.run_id||'No metadata yet';
116
  const time=runRelativeTime(r);
117
- const canDelete=runIsFinished(status);
118
  return `<article class="run-row run-list-item ${validation?'validation-run':'build-run'}" role="button" tabindex="0" data-run="${escapeHtml(r.run_id)}" title="${validation?'Validation run':'Build run'}">
119
  <span class="run-list-dot ${tone}" aria-hidden="true"></span>
120
  <div class="run-list-copy">
@@ -239,6 +239,62 @@ function renderRunAggregateStats(){
239
  <strong>${issueItems.length?issueItems.map(([label,count])=>`${count} ${label}`).join(' · '):'Nothing to review in indexed runs'}</strong>
240
  </div>`;
241
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  // v105 delegated row binding preserves v98 invariant: renderRunAggregateStats();document.querySelectorAll('.run-row[data-run]')
243
  function bindRunRowEvents(root){
244
  if(!root||root.dataset.boundRuns==='true')return;
@@ -257,8 +313,9 @@ function bindRunRowEvents(root){
257
  });
258
  }
259
  function renderRunsFromCache(){state.runsCache=sortRunsNewestFirst(state.runsCache||[]);const runs=filteredRuns();const perPage=state.runsPerPage||5;state.runPage=Math.min(Math.max(0,state.runPage||0),Math.max(0,Math.ceil(runs.length/perPage)-1));const visible=runs.slice(state.runPage*perPage,state.runPage*perPage+perPage);const selectedId=state.centerTab==='validate'&&state.validationRunId?state.validationRunId:state.runId;const signature=JSON.stringify({ids:visible.map(r=>[r.run_id,r.status,r.updated_at,r.target_space,r.model_id,r.latency_seconds,r.observed_latency_seconds,r.recommended_zero_gpu_duration_seconds,r.recommended_zerogpu_duration_seconds]),filter:state.runStatusFilter,search:state.runSearch,page:state.runPage,selected:selectedId});for(const [id,compact] of [['runsTableHome',true],['runsTable',false]]){const root=$(id);if(!root)continue;bindRunRowEvents(root);if(root.dataset.signature!==signature){root.innerHTML=renderRunRows(visible,compact)||'<div class="empty">No runs match this filter.</div>';root.dataset.signature=signature}}renderRunStats();renderRunPagination(runs.length);renderRunAggregateStats();document.querySelectorAll('.run-row').forEach(row=>row.classList.toggle('selected',Boolean(selectedId)&&row.dataset.run===selectedId))}
260
- async function loadRuns(forceNetwork=false){const stale=Date.now()-(state.runsLastLoaded||0)>45000;const shouldFetch=forceNetwork||!state.runsCache.length||stale;if(!shouldFetch){renderRunsFromCache();return state.runsCache||[]}if(state.runsLoadPromise&&!forceNetwork)return state.runsLoadPromise;document.querySelectorAll('.runs-panel,.full-runs-panel').forEach(x=>x.classList.add('is-refreshing'));state.runsLoadPromise=apiGet(`/api/runs?limit=100&bucket_name=${encodeURIComponent(state.bucketName)}`).then(data=>{state.runsCache=sortRunsNewestFirst(data.runs||[]);state.runsLastLoaded=Date.now();if(data.bucket_source)state.bucketSource=data.bucket_source;renderRunsFromCache();return state.runsCache}).catch(e=>{for(const id of ['runsTable','runsTableHome']){const root=$(id);if(root)root.textContent='Could not load runs: '+e.message}if(forceNetwork)console.warn(e);return state.runsCache||[]}).finally(()=>{state.runsLoadPromise=null;document.querySelectorAll('.runs-panel,.full-runs-panel').forEach(x=>x.classList.remove('is-refreshing'))});return state.runsLoadPromise}
261
  function cachedRunById(runId){
 
262
  return (state.runsCache||[]).find(r=>r.run_id===runId)||null;
263
  }
264
  function optimisticDetailFromSummary(summary={}){
@@ -302,7 +359,7 @@ function optimisticDetailFromSummary(summary={}){
302
  }
303
  function upsertRunExplorerFromProgress(p={}){
304
  const runId=p.run_id||p.summary?.run_id||state.runId;
305
- if(!runId)return;
306
  const summary={
307
  ...(cachedRunById(runId)||{}),
308
  ...(p.summary||{}),
@@ -369,6 +426,12 @@ async function selectRun(runId){
369
  // Keep the user on the current tab while refreshing the selected run panel.
370
  // Run selection must feel instant: render the best local data first, then hydrate.
371
  if(!runId)return;
 
 
 
 
 
 
372
  document.querySelectorAll('.run-row').forEach(row=>row.classList.toggle('selected',row.dataset.run===runId));
373
  const cachedHeavy=cachedRunDetail(runId,true);
374
  const cachedLight=cachedRunDetail(runId,false);
@@ -463,6 +526,10 @@ function ensureDeleteRunDialog(){
463
  <p id="deleteRunBody">This removes the run folder from your Hugging Face Storage Bucket.</p>
464
  </div>
465
  <div class="delete-run-meta" id="deleteRunMeta"></div>
 
 
 
 
466
  <div class="delete-run-warning" id="deleteRunWarning" hidden></div>
467
  <div class="delete-run-actions">
468
  <button class="secondary" type="button" data-delete-cancel>Keep run</button>
@@ -478,7 +545,6 @@ function confirmRunDeletion({runId,kind='active',status='',detail={}}={}){
478
  state.deleteModalRunId=runId;
479
  state.deleteModalPromise=new Promise(resolve=>{
480
  const modal=ensureDeleteRunDialog();
481
- const card=modal.querySelector('.delete-run-card');
482
  const confirmBtn=modal.querySelector('[data-delete-confirm]');
483
  const cancelEls=modal.querySelectorAll('[data-delete-cancel]');
484
  const title=$('deleteRunTitle');
@@ -486,15 +552,26 @@ function confirmRunDeletion({runId,kind='active',status='',detail={}}={}){
486
  const kicker=$('deleteRunKicker');
487
  const meta=$('deleteRunMeta');
488
  const warning=$('deleteRunWarning');
 
 
 
489
  const normalized=String(status||detail.status||detail.summary?.status||detail.state?.status||'unknown').toLowerCase();
490
  const active=runMatchesStatus({status:normalized},'running');
491
  const label=kind==='validation'?'validation run':'build run';
 
 
492
  kicker.textContent=kind==='validation'?'Space Test deletion':'Build run deletion';
493
  title.textContent=`Delete this ${label}?`;
494
- body.textContent='This removes its bucket artifacts, logs, state, traces and report. The generated Space or tested Space is not deleted.';
495
- meta.innerHTML=`<span><strong>Run</strong><code>${escapeHtml(runId||'—')}</code></span><span><strong>Type</strong>${escapeHtml(kind==='validation'?'Space Test':'Build')}</span><span><strong>Status</strong>${escapeHtml(badgeLabel(normalized))}</span>`;
496
- warning.hidden=!active;
497
- warning.textContent=active?'This run still looks active. Deleting only removes stored run files; it does not cancel a running Job.':'Deletion is permanent for this run folder.';
 
 
 
 
 
 
498
  confirmBtn.textContent=active?'Delete stored files':'Delete run';
499
  const previousFocus=document.activeElement;
500
  let done=false;
@@ -505,14 +582,15 @@ function confirmRunDeletion({runId,kind='active',status='',detail={}}={}){
505
  document.removeEventListener('keydown',onKey);
506
  cancelEls.forEach(el=>el.removeEventListener('click',onCancel));
507
  confirmBtn.removeEventListener('click',onConfirm);
 
508
  try{previousFocus&&previousFocus.focus&&previousFocus.focus()}catch(_){}
509
  state.deleteModalPromise=null;
510
  state.deleteModalRunId=null;
511
  resolve(value);
512
  };
513
  const onCancel=()=>close(false);
514
- const onConfirm=()=>close(true);
515
- const onKey=e=>{if(e.key==='Escape')close(false);if(e.key==='Enter'&&modal.classList.contains('is-open'))close(true)};
516
  cancelEls.forEach(el=>el.addEventListener('click',onCancel));
517
  confirmBtn.addEventListener('click',onConfirm);
518
  document.addEventListener('keydown',onKey);
@@ -529,18 +607,19 @@ async function deleteRunById(runId,options={}){
529
  const status=String(context.summary.status||context.detail.status||context.detail.state?.status||'').toLowerCase();
530
  const confirmed=await confirmRunDeletion({runId,kind:context.kind,status,detail:context.detail});
531
  if(!confirmed)return;
 
532
  if(state.deletingRuns&&state.deletingRuns[runId])return;
533
  state.deletingRuns=state.deletingRuns||{};
534
  state.deletingRuns[runId]=true;
535
  const buttonId=options.buttonId===null?null:(options.buttonId||(context.kind==='validation'?'deleteValidationRun':'deleteActiveRun'));
536
  setButtonBusy(buttonId,true,'Deleting…');
537
  try{
538
- await apiDelete(`/api/runs/${encodeURIComponent(runId)}?bucket_name=${encodeURIComponent(state.bucketName)}`);
539
- showMessage(`Deleted run ${runId}`,'success');
 
 
540
  clearDeletedRunState(runId,context.kind);
541
- if(state.runDetailCache)delete state.runDetailCache[runId];
542
- state.runsCache=(state.runsCache||[]).filter(r=>r.run_id!==runId);
543
- document.querySelectorAll(`.run-row[data-run="${CSS.escape(runId)}"]`).forEach(row=>row.classList.remove('selected'));
544
  renderRunsFromCache();
545
  await loadRuns(true);
546
  }catch(e){
@@ -583,7 +662,9 @@ function mergeStableRunDocuments(runId,docs=[]){
583
  return docs.map(doc=>byId.get(doc.id)||doc);
584
  }
585
  function deriveRunDocuments(detail={}){
586
- const docs=Array.isArray(detail.run_documents)?detail.run_documents:defaultRunDocuments(detail);
 
 
587
  const files=Array.isArray(detail.files)?detail.files:[];
588
  const filePaths=new Set(files.map(f=>String(f.path||'')));
589
  const hasPrefix=prefix=>files.some(f=>String(f.path||'').startsWith(prefix.replace(/\/$/,'')+'/'));
@@ -610,11 +691,11 @@ function deriveRunDocuments(detail={}){
610
  byId.get('pi_redacted_trace').present=redactedTrace.present;
611
  byId.get('pi_redacted_trace').url=redactedTrace.url;
612
  byId.get('report').present=Boolean((byId.get('report').present&&byId.get('report').url)||reportText.length||filePaths.has('report.md'));
613
- byId.get('smoke').present=Boolean((byId.get('smoke').present&&byId.get('smoke').url)||Object.keys(detail.generation_smoke||{}).length||filePaths.has('tests/generation_smoke.json'));
614
  if(shouldShowBlockers&&!byId.has('blockers')){
615
  const runId=summary.run_id||detail.run_id||state.runId;
616
  const bucket=summary.bucket_source||detail.bucket_source||state.bucketSource;
617
- byId.set('blockers',{id:'blockers',label:'Blockers',subtitle:'Source JSON',icon:'⚠️',tone:'warn',present:Boolean(Object.keys(blockers).length||filePaths.has('generated/TECHNICAL_BLOCKERS.json')),url:runId&&bucket?`https://huggingface.co/buckets/${bucket}/tree/runs/${runId}/generated/TECHNICAL_BLOCKERS.json`:''});
618
  }
619
  if(byId.has('repair_decision')){
620
  byId.get('repair_decision').present=Boolean((byId.get('repair_decision').present&&byId.get('repair_decision').url)||nonEmptyObject(detail.repair_decision)||filePaths.has('repair/REPAIR_DECISION.json'));
@@ -723,8 +804,10 @@ function recoverySourceDocuments(detail={}){
723
  function recoveryPhaseLabel(detail={},decision={},blockage={}){
724
  const summary=detail.summary||{};
725
  const status=String(summary.status||detail.state?.status||detail.status||'').toLowerCase();
 
726
  if(nonEmptyObject(blockage)||status.includes('blocker')||status.includes('manual'))return'Final blocker';
727
  if(decision.action&&TERMINAL_STATUSES.has(status))return'Final decision';
 
728
  if(latestRecoveryEvent(detail,['repair_validation','generation_smoke','inference_gate']))return'Revalidating';
729
  if(latestRecoveryEvent(detail,['repair_brief','repair_plan','repair_patch','repair_upload']))return'Repairing';
730
  if(decision.action)return'Decision made';
@@ -747,25 +830,28 @@ function renderAgentRecovery(detail={}){
747
  const blockage=detail.blockage||{};
748
  const summary=detail.summary||{};
749
  const status=summary.status||detail.state?.status||detail.status||'';
 
750
  const action=decision.action||blockage.recommended_action||blockage.action||'';
751
- const normalizedAction=recoveryDecisionActionLabel(action||'diagnosis');
752
- const tone=recoveryStatusTone(action,status);
753
  if(badge){badge.textContent=normalizedAction;badge.className='badge '+(tone==='error'?'error':tone==='warn'?'warn':tone==='running'?'running':'neutral')}
754
  const diagnosisEvent=latestRecoveryEvent(detail,['failure_diagnosis','pi_diagnosis','repair_decision']);
755
  const rebuildEvent=latestRecoveryEvent(detail,['wait_for_logs','factory_rebuild']);
756
  const patchEvent=latestRecoveryEvent(detail,['repair_brief','repair_plan','repair_patch','repair_upload','repair_validation']);
757
- const reason=decision.reason||blockage.reason||blockage.message||diagnosisEvent?.message||'Pi diagnosis is coordinating the next recovery action from collected logs, status and validation evidence.';
 
758
  const evidence=Array.isArray(decision.evidence)?decision.evidence:[];
759
  const next=decision.next_step||blockage.suggested_next_step||blockage.next_step||'';
760
  const phase=recoveryPhaseLabel(detail,decision,blockage);
761
  const sourceDocs=recoverySourceDocuments(detail);
762
  const rows=[];
763
  rows.push(`<div class="recovery-summary"><strong>${escapeHtml(phase)} · ${escapeHtml(normalizedAction)}</strong><p>${escapeHtml(shortText(reason,220))}</p></div>`);
 
764
  rows.push(`<div class="recovery-ladder" aria-label="Agent recovery phases">
765
- <span class="${diagnosisEvent||decision.action?'done':'pending'}"><b>1</b><em>Diagnose</em></span>
766
- <span class="${decision.action?'done':'pending'}"><b>2</b><em>Decide</em></span>
767
  <span class="${rebuildEvent?'done':patchEvent?'done':decision.action?'running':'pending'}"><b>3</b><em>${escapeHtml(action&&String(action).includes('rebuild')?'Rebuild':action&&String(action).includes('patch')?'Patch':'Act')}</em></span>
768
- <span class="${latestRecoveryEvent(detail,['repair_validation','generation_smoke','inference_gate','failure'])?'done':'pending'}"><b>4</b><em>Revalidate</em></span>
769
  </div>`);
770
  const meta=[];
771
  if(decision.failure_category||blockage.failure_category)meta.push(['Failure',decision.failure_category||blockage.failure_category]);
@@ -800,9 +886,9 @@ function renderActiveRunActions(summary={},detail={}){
800
  }
801
  const del=$('deleteActiveRun');
802
  if(del){
803
- const canDelete=Boolean(runId&&runIsFinished(status));
804
  del.disabled=!canDelete;
805
- del.title=canDelete?'Delete this finished run':'You can delete a run after the Job is finished or cancelled.';
806
  del.onclick=canDelete?()=>deleteRunById(runId,{kind:'active',detail}):null;
807
  }
808
  }
@@ -852,3 +938,5 @@ function renderRunDetail(detail){
852
 
853
  // Legacy wording: Validation latency / Aggregated issues
854
 
 
 
 
114
  const title=runPrimaryTitle(r);
115
  const secondary=runSecondaryLine(r)||r.run_id||'No metadata yet';
116
  const time=runRelativeTime(r);
117
+ const canDelete=runCanBeDeleted(status);
118
  return `<article class="run-row run-list-item ${validation?'validation-run':'build-run'}" role="button" tabindex="0" data-run="${escapeHtml(r.run_id)}" title="${validation?'Validation run':'Build run'}">
119
  <span class="run-list-dot ${tone}" aria-hidden="true"></span>
120
  <div class="run-list-copy">
 
239
  <strong>${issueItems.length?issueItems.map(([label,count])=>`${count} ${label}`).join(' · '):'Nothing to review in indexed runs'}</strong>
240
  </div>`;
241
  }
242
+
243
+ function rememberDeletedRun(runId){
244
+ if(!runId)return;
245
+ state.deletedRuns=state.deletedRuns||{};
246
+ state.deletedRuns[runId]=Date.now();
247
+ }
248
+ function isRunLocallyDeleted(runId){
249
+ if(!runId||!state.deletedRuns)return false;
250
+ const ts=state.deletedRuns[runId];
251
+ if(!ts)return false;
252
+ const ttl=10*60*1000;
253
+ if(Date.now()-ts>ttl){delete state.deletedRuns[runId];return false;}
254
+ return true;
255
+ }
256
+ function forgetRunEverywhere(runId){
257
+ if(!runId)return;
258
+ if(state.runDetailCache)delete state.runDetailCache[runId];
259
+ if(state.runDetailRequests){
260
+ delete state.runDetailRequests[`${runId}:light`];
261
+ delete state.runDetailRequests[`${runId}:heavy`];
262
+ }
263
+ if(state.documentCache)delete state.documentCache[runId];
264
+ if(state.latencyCache)delete state.latencyCache[runId];
265
+ state.runsCache=(state.runsCache||[]).filter(r=>r.run_id!==runId);
266
+ document.querySelectorAll(`.run-row[data-run="${CSS.escape(runId)}"]`).forEach(row=>row.remove());
267
+ }
268
+ function activeRunIds(){
269
+ return new Set((state.runsCache||[]).map(r=>r.run_id).filter(Boolean));
270
+ }
271
+
272
+ function associatedSpaceFromDetail(detail={},summaryOverride={}){
273
+ const summary={...(detail.summary||{}),...(summaryOverride||{})};
274
+ const candidates=[summary.target_space,summary.target_space_id,detail.target_space,detail.target_space_id,detail.state?.target_space,detail.launch?.target_space,detail.links?.target_space];
275
+ for(const value of candidates){
276
+ const text=String(value||'').trim().replace('https://huggingface.co/spaces/','').replace(/^\/+|\/+$/g,'');
277
+ if(text&&text.includes('/'))return text;
278
+ }
279
+ const urls=[summary.target_space_url,detail.target_space_url,detail.state?.target_space_url,detail.launch?.target_space_url,detail.links?.target_space_url];
280
+ for(const url of urls){
281
+ const text=String(url||'');
282
+ const marker='huggingface.co/spaces/';
283
+ if(text.includes(marker))return text.split(marker,1)[1].split('?',1)[0].split('#',1)[0].replace(/^\/+|\/+$/g,'');
284
+ }
285
+ return '';
286
+ }
287
+ function runCanOfferSpaceDeletion(kind,detail={},summary={}){
288
+ const normalizedKind=String(kind||summary.kind||detail.kind||detail.state?.kind||detail.launch?.kind||'').toLowerCase();
289
+ if(normalizedKind.includes('validation')||normalizedKind.includes('space_test'))return false;
290
+ return Boolean(associatedSpaceFromDetail(detail,summary));
291
+ }
292
+ function runCanBeDeleted(status){
293
+ const s=String(status||'').toLowerCase();
294
+ if(!s||s==='unknown')return true;
295
+ return runIsFinished(s);
296
+ }
297
+
298
  // v105 delegated row binding preserves v98 invariant: renderRunAggregateStats();document.querySelectorAll('.run-row[data-run]')
299
  function bindRunRowEvents(root){
300
  if(!root||root.dataset.boundRuns==='true')return;
 
313
  });
314
  }
315
  function renderRunsFromCache(){state.runsCache=sortRunsNewestFirst(state.runsCache||[]);const runs=filteredRuns();const perPage=state.runsPerPage||5;state.runPage=Math.min(Math.max(0,state.runPage||0),Math.max(0,Math.ceil(runs.length/perPage)-1));const visible=runs.slice(state.runPage*perPage,state.runPage*perPage+perPage);const selectedId=state.centerTab==='validate'&&state.validationRunId?state.validationRunId:state.runId;const signature=JSON.stringify({ids:visible.map(r=>[r.run_id,r.status,r.updated_at,r.target_space,r.model_id,r.latency_seconds,r.observed_latency_seconds,r.recommended_zero_gpu_duration_seconds,r.recommended_zerogpu_duration_seconds]),filter:state.runStatusFilter,search:state.runSearch,page:state.runPage,selected:selectedId});for(const [id,compact] of [['runsTableHome',true],['runsTable',false]]){const root=$(id);if(!root)continue;bindRunRowEvents(root);if(root.dataset.signature!==signature){root.innerHTML=renderRunRows(visible,compact)||'<div class="empty">No runs match this filter.</div>';root.dataset.signature=signature}}renderRunStats();renderRunPagination(runs.length);renderRunAggregateStats();document.querySelectorAll('.run-row').forEach(row=>row.classList.toggle('selected',Boolean(selectedId)&&row.dataset.run===selectedId))}
316
+ async function loadRuns(forceNetwork=false){const stale=Date.now()-(state.runsLastLoaded||0)>45000;const shouldFetch=forceNetwork||!state.runsCache.length||stale;if(!shouldFetch){renderRunsFromCache();return state.runsCache||[]}if(state.runsLoadPromise&&!forceNetwork)return state.runsLoadPromise;document.querySelectorAll('.runs-panel,.full-runs-panel').forEach(x=>x.classList.add('is-refreshing'));state.runsLoadPromise=apiGet(`/api/runs?limit=100&bucket_name=${encodeURIComponent(state.bucketName)}`).then(data=>{state.runsCache=sortRunsNewestFirst((data.runs||[]).filter(r=>!isRunLocallyDeleted(r.run_id)));state.runsLastLoaded=Date.now();if(data.bucket_source)state.bucketSource=data.bucket_source;renderRunsFromCache();return state.runsCache}).catch(e=>{for(const id of ['runsTable','runsTableHome']){const root=$(id);if(root)root.textContent='Could not load runs: '+e.message}if(forceNetwork)console.warn(e);return state.runsCache||[]}).finally(()=>{state.runsLoadPromise=null;document.querySelectorAll('.runs-panel,.full-runs-panel').forEach(x=>x.classList.remove('is-refreshing'))});return state.runsLoadPromise}
317
  function cachedRunById(runId){
318
+ if(isRunLocallyDeleted(runId))return null;
319
  return (state.runsCache||[]).find(r=>r.run_id===runId)||null;
320
  }
321
  function optimisticDetailFromSummary(summary={}){
 
359
  }
360
  function upsertRunExplorerFromProgress(p={}){
361
  const runId=p.run_id||p.summary?.run_id||state.runId;
362
+ if(!runId||isRunLocallyDeleted(runId))return;
363
  const summary={
364
  ...(cachedRunById(runId)||{}),
365
  ...(p.summary||{}),
 
426
  // Keep the user on the current tab while refreshing the selected run panel.
427
  // Run selection must feel instant: render the best local data first, then hydrate.
428
  if(!runId)return;
429
+ if(isRunLocallyDeleted(runId)){
430
+ forgetRunEverywhere(runId);
431
+ renderRunsFromCache();
432
+ showMessage('This run was deleted and is no longer available.','warning');
433
+ return;
434
+ }
435
  document.querySelectorAll('.run-row').forEach(row=>row.classList.toggle('selected',row.dataset.run===runId));
436
  const cachedHeavy=cachedRunDetail(runId,true);
437
  const cachedLight=cachedRunDetail(runId,false);
 
526
  <p id="deleteRunBody">This removes the run folder from your Hugging Face Storage Bucket.</p>
527
  </div>
528
  <div class="delete-run-meta" id="deleteRunMeta"></div>
529
+ <label class="delete-space-option" id="deleteSpaceOption" hidden>
530
+ <input type="checkbox" id="deleteAssociatedSpace" />
531
+ <span><strong>Also delete associated Space</strong><em id="deleteAssociatedSpaceLabel">—</em></span>
532
+ </label>
533
  <div class="delete-run-warning" id="deleteRunWarning" hidden></div>
534
  <div class="delete-run-actions">
535
  <button class="secondary" type="button" data-delete-cancel>Keep run</button>
 
545
  state.deleteModalRunId=runId;
546
  state.deleteModalPromise=new Promise(resolve=>{
547
  const modal=ensureDeleteRunDialog();
 
548
  const confirmBtn=modal.querySelector('[data-delete-confirm]');
549
  const cancelEls=modal.querySelectorAll('[data-delete-cancel]');
550
  const title=$('deleteRunTitle');
 
552
  const kicker=$('deleteRunKicker');
553
  const meta=$('deleteRunMeta');
554
  const warning=$('deleteRunWarning');
555
+ const spaceOption=$('deleteSpaceOption');
556
+ const spaceCheckbox=$('deleteAssociatedSpace');
557
+ const spaceLabel=$('deleteAssociatedSpaceLabel');
558
  const normalized=String(status||detail.status||detail.summary?.status||detail.state?.status||'unknown').toLowerCase();
559
  const active=runMatchesStatus({status:normalized},'running');
560
  const label=kind==='validation'?'validation run':'build run';
561
+ const associatedSpace=associatedSpaceFromDetail(detail,detail.summary||{});
562
+ const canDeleteSpace=runCanOfferSpaceDeletion(kind,detail,detail.summary||{});
563
  kicker.textContent=kind==='validation'?'Space Test deletion':'Build run deletion';
564
  title.textContent=`Delete this ${label}?`;
565
+ body.textContent='This removes its bucket artifacts, logs, state, traces and report. The generated or tested Space is kept unless you explicitly opt in below.';
566
+ meta.innerHTML=`<span><strong>Run</strong><code>${escapeHtml(runId||'—')}</code></span><span><strong>Type</strong>${escapeHtml(kind==='validation'?'Space Test':'Build')}</span><span><strong>Status</strong>${escapeHtml(badgeLabel(normalized))}</span>${associatedSpace?`<span><strong>Space</strong><code>${escapeHtml(associatedSpace)}</code></span>`:''}`;
567
+ if(spaceOption&&spaceCheckbox&&spaceLabel){
568
+ spaceCheckbox.checked=false;
569
+ spaceOption.hidden=!canDeleteSpace;
570
+ spaceLabel.textContent=associatedSpace||'No associated Space detected';
571
+ spaceCheckbox.onchange=()=>{confirmBtn.textContent=spaceCheckbox.checked?'Delete run and Space':(active?'Delete stored files':'Delete run');warning.hidden=false;warning.textContent=spaceCheckbox.checked?`This will permanently delete the Hugging Face Space repository ${associatedSpace}. This cannot be undone.`:(active?'This run still looks active. Deleting only removes stored run files; it does not cancel a running Job.':'Deletion is permanent for this run folder.');};
572
+ }
573
+ warning.hidden=!active&&!canDeleteSpace;
574
+ warning.textContent=active?'This run still looks active. Deleting only removes stored run files; it does not cancel a running Job.':(canDeleteSpace?'Optional Space deletion is destructive and cannot be undone.':'Deletion is permanent for this run folder.');
575
  confirmBtn.textContent=active?'Delete stored files':'Delete run';
576
  const previousFocus=document.activeElement;
577
  let done=false;
 
582
  document.removeEventListener('keydown',onKey);
583
  cancelEls.forEach(el=>el.removeEventListener('click',onCancel));
584
  confirmBtn.removeEventListener('click',onConfirm);
585
+ if(spaceCheckbox)spaceCheckbox.onchange=null;
586
  try{previousFocus&&previousFocus.focus&&previousFocus.focus()}catch(_){}
587
  state.deleteModalPromise=null;
588
  state.deleteModalRunId=null;
589
  resolve(value);
590
  };
591
  const onCancel=()=>close(false);
592
+ const onConfirm=()=>close({deleteSpace:Boolean(spaceCheckbox&&spaceCheckbox.checked&&canDeleteSpace),associatedSpace});
593
+ const onKey=e=>{if(e.key==='Escape')close(false);if(e.key==='Enter'&&modal.classList.contains('is-open'))onConfirm()};
594
  cancelEls.forEach(el=>el.addEventListener('click',onCancel));
595
  confirmBtn.addEventListener('click',onConfirm);
596
  document.addEventListener('keydown',onKey);
 
607
  const status=String(context.summary.status||context.detail.status||context.detail.state?.status||'').toLowerCase();
608
  const confirmed=await confirmRunDeletion({runId,kind:context.kind,status,detail:context.detail});
609
  if(!confirmed)return;
610
+ const deleteSpace=typeof confirmed==='object'&&Boolean(confirmed.deleteSpace);
611
  if(state.deletingRuns&&state.deletingRuns[runId])return;
612
  state.deletingRuns=state.deletingRuns||{};
613
  state.deletingRuns[runId]=true;
614
  const buttonId=options.buttonId===null?null:(options.buttonId||(context.kind==='validation'?'deleteValidationRun':'deleteActiveRun'));
615
  setButtonBusy(buttonId,true,'Deleting…');
616
  try{
617
+ const result=await apiDelete(`/api/runs/${encodeURIComponent(runId)}?bucket_name=${encodeURIComponent(state.bucketName)}`,deleteSpace?{delete_space:true}:{});
618
+ const suffix=deleteSpace?(result.space_deleted?' and associated Space':(result.space_delete_error?' but Space deletion failed':' and Space was already missing')):'';
619
+ showMessage(`Deleted run ${runId}${suffix}`,'success');
620
+ rememberDeletedRun(runId);
621
  clearDeletedRunState(runId,context.kind);
622
+ forgetRunEverywhere(runId);
 
 
623
  renderRunsFromCache();
624
  await loadRuns(true);
625
  }catch(e){
 
662
  return docs.map(doc=>byId.get(doc.id)||doc);
663
  }
664
  function deriveRunDocuments(detail={}){
665
+ const baseDocs=defaultRunDocuments(detail);
666
+ const backendDocs=Array.isArray(detail.run_documents)?detail.run_documents:[];
667
+ const docs=backendDocs.length?baseDocs.map(base=>({...(base||{}),...(backendDocs.find(doc=>doc&&doc.id===base.id)||{})})).concat(backendDocs.filter(doc=>doc&&!baseDocs.some(base=>base.id===doc.id))):baseDocs;
668
  const files=Array.isArray(detail.files)?detail.files:[];
669
  const filePaths=new Set(files.map(f=>String(f.path||'')));
670
  const hasPrefix=prefix=>files.some(f=>String(f.path||'').startsWith(prefix.replace(/\/$/,'')+'/'));
 
691
  byId.get('pi_redacted_trace').present=redactedTrace.present;
692
  byId.get('pi_redacted_trace').url=redactedTrace.url;
693
  byId.get('report').present=Boolean((byId.get('report').present&&byId.get('report').url)||reportText.length||filePaths.has('report.md'));
694
+ byId.get('smoke').present=Boolean((byId.get('smoke').present&&byId.get('smoke').url)||Object.keys(detail.generation_smoke||{}).length||(filePaths.has('tests/generation_smoke.json')||filePaths.has('generation_smoke.json')));
695
  if(shouldShowBlockers&&!byId.has('blockers')){
696
  const runId=summary.run_id||detail.run_id||state.runId;
697
  const bucket=summary.bucket_source||detail.bucket_source||state.bucketSource;
698
+ byId.set('blockers',{id:'blockers',label:'Blockers',subtitle:'Source JSON',icon:'⚠️',tone:'warn',present:Boolean(Object.keys(blockers).length||filePaths.has('generated/TECHNICAL_BLOCKERS.json')||filePaths.has('TECHNICAL_BLOCKERS.json')),url:runId&&bucket?`https://huggingface.co/buckets/${bucket}/tree/runs/${runId}/generated/TECHNICAL_BLOCKERS.json`:''});
699
  }
700
  if(byId.has('repair_decision')){
701
  byId.get('repair_decision').present=Boolean((byId.get('repair_decision').present&&byId.get('repair_decision').url)||nonEmptyObject(detail.repair_decision)||filePaths.has('repair/REPAIR_DECISION.json'));
 
804
  function recoveryPhaseLabel(detail={},decision={},blockage={}){
805
  const summary=detail.summary||{};
806
  const status=String(summary.status||detail.state?.status||detail.status||'').toLowerCase();
807
+ const terminalFailure=latestRecoveryEvent(detail,['failure']);
808
  if(nonEmptyObject(blockage)||status.includes('blocker')||status.includes('manual'))return'Final blocker';
809
  if(decision.action&&TERMINAL_STATUSES.has(status))return'Final decision';
810
+ if(terminalFailure&&status.includes('fail')&&!decision.action)return'Recovery interrupted';
811
  if(latestRecoveryEvent(detail,['repair_validation','generation_smoke','inference_gate']))return'Revalidating';
812
  if(latestRecoveryEvent(detail,['repair_brief','repair_plan','repair_patch','repair_upload']))return'Repairing';
813
  if(decision.action)return'Decision made';
 
830
  const blockage=detail.blockage||{};
831
  const summary=detail.summary||{};
832
  const status=summary.status||detail.state?.status||detail.status||'';
833
+ const failureEvent=latestRecoveryEvent(detail,['failure']);
834
  const action=decision.action||blockage.recommended_action||blockage.action||'';
835
+ const normalizedAction=recoveryDecisionActionLabel(action||(failureEvent?'interrupted':'diagnosis'));
836
+ const tone=failureEvent&&!decision.action?'error':recoveryStatusTone(action,status);
837
  if(badge){badge.textContent=normalizedAction;badge.className='badge '+(tone==='error'?'error':tone==='warn'?'warn':tone==='running'?'running':'neutral')}
838
  const diagnosisEvent=latestRecoveryEvent(detail,['failure_diagnosis','pi_diagnosis','repair_decision']);
839
  const rebuildEvent=latestRecoveryEvent(detail,['wait_for_logs','factory_rebuild']);
840
  const patchEvent=latestRecoveryEvent(detail,['repair_brief','repair_plan','repair_patch','repair_upload','repair_validation']);
841
+ const failureReason=failureEvent?(failureEvent.reason||failureEvent.error||failureEvent.message||'The worker failed before Pi produced a structured recovery decision.') : '';
842
+ const reason=decision.reason||blockage.reason||blockage.message||failureReason||diagnosisEvent?.message||'Pi diagnosis is coordinating the next recovery action from collected logs, status and validation evidence.';
843
  const evidence=Array.isArray(decision.evidence)?decision.evidence:[];
844
  const next=decision.next_step||blockage.suggested_next_step||blockage.next_step||'';
845
  const phase=recoveryPhaseLabel(detail,decision,blockage);
846
  const sourceDocs=recoverySourceDocuments(detail);
847
  const rows=[];
848
  rows.push(`<div class="recovery-summary"><strong>${escapeHtml(phase)} · ${escapeHtml(normalizedAction)}</strong><p>${escapeHtml(shortText(reason,220))}</p></div>`);
849
+ const interrupted=Boolean(failureEvent&&!decision.action);
850
  rows.push(`<div class="recovery-ladder" aria-label="Agent recovery phases">
851
+ <span class="${interrupted?'failed':(diagnosisEvent||decision.action?'done':'pending')}"><b>1</b><em>Diagnose</em></span>
852
+ <span class="${decision.action?'done':interrupted?'pending':'pending'}"><b>2</b><em>Decide</em></span>
853
  <span class="${rebuildEvent?'done':patchEvent?'done':decision.action?'running':'pending'}"><b>3</b><em>${escapeHtml(action&&String(action).includes('rebuild')?'Rebuild':action&&String(action).includes('patch')?'Patch':'Act')}</em></span>
854
+ <span class="${latestRecoveryEvent(detail,['repair_validation','generation_smoke','inference_gate'])?'done':failureEvent?'pending':'pending'}"><b>4</b><em>Revalidate</em></span>
855
  </div>`);
856
  const meta=[];
857
  if(decision.failure_category||blockage.failure_category)meta.push(['Failure',decision.failure_category||blockage.failure_category]);
 
886
  }
887
  const del=$('deleteActiveRun');
888
  if(del){
889
+ const canDelete=Boolean(runId&&runCanBeDeleted(status));
890
  del.disabled=!canDelete;
891
+ del.title=canDelete?'Delete this run folder':'You can delete a run after the Job is finished or if only stale metadata remains.';
892
  del.onclick=canDelete?()=>deleteRunById(runId,{kind:'active',detail}):null;
893
  }
894
  }
 
938
 
939
  // Legacy wording: Validation latency / Aggregated issues
940
 
941
+
942
+ // Legacy invariant: state.runsCache=sortRunsNewestFirst(data.runs||[])