Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/app/src/postgres/run-query-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<any>(`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<any>(`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<any>(`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<any>(`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; }
Expand Down
9 changes: 5 additions & 4 deletions packages/app/src/ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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?'<ul data-job-runs-list class="min-w-0 divide-y divide-stone-200 dark:divide-slate-800">'+r.items.map(x=>'<li class="min-w-0"><a class="flex min-w-0 items-center justify-between gap-4 p-5 hover:bg-stone-50 dark:hover:bg-slate-800" href="/job-runs/'+encodeURIComponent(x.id)+'"><span class="min-w-0 truncate"><span class="block truncate font-medium">'+esc(x.name||x.run_name||x.issue_title||x.provider||x.agent_kind||'Factorize')+'</span><span class="mt-1 block truncate text-xs text-slate-500">'+new Date(x.created_at).toLocaleString()+(activeRunStates.includes(x.state)?' · <span data-run-started="'+esc(x.created_at)+'">'+elapsedSince(x.created_at)+'</span>':'')+'</span></span><span class="shrink-0">'+stateBadge(x.state)+'</span></a></li>').join('')+'</ul>':'<p data-job-runs-list class="p-8 text-sm text-slate-500">No runs yet.</p>';
const jobRunsMarkup=r=>r.items?.length?'<ul data-job-runs-list class="min-w-0 divide-y divide-stone-200 dark:divide-slate-800">'+r.items.map(x=>'<li class="min-w-0"><a class="flex min-w-0 items-center justify-between gap-4 p-5 hover:bg-stone-50 dark:hover:bg-slate-800" href="/job-runs/'+encodeURIComponent(x.id)+'"><span class="min-w-0 truncate"><span class="block truncate font-medium">'+esc(x.name||x.run_name||x.issue_title||x.provider||x.agent_kind||'Factorize')+'</span><span class="mt-1 block truncate text-xs text-slate-500">'+new Date(x.created_at).toLocaleString()+(activeRunStates.includes(x.state)&&x.started_at?' · <span data-run-started="'+esc(x.started_at)+'">'+elapsedSince(x.started_at)+'</span>':'')+'</span></span><span class="shrink-0">'+stateBadge(x.state)+'</span></a></li>').join('')+'</ul>':'<p data-job-runs-list class="p-8 text-sm text-slate-500">No runs yet.</p>';
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'<nav data-job-runs-pagination class="flex items-center justify-between gap-3 border-b border-stone-200 px-6 py-3 text-sm dark:border-slate-800" aria-label="Runs pagination"><button type="button" data-run-page="previous"'+previous+' class="rounded-lg border border-stone-300 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700">Previous</button><span class="text-slate-500">Page '+page+'</span><button type="button" data-run-page="next"'+next+' class="rounded-lg border border-stone-300 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-50 dark:border-slate-700">Next</button></nav>'};
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');
Expand Down Expand Up @@ -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 '<article class="rounded-xl border p-4 '+(traceStyle[e.type]||'border-stone-200 bg-stone-50 dark:border-slate-700 dark:bg-slate-800/50')+'"><div class="flex flex-wrap items-center justify-between gap-2"><h3 class="text-sm font-semibold">'+esc(e.title)+'</h3><span class="rounded-full border border-current/20 px-2 py-0.5 text-[.68rem] font-semibold uppercase tracking-wide opacity-70">'+esc(e.type.replaceAll('_',' '))+'</span></div><pre class="mt-3 max-h-96 overflow-auto whitespace-pre-wrap break-words font-mono text-xs leading-5">'+esc(e.preview||'')+'</pre></article>'}
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='<nav aria-label="Breadcrumb"><ol class="flex min-w-0 flex-wrap items-center gap-2 text-sm text-slate-500"><li><a href="/jobs" class="font-medium hover:text-slate-950 dark:hover:text-white">Jobs</a></li><li aria-hidden="true">/</li><li class="min-w-0 max-w-full"><a href="/jobs/'+encodeURIComponent(r.job_id)+'" class="block truncate font-medium hover:text-slate-950 dark:hover:text-white">'+esc(j.name)+'</a></li><li aria-hidden="true">/</li><li aria-current="page" class="min-w-0 max-w-full truncate font-medium text-slate-950 dark:text-white">'+esc(runLabel)+'</li></ol></nav><div class="mt-6 flex min-w-0 flex-wrap items-start justify-between gap-4"><div class="min-w-0"><h1 class="break-words text-3xl font-bold">'+esc(runLabel)+'</h1><p data-run-elapsed class="mt-2 text-slate-500">'+elapsed(r)+'</p></div><div class="flex min-w-0 flex-wrap items-center gap-3"><span data-run-state>'+stateBadge(r.state)+'</span>'+(canKill?'<button id="kill-run" class="rounded-xl border border-red-200 px-4 py-2 text-sm font-semibold text-red-700">Kill run</button>':'')+'</div></div>'+triggerProvenance(r)+'<details data-run-prompt-details class="${card} mt-8 min-w-0 overflow-hidden p-6"><summary class="cursor-pointer font-semibold">Prompt</summary><pre data-run-prompt class="mt-4 min-w-0 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-xl bg-slate-950 p-4 font-mono text-xs leading-5 text-slate-100">'+esc(r.prompt||'Prompt is not available for this run.')+'</pre></details><section class="${card} mt-6 min-w-0 overflow-hidden p-6"><div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="font-semibold">Trace</h2><p class="mt-1 text-xs text-slate-500">Native '+esc(r.agent_kind||'agent')+' session · '+esc(r.artifact_state||'pending')+'</p></div>'+(activeStates.includes(r.state)?'<span class="text-xs text-slate-500" aria-live="polite">Live · refreshes automatically</span>':'')+'</div><p data-trace-empty class="mt-6 text-sm text-slate-500">Trace events are not available yet.</p><div data-run-trace class="mt-5 grid gap-3"></div><button data-trace-more class="mt-5 hidden rounded-xl border border-stone-300 px-4 py-2 text-sm font-semibold">Load more</button></section>';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='<p class="text-red-700">'+esc(e.message)+'</p>');`);
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='<nav aria-label="Breadcrumb"><ol class="flex min-w-0 flex-wrap items-center gap-2 text-sm text-slate-500"><li><a href="/jobs" class="font-medium hover:text-slate-950 dark:hover:text-white">Jobs</a></li><li aria-hidden="true">/</li><li class="min-w-0 max-w-full"><a href="/jobs/'+encodeURIComponent(r.job_id)+'" class="block truncate font-medium hover:text-slate-950 dark:hover:text-white">'+esc(j.name)+'</a></li><li aria-hidden="true">/</li><li aria-current="page" class="min-w-0 max-w-full truncate font-medium text-slate-950 dark:text-white">'+esc(runLabel)+'</li></ol></nav><div class="mt-6 flex min-w-0 flex-wrap items-start justify-between gap-4"><div class="min-w-0"><h1 class="break-words text-3xl font-bold">'+esc(runLabel)+'</h1><p data-run-elapsed class="mt-2 text-slate-500">'+elapsed(r)+'</p><p data-run-queue class="mt-1 text-sm text-slate-500">'+queueTime(r)+'</p></div><div class="flex min-w-0 flex-wrap items-center gap-3"><span data-run-state>'+stateBadge(r.state)+'</span>'+(canKill?'<button id="kill-run" class="rounded-xl border border-red-200 px-4 py-2 text-sm font-semibold text-red-700">Kill run</button>':'')+'</div></div>'+triggerProvenance(r)+'<details data-run-prompt-details class="${card} mt-8 min-w-0 overflow-hidden p-6"><summary class="cursor-pointer font-semibold">Prompt</summary><pre data-run-prompt class="mt-4 min-w-0 max-w-full overflow-x-auto whitespace-pre-wrap break-words rounded-xl bg-slate-950 p-4 font-mono text-xs leading-5 text-slate-100">'+esc(r.prompt||'Prompt is not available for this run.')+'</pre></details><section class="${card} mt-6 min-w-0 overflow-hidden p-6"><div class="flex flex-wrap items-center justify-between gap-3"><div><h2 class="font-semibold">Trace</h2><p class="mt-1 text-xs text-slate-500">Native '+esc(r.agent_kind||'agent')+' session · '+esc(r.artifact_state||'pending')+'</p></div>'+(activeStates.includes(r.state)?'<span class="text-xs text-slate-500" aria-live="polite">Live · refreshes automatically</span>':'')+'</div><p data-trace-empty class="mt-6 text-sm text-slate-500">Trace events are not available yet.</p><div data-run-trace class="mt-5 grid gap-3"></div><button data-trace-more class="mt-5 hidden rounded-xl border border-stone-300 px-4 py-2 text-sm font-semibold">Load more</button></section>';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='<p class="text-red-700">'+esc(e.message)+'</p>');`);

const settingsLayout = (active: "integrations" | "api-keys", content: string) => `<main class="mx-auto w-full max-w-6xl min-w-0 px-4 py-8 sm:px-5 sm:py-12 lg:px-8"><div class="grid min-w-0 gap-8 md:grid-cols-[13rem_minmax(0,1fr)] md:gap-10"><nav aria-label="Settings" class="flex gap-2 overflow-x-auto border-b border-stone-200 pb-3 md:block md:border-b-0 md:pb-0 dark:border-slate-800">
${[["integrations", "Integrations"], ["api-keys", "API Keys"]].map(([id, label]) => `<a href="/settings/${id}"${active === id ? ` aria-current="page"` : ""} class="flex min-h-11 shrink-0 items-center rounded-lg px-3 py-2 text-sm text-slate-600 hover:bg-stone-100 hover:text-slate-950 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-factorize-600 aria-[current=page]:bg-stone-100 aria-[current=page]:font-semibold aria-[current=page]:text-slate-950 md:mb-1 dark:text-slate-300 dark:hover:bg-slate-800 dark:hover:text-white dark:aria-[current=page]:bg-slate-800 dark:aria-[current=page]:text-white">${label}</a>`).join("")}
Expand Down
16 changes: 16 additions & 0 deletions packages/app/test/postgres-run-query-repository.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { describe, expect, it, vi } from "vitest";
import { RunQueryRepository } from "../src/postgres/run-query-repository";

describe("run timing queries", () => {
it.each([null, new Date("2026-09-18T12:10:00Z")])("exposes the persisted start (%s) separately from creation", async started_at => {
const row = { id: "run", created_at: new Date("2026-09-18T12:00:00Z"), started_at };
const query = vi.fn(async () => ({ rows: [row] }));
const repository = new RunQueryRepository({ pool: { query } } as any, "tenant");
expect((await repository.list(new URLSearchParams())).items[0]).toEqual(row);
expect(await repository.get("run")).toEqual(row);
for (const [sql] of query.mock.calls as unknown as [string][]) {
expect(sql).toContain("jr.started_at");
expect(sql).toContain("jr.tenant_id=r.tenant_id AND jr.id=r.id");
}
});
});
Loading
Loading