diff --git a/packages/app/src/page-refresh.ts b/packages/app/src/page-refresh.ts new file mode 100644 index 0000000..7f0e7c0 --- /dev/null +++ b/packages/app/src/page-refresh.ts @@ -0,0 +1,37 @@ +/** Shared browser orchestration. Completion-based timers prevent overlapping polls. */ +export const pageRefreshBrowserSource = ` +function pageRefresh(load,{delay=()=>3000,onError=()=>{}}={}){ + let timer,busy=false,stopped=false,failures=0; + const clear=()=>{clearTimeout(timer);timer=undefined}; + const schedule=ms=>{clear();if(!stopped&&!document.hidden&&ms!==null)timer=setTimeout(refresh,ms)}; + async function refresh(){ + if(stopped||document.hidden||busy)return; + clear();busy=true; + let next; + try{await load();failures=0;next=delay()} + catch(error){failures++;next=Math.min(30000,5000*2**(failures-1));onError(error)} + finally{busy=false;schedule(next)} + } + const resume=()=>{clear();if(!document.hidden)refresh()}; + const hide=()=>{stopped=true;clear()}; + const show=()=>{stopped=false;resume()}; + document.addEventListener('visibilitychange',resume); + window.addEventListener('focus',resume); + window.addEventListener('pagehide',hide); + window.addEventListener('pageshow',show); + refresh(); + return {refresh}; +} +// Avoid replacing unchanged content; retain disclosure and keyboard position on updates. +function replacePageContent(root,html,afterReplace=()=>{}){ + const open=[...root.querySelectorAll('details')].map(x=>x.open); + const focused=root.contains(document.activeElement)?document.activeElement:null; + const focusId=focused?.id,focusHref=focused?.getAttribute('href'),focusPage=focused?.dataset.runPage; + const summaryIndex=[...root.querySelectorAll('details>summary')].indexOf(focused); + root.innerHTML=html; + root.querySelectorAll('details').forEach((x,i)=>x.open=Boolean(open[i])); + afterReplace(); + const target=focusPage?[...root.querySelectorAll('[data-run-page]')].find(x=>x.dataset.runPage===focusPage):summaryIndex>=0?root.querySelectorAll('details>summary')[summaryIndex]:focusId?document.getElementById(focusId):focusHref?[...root.querySelectorAll('a')].find(x=>x.getAttribute('href')===focusHref):null; + target?.focus({preventScroll:true}); +} +`; diff --git a/packages/app/src/ui.ts b/packages/app/src/ui.ts index f8da0b7..938db28 100644 --- a/packages/app/src/ui.ts +++ b/packages/app/src/ui.ts @@ -1,6 +1,7 @@ /** Framework-light pages; Tailwind is compiled locally into public/styles.css. */ import { triggerContextCatalog } from "./trigger-context"; import { ansiToHtmlBrowserSource } from "./ansi"; +import { pageRefreshBrowserSource } from "./page-refresh"; import { tracePresentationBrowserSource } from "./trace-presentation"; type Viewer = { email: string } | null; const escapeHtml = (value: string) => value.replace(/[&<>"']/g, (char) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[char]!); @@ -74,6 +75,7 @@ const card = "rounded-2xl border border-stone-200 bg-white shadow-sm dark:border const uiHelpers = ` const $=s=>document.querySelector(s),esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); ${ansiToHtmlBrowserSource} +${pageRefreshBrowserSource} const request=async(url,options)=>{const requestUrl=String(url).startsWith('/api/v1/runs?jobId=')&&!String(url).includes('limit=')?String(url)+'&limit=20':url,r=await fetch(requestUrl,options),body=await r.json().catch(()=>({}));if(r.status===401){location.href='/auth/linear';throw new Error('Unauthorized')}if(!r.ok)throw new Error(body.error?.message||body.error||'Request failed');return body}; const jobRunsPageSize=20,jobRunsState={page:1,cursors:[null],runs:null,loading:false}; const jobRunsMarkup=r=>r.items?.length?'':'

No runs yet.

'; @@ -90,7 +92,8 @@ const jobStatus=j=>j.runningCount>0?{label:'Running',classes:'bg-blue-500 ring-b export const jobsPage = (viewer: { email: string }) => shell("Jobs — Factorize", viewer, `
${dashboardHead("Jobs", "", 'Create job')}

Loading jobs…

`, `${uiHelpers} -async function load(){const jobs=await request('/api/v1/jobs');const root=$('#jobs');if(!jobs.length){root.innerHTML='

No jobs yet

Create a manual-only or automatically triggered job.

';return}root.innerHTML=''}load().catch(e=>$('#jobs').innerHTML='

'+esc(e.message)+'

');`); +let jobsSnapshot,activeJobs=false; +async function load(){const jobs=await request('/api/v1/jobs');activeJobs=jobs.some(j=>j.runningCount>0);const snapshot=JSON.stringify(jobs);if(snapshot===jobsSnapshot)return;jobsSnapshot=snapshot;const root=$('#jobs');if(!jobs.length){root.innerHTML='

No jobs yet

Create a manual-only or automatically triggered job.

';return}replacePageContent(root,'')}pageRefresh(load,{delay:()=>activeJobs?3000:10000,onError:e=>{if(!jobsSnapshot)$('#jobs').innerHTML='

'+esc(e.message)+'

'}});`); export const jobPage = (viewer: { email: string }, jobId?: string) => shell(`${jobId ? "Edit" : "Create"} job — Factorize`, viewer, `
← Jobs
${dashboardHead(jobId ? "Edit job" : "Create a job", "Every prompt input comes from an explicit trigger row with a stable context slug.")}

Step 1

Job details

Name this job and set how many runs it can process at once.

@@ -179,10 +182,12 @@ manualRunForm.onsubmit=async e=>{e.preventDefault();const button=$('#submit-manu const activeRunStates=['queued','starting','running','blocked','stopping'];let elapsedTimer; function elapsedSince(value){const seconds=Math.max(0,Math.floor((Date.now()-new Date(value).getTime())/1000)),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),remainder=seconds%60;return(hours?hours+'h ':'')+(minutes||hours?minutes+'m ':'')+remainder+'s elapsed'} function refreshRunElapsed(){document.querySelectorAll('[data-run-started]').forEach(label=>label.textContent=elapsedSince(label.dataset.runStarted))} -async function load(){clearInterval(elapsedTimer);const [j,runs]=await Promise.all([request('/api/v1/jobs/'+encodeURIComponent(id)),request('/api/v1/runs?jobId='+encodeURIComponent(id))]),triggers=j.triggers||[],status=jobStatus(j),hasActiveRuns=runs.items.some(r=>activeRunStates.includes(r.state));jobRunsState.page=1;jobRunsState.cursors=[null];jobRunsState.runs=runs;root.innerHTML='

'+esc(j.name)+'

'+esc(j.runningCount)+'/'+esc(j.concurrencyLimit)+' Running'+(j.enabled?'':'Disabled')+'
Details
Triggers
'+(triggers.length?triggers.map(t=>triggerBadge(t.kind)).join(' '):'None')+'
Model
'+esc(j.model||'Harness default')+'

Prompt template

'+esc(j.promptTemplate)+'
Edit

Runs

'+(runs.items.length?jobRunsMarkup(runs):'

No runs yet.

')+'
';refreshJobRunsPagination();if(hasActiveRuns)elapsedTimer=setInterval(refreshRunElapsed,1000);$('#run').onclick=openManualRun;$('#toggle').onclick=async()=>{await request('/api/v1/jobs/'+encodeURIComponent(id)+'/'+(j.enabled?'disable':'enable'),{method:'POST'});load()};$('#delete').onclick=async()=>{if(confirm('Delete this job and its queued history?')){await request('/api/v1/jobs/'+encodeURIComponent(id),{method:'DELETE'});location.href='/jobs'}}}load().catch(e=>root.innerHTML='

'+esc(e.message)+'

');`); +let jobSnapshot,jobActive=false; +async function load(){if(jobRunsState.loading)return;jobRunsState.loading=true;try{const cursor=jobRunsState.cursors[jobRunsState.page];const [j,runs]=await Promise.all([request('/api/v1/jobs/'+encodeURIComponent(id)),request('/api/v1/runs?jobId='+encodeURIComponent(id)+'&limit='+jobRunsPageSize+(cursor?'&cursor='+encodeURIComponent(cursor):''))]),triggers=j.triggers||[],status=jobStatus(j),hasActiveRuns=runs.items.some(r=>activeRunStates.includes(r.state));jobActive=j.runningCount>0||hasActiveRuns;const snapshot=JSON.stringify([j,runs,jobRunsState.page]);if(snapshot===jobSnapshot)return;jobSnapshot=snapshot;clearInterval(elapsedTimer);jobRunsState.runs=runs;replacePageContent(root,'

'+esc(j.name)+'

'+esc(j.runningCount)+'/'+esc(j.concurrencyLimit)+' Running'+(j.enabled?'':'Disabled')+'
Details
Triggers
'+(triggers.length?triggers.map(t=>triggerBadge(t.kind)).join(' '):'None')+'
Model
'+esc(j.model||'Harness default')+'

Prompt template

'+esc(j.promptTemplate)+'
Edit

Runs

'+(runs.items.length?jobRunsMarkup(runs):'

No runs yet.

')+'
',refreshJobRunsPagination);if(hasActiveRuns)elapsedTimer=setInterval(refreshRunElapsed,1000);$('#run').onclick=openManualRun;$('#toggle').onclick=async()=>{await request('/api/v1/jobs/'+encodeURIComponent(id)+'/'+(j.enabled?'disable':'enable'),{method:'POST'});jobRefresh.refresh()};$('#delete').onclick=async()=>{if(confirm('Delete this job and its queued history?')){await request('/api/v1/jobs/'+encodeURIComponent(id),{method:'DELETE'});location.href='/jobs'}}}finally{jobRunsState.loading=false}} +const jobRefresh=pageRefresh(load,{delay:()=>jobActive?3000:10000,onError:e=>{if(!jobSnapshot)root.innerHTML='

'+esc(e.message)+'

'}});`); export const jobRunPage = (viewer: { email: string }, runId: string) => shell("Run — Factorize", viewer, `

Loading run…

`, `${uiHelpers} -const root=$('#run-detail'),id=root.dataset.id,activeStates=['queued','starting','running','blocked','stopping'];let refreshTimer,elapsedTimer,traceCursor=0,traceDone=false; +const root=$('#run-detail'),id=root.dataset.id,activeStates=['queued','starting','running','blocked','stopping'];let elapsedTimer,traceCursor=0,traceDone=false,traceLoading=null,currentRun=null; function triggerProvenance(r){ const invocation=r.invocation; if(!invocation)return '

Trigger

Trigger information is not available for this run.

'; @@ -191,13 +196,23 @@ function triggerProvenance(r){ return '

Trigger

'+row('Type',source)+row('Trigger',slugs.join(', '))+row('Trigger ID',invocation.trigger_id)+row('Invoked at',invocation.created_at)+'
Trigger details
'+esc(JSON.stringify({context,...(invocation.occurrence?{occurrence:invocation.occurrence}:{})},null,2))+'
'; } ${tracePresentationBrowserSource} -async function loadTrace(reset=false){if(reset){traceCursor=0;traceDone=false;const list=$('[data-run-trace]');if(list)list.innerHTML=''}if(traceDone)return;const page=await request('/api/v1/runs/'+encodeURIComponent(id)+'/trace?after='+traceCursor+'&limit=100'),list=$('[data-run-trace]');if(!list)return;list.insertAdjacentHTML('beforeend',page.items.map(traceCard).join(''));list.classList.toggle('trace-at-end',!page.nextCursor);if(page.items.length)traceCursor=page.items.at(-1).sequence;traceDone=!page.nextCursor;const more=$('[data-trace-more]');if(more)more.classList.toggle('hidden',traceDone);const empty=$('[data-trace-empty]');if(empty)empty.classList.toggle('hidden',Boolean(list.children.length))} +function loadTrace(reset=false){if(traceLoading)return traceLoading;traceLoading=fetchTrace(reset).finally(()=>{traceLoading=null});return traceLoading} +async function fetchTrace(reset=false){if(reset){traceCursor=0;traceDone=false;const list=$('[data-run-trace]');if(list)list.innerHTML=''}if(traceDone)return;const page=await request('/api/v1/runs/'+encodeURIComponent(id)+'/trace?after='+traceCursor+'&limit=100'),list=$('[data-run-trace]');if(!list)return;list.insertAdjacentHTML('beforeend',page.items.map(traceCard).join(''));list.classList.toggle('trace-at-end',!page.nextCursor);if(page.items.length)traceCursor=page.items.at(-1).sequence;traceDone=!page.nextCursor;const more=$('[data-trace-more]');if(more)more.classList.toggle('hidden',traceDone);const empty=$('[data-trace-empty]');if(empty)empty.classList.toggle('hidden',Boolean(list.children.length))} function elapsed(r){if(!r.started_at)return ['queued','starting'].includes(r.state)?'Waiting to start':'Start time unavailable';const start=new Date(r.started_at).getTime(),end=activeStates.includes(r.state)?Date.now():new Date(r.updated_at||r.created_at).getTime(),seconds=Math.max(0,Math.floor((end-start)/1000)),hours=Math.floor(seconds/3600),minutes=Math.floor(seconds%3600/60),remainder=seconds%60;return(hours?hours+'h ':'')+(minutes||hours?minutes+'m ':'')+remainder+'s elapsed'} function queueTime(r){if(!r.started_at&&!['queued','starting'].includes(r.state))return '';const seconds=Math.max(0,Math.floor((new Date(r.started_at||Date.now()).getTime()-new Date(r.created_at).getTime())/1000));return seconds+'s '+(r.started_at?'queue time':'waiting to start')} function updateElapsed(r){const queue=$('[data-run-queue]');if(queue)queue.textContent=queueTime(r);const label=$('[data-run-elapsed]');if(label)label.textContent=elapsed(r);clearInterval(elapsedTimer);if(activeStates.includes(r.state))elapsedTimer=setInterval(()=>{const current=$('[data-run-elapsed]');if(current)current.textContent=elapsed(r);const queue=$('[data-run-queue]');if(queue)queue.textContent=queueTime(r)},1000)} -function scheduleRefresh(r){root.classList.toggle('trace-running',r.state==='running');clearTimeout(refreshTimer);if(activeStates.includes(r.state))refreshTimer=setTimeout(refreshOutput,3000)} -async function refreshOutput(){try{const r=await request('/api/v1/runs/'+encodeURIComponent(id)),state=$('[data-run-state]');if(state)state.innerHTML=stateBadge(r.state);traceDone=false;await loadTrace();updateElapsed(r);scheduleRefresh(r)}catch{refreshTimer=setTimeout(refreshOutput,5000)}} -request('/api/v1/runs/'+encodeURIComponent(id)).then(async r=>({r,j:await request('/api/v1/jobs/'+encodeURIComponent(r.job_id))})).then(async({r,j})=>{const canKill=!['done','failed','stopped','succeeded'].includes(r.state),runLabel=r.name||r.run_name||r.issue_title||'Run '+r.id.slice(0,8);document.title=runLabel+' — Factorize';root.innerHTML='

'+esc(runLabel)+'

'+elapsed(r)+'

'+queueTime(r)+'

'+stateBadge(r.state)+''+(canKill?'':'')+'
'+triggerProvenance(r)+'
Prompt
'+esc(r.prompt||'Prompt is not available for this run.')+'

Trace

Native '+esc(r.agent_kind||'agent')+' session · '+esc(r.artifact_state||'pending')+'

'+(activeStates.includes(r.state)?'Live · refreshes automatically':'')+'

Trace events are not available yet.

';if(canKill)$('#kill-run').onclick=async()=>{if(!confirm('Kill this run and release its concurrency slot?'))return;await request('/api/v1/runs/'+encodeURIComponent(id)+'/kill',{method:'POST'});location.reload()};$('[data-trace-more]').onclick=()=>{traceDone=false;loadTrace()};await loadTrace();updateElapsed(r);scheduleRefresh(r)}).catch(e=>root.innerHTML='

'+esc(e.message)+'

');`); +function updateRun(r){ + currentRun=r; + root.classList.toggle('trace-running',r.state==='running'); + const state=$('[data-run-state]');if(state)state.innerHTML=stateBadge(r.state); + if(!activeStates.includes(r.state))$('#kill-run')?.remove(); + const live=$('[data-run-live]');if(live)live.hidden=!activeStates.includes(r.state); + const artifact=$('[data-run-artifact]');if(artifact)artifact.textContent='Native '+(r.agent_kind||'agent')+' session · '+(r.artifact_state||'pending'); + updateElapsed(r); +} +async function refreshOutput(){const r=await request('/api/v1/runs/'+encodeURIComponent(id));updateRun(r);if(traceLoading)await traceLoading;traceDone=false;await loadTrace()} +async function loadRun(){return request('/api/v1/runs/'+encodeURIComponent(id)).then(async r=>({r,j:await request('/api/v1/jobs/'+encodeURIComponent(r.job_id))})).then(async({r,j})=>{const canKill=activeStates.includes(r.state),runLabel=r.name||r.run_name||r.issue_title||'Run '+r.id.slice(0,8);document.title=runLabel+' — Factorize';root.innerHTML='

'+esc(runLabel)+'

'+elapsed(r)+'

'+queueTime(r)+'

'+stateBadge(r.state)+''+(canKill?'':'')+'
'+triggerProvenance(r)+'
Prompt
'+esc(r.prompt||'Prompt is not available for this run.')+'

Trace

Native '+esc(r.agent_kind||'agent')+' session · '+esc(r.artifact_state||'pending')+'

'+(activeStates.includes(r.state)?'Live · refreshes automatically':'')+'

Trace events are not available yet.

';if(canKill)$('#kill-run').onclick=async()=>{if(!confirm('Kill this run and release its concurrency slot?'))return;await request('/api/v1/runs/'+encodeURIComponent(id)+'/kill',{method:'POST'});await runRefresh.refresh()};$('[data-trace-more]').onclick=()=>{traceDone=false;loadTrace().catch(()=>{})};updateRun(r);await loadTrace()})} +const runRefresh=pageRefresh(async()=>{if(!currentRun)await loadRun();else await refreshOutput()},{delay:()=>activeStates.includes(currentRun.state)?3000:['pending','collecting'].includes(currentRun.artifact_state)?10000:null,onError:e=>{if(!currentRun)root.innerHTML='

'+esc(e.message)+'

'}});`); const settingsLayout = (active: "integrations" | "api-keys", content: string) => `