diff --git a/packages/app/src/postgres/run-query-repository.ts b/packages/app/src/postgres/run-query-repository.ts index 246fadf..709f3ab 100644 --- a/packages/app/src/postgres/run-query-repository.ts +++ b/packages/app/src/postgres/run-query-repository.ts @@ -12,13 +12,13 @@ export class RunQueryRepository { if (query.get("contextQuery")) { values.push(`%${query.get("contextQuery")}%`); clauses.push(`i.context::text ILIKE $${values.length}`); } if (cursor) { values.push(cursor[0], cursor[1]); clauses.push(`(r.created_at,r.id)<($${values.length - 1}::timestamptz,$${values.length}::uuid)`); } values.push(limit + 1); - const result = await this.database.pool.query(`SELECT r.id,r.job_id,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at - FROM app.runs r JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id WHERE ${clauses.join(" AND ")} ORDER BY r.created_at DESC,r.id DESC LIMIT $${values.length}`, values); + const result = await this.database.pool.query(`SELECT r.id,r.job_id,r.issue_id,r.issue_url,r.issue_title,r.run_name,r.agent_name,r.workspace_name,r.agent_kind,r.state,r.provider,r.execution_backend_kind backend_kind,r.execution_capabilities capabilities,r.destination_url,r.artifact_state,r.artifact_error,r.created_at,r.updated_at,jr.started_at + FROM app.runs r JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id WHERE ${clauses.join(" AND ")} ORDER BY r.created_at DESC,r.id DESC LIMIT $${values.length}`, values); const rows = result.rows.slice(0, limit), hasMore = result.rows.length > limit; return { items: rows, nextCursor: hasMore && rows.length ? encodeCursor(rows.at(-1).created_at, rows.at(-1).id) : null }; } async get(runId: string) { - return (await this.database.pool.query(`SELECT r.*,jr.encrypted_prompt,i.source invocation_source,i.claim_key invocation_claim_key,i.trigger_id invocation_trigger_id,i.context,i.occurrence,i.created_at invocation_created_at + return (await this.database.pool.query(`SELECT r.*,jr.started_at,jr.encrypted_prompt,i.source invocation_source,i.claim_key invocation_claim_key,i.trigger_id invocation_trigger_id,i.context,i.occurrence,i.created_at invocation_created_at FROM app.runs r JOIN app.job_runs jr ON jr.tenant_id=r.tenant_id AND jr.id=r.id JOIN app.invocations i ON i.tenant_id=r.tenant_id AND i.id=r.invocation_id WHERE r.tenant_id=$1 AND r.id=$2`, [this.tenantId, runId])).rows[0] ?? null; } async activity(runId: string) { return (await this.database.pool.query("SELECT action,detail,created_at FROM app.run_activity WHERE tenant_id=$1 AND run_id=$2 ORDER BY created_at,id", [this.tenantId, runId])).rows; } diff --git a/packages/app/src/ui.ts b/packages/app/src/ui.ts index 8ad6c7d..19be62a 100644 --- a/packages/app/src/ui.ts +++ b/packages/app/src/ui.ts @@ -75,7 +75,7 @@ const $=s=>document.querySelector(s),esc=v=>String(v??'').replace(/[&<>"']/g,c=> ${ansiToHtmlBrowserSource} 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.

'; +const jobRunsMarkup=r=>r.items?.length?'':'

No runs yet.

'; const jobRunsControls=(r,page)=>{if(page===1&&!r.nextCursor)return'';const previous=page>1?'':' disabled aria-disabled="true"',next=r.nextCursor?'':' disabled aria-disabled="true"';return''}; const refreshJobRunsPagination=()=>{const section=document.querySelector('[data-job-runs-section]')||document.querySelector('#job-detail section:last-of-type'),r=jobRunsState.runs;if(!section||!r)return;section.querySelectorAll('[data-job-runs-pagination]').forEach(x=>x.remove());const list=section.querySelector('[data-job-runs-list]')||section.querySelector('ul,section>p');if(list)list.outerHTML=jobRunsMarkup(r);const header=section.firstElementChild;header?.insertAdjacentHTML('afterend',jobRunsControls(r,jobRunsState.page));section.insertAdjacentHTML('beforeend',jobRunsControls(r,jobRunsState.page));section.querySelectorAll('[data-run-page]').forEach(button=>button.addEventListener('click',async()=>{if(jobRunsState.loading)return;const direction=button.dataset.runPage,currentPage=jobRunsState.page,targetPage=direction==='next'?currentPage+1:currentPage-1;if(targetPage<1||targetPage>currentPage+1)return;const cursor=targetPage===currentPage+1?r.nextCursor:jobRunsState.cursors[targetPage];if(direction==='next'&&!cursor)return;jobRunsState.loading=true;try{const query='/api/v1/runs?jobId='+encodeURIComponent(document.querySelector('#job-detail').dataset.id)+'&limit='+jobRunsPageSize+(cursor?'&cursor='+encodeURIComponent(cursor):'');const nextRuns=await request(query);jobRunsState.cursors[targetPage]=cursor;jobRunsState.page=targetPage;jobRunsState.runs=nextRuns;refreshJobRunsPagination()}finally{jobRunsState.loading=false}}))}; const pairs=v=>Object.entries(v||{}).map(([k,x])=>k+'='+x).join('\\n'); @@ -192,11 +192,12 @@ function triggerProvenance(r){ const traceStyle={user_message:'border-amber-300 bg-amber-50 dark:border-amber-900 dark:bg-amber-950/30',assistant_message:'border-emerald-300 bg-emerald-50 dark:border-emerald-900 dark:bg-emerald-950/30',reasoning:'border-violet-300 bg-violet-50 dark:border-violet-900 dark:bg-violet-950/30',tool_call:'border-sky-300 bg-sky-50 dark:border-sky-900 dark:bg-sky-950/30',tool_result:'border-cyan-300 bg-cyan-50 dark:border-cyan-900 dark:bg-cyan-950/30',command:'border-slate-400 bg-slate-950 text-slate-100',error:'border-red-400 bg-red-50 dark:bg-red-950/30',warning:'border-orange-300 bg-orange-50 dark:bg-orange-950/30'}; function traceCard(e){return '

'+esc(e.title)+'

'+esc(e.type.replaceAll('_',' '))+'
'+esc(e.preview||'')+'
'} 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(''));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){const start=new Date(r.created_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 updateElapsed(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)},1000)} +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){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)+'

'+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)+'

');`); +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)+'

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