From 6632ba2e0d35638977b02fdf7f5dcb8f5112f1b2 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Mon, 24 Aug 2026 14:07:44 -0700 Subject: [PATCH] feat: paginate cross-host history sessions --- docs/OBSERVABILITY.md | 22 +++ src/lib/dashboard-server.mjs | 17 ++- src/lib/dashboard/live/client.mjs | 26 ++-- src/lib/dashboard/live/styles.mjs | 1 + src/lib/live/live-sessions-service.mjs | 140 +++++++++++++++++-- src/lib/live/native-transcript-discovery.mjs | 32 ++++- tests/dashboard.test.cjs | 37 ++++- tests/kit/live-service.test.mjs | 41 ++++++ tests/ui/dashboard-ui.mjs | 17 ++- 9 files changed, 302 insertions(+), 31 deletions(-) diff --git a/docs/OBSERVABILITY.md b/docs/OBSERVABILITY.md index 7c9a6d0..404fbd0 100644 --- a/docs/OBSERVABILITY.md +++ b/docs/OBSERVABILITY.md @@ -225,6 +225,27 @@ secondary tab row directly beneath the main Observability tab; the Left/Right, H also move between these two views when the tab row has focus. Their canonical hashes are `#observability/live` and `#observability/history`. +### History loading, pagination, and coverage + +History is loaded newest-first in bounded pages. The browser requests an initial page of up to +100 sessions, automatically asks for the next opaque continuation token as the list approaches +its sentinel, and keeps a visible **Load older sessions** fallback when automatic loading is not +available. The project browser uses the server's full project metadata, so a page containing only +the newest sessions does not make a project's total look smaller than it is. + +The server keeps the history scan separate from the live tailer. It discovers eligible Claude and +Codex transcript files by file modification time, materializes a stable short-lived snapshot, and +then pages that snapshot. Each paginated response reports `pagination.total`, `hasMore`, and an +opaque `nextPageToken`; `coverage` reports the per-host candidate/returned file counts, the file +limit, the scan time, and whether discovery was complete. An incomplete scan is disclosed in the +History view rather than presented as an authoritative empty or complete result. + +This was added compatibly. `GET /api/live/history` without `limit`, `pageToken`, or `projectKey` +continues to return the pre-pagination snapshot shape. Clients that understand pagination opt in +with those query parameters and receive the same snapshot fields plus additive `pagination` and +`coverage` fields. Continuation tokens are short-lived and scoped to their project/window snapshot; +an expired or malformed token returns `400` so a client can restart from the first page. + ### Live and Review playback An active session opens in **Live** mode and follows new topology and transcript @@ -394,6 +415,7 @@ unbounded content snapshot. | Symptom | Explanation | |---------|-------------| | No sessions | No supported metadata was found within the bounded newest-first discovery set | +| History says the scan is incomplete | The per-host discovery bound was reached; older files may not be represented, so widen the configured source or rerun after reducing the corpus | | Live shows 0 projects | No retained root currently satisfies the Live predicate; switch to History for past sessions | | History shows 0 projects | No retained non-live root exists; current work, if any, remains in Live | | Green or moving content appears in History | This violates the Observability contract; refresh, then report it as a presentation defect if it remains | diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index 982f7f4..be49050 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -961,9 +961,20 @@ export function startDashboard({ return; } const sinceMs = windowToSinceMs(query.get('window')); - sendJson(res, 200, publicLivePayload(await service.historySnapshot({ sinceMs }))); - } catch { - sendJson(res, 503, { error: 'live telemetry unavailable' }); + const pageRequested = query.has('limit') || query.has('pageToken') || query.has('projectKey'); + const payload = pageRequested && typeof service.historyPage === 'function' + ? await service.historyPage({ + sinceMs, + projectKey: query.get('projectKey') || null, + pageToken: query.get('pageToken') || null, + limit: clampInt(query.get('limit'), 100, 1, 250), + }) + : await service.historySnapshot({ sinceMs }); + sendJson(res, 200, publicLivePayload(payload)); + } catch (error) { + sendJson(res, error?.code === 'INVALID_HISTORY_PAGE_TOKEN' ? 400 : 503, + { error: error?.code === 'INVALID_HISTORY_PAGE_TOKEN' + ? 'invalid history page token' : 'live telemetry unavailable' }); } finally { scheduleLiveIdle(); } diff --git a/src/lib/dashboard/live/client.mjs b/src/lib/dashboard/live/client.mjs index 0aed186..f6ce9b9 100644 --- a/src/lib/dashboard/live/client.mjs +++ b/src/lib/dashboard/live/client.mjs @@ -20,7 +20,7 @@ export const LIVE_JS = ` function dashSseUrl(u){return DASH_TOKEN?u+(u.indexOf("?")<0?"?":"&")+"token="+encodeURIComponent(DASH_TOKEN):u;} var PAUSE_LIMIT=256,MAX_TURNS=500,TRANSCRIPT_COLLAPSE_KEY="ak-dash-transcript-collapsed",TERMINAL={completed:1,failed:1,cancelled:1}; function storedTranscriptCollapsed(){try{return localStorage.getItem(TRANSCRIPT_COLLAPSE_KEY)==="true";}catch(_){return false;}} - var state={snapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},events:[],project:null,selected:null,node:null,browserLevel:"projects",scope:"live",historyWindow:"14d",historySnapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},historyLoading:false,historyError:false,paused:false,pending:[],overflow:false,source:null,resyncing:false,lastAt:0,active:false,connection:{key:"connecting",text:"Connecting to local session telemetry…"},positions:{},pinned:{},seen:{},camera:{x:24,y:24,k:1},cameraEpoch:0,fitFor:null,pointer:null,transcriptCollapsed:storedTranscriptCollapsed(),playback:{mode:"live",events:[],items:[],index:0,playing:false,speed:1,timer:null,startAt:null,endAt:null,truncated:false,gap:false},transcript:{source:null,turns:[],seen:{},query:"",follow:true,unread:0,status:"idle",session:null}}; + var state={snapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},events:[],project:null,selected:null,node:null,browserLevel:"projects",scope:"live",historyWindow:"14d",historySnapshot:{schemaVersion:2,cursor:null,projects:[],sessions:[]},historyLoading:false,historyLoadingMore:false,historyError:false,historyPagination:null,historyCoverage:null,historyRequest:0,historyProjectKey:null,historyObserver:null,paused:false,pending:[],overflow:false,source:null,resyncing:false,lastAt:0,active:false,connection:{key:"connecting",text:"Connecting to local session telemetry…"},positions:{},pinned:{},seen:{},camera:{x:24,y:24,k:1},cameraEpoch:0,fitFor:null,pointer:null,transcriptCollapsed:storedTranscriptCollapsed(),playback:{mode:"live",events:[],items:[],index:0,playing:false,speed:1,timer:null,startAt:null,endAt:null,truncated:false,gap:false},transcript:{source:null,turns:[],seen:{},query:"",follow:true,unread:0,status:"idle",session:null}}; function el(id){return document.getElementById(id);} function esc(s){return String(s==null?"":s).replace(/[&<>"']/g,function(c){return{"&":"&","<":"<",">":">",'"':""","'":"'"}[c];});} function short(s,n){s=String(s||"");return s.length>n?s.slice(0,n-1)+"…":s;} @@ -51,7 +51,9 @@ export const LIVE_JS = ` // buckets (not one shared state.snapshot) so a live delta arriving while // History is on screen can never silently overwrite the historical view. function currentSnapshot(){return state.scope==="history"?state.historySnapshot:state.snapshot;} - function renderConnection(){var history=state.scope==="history",c=history?(state.historyLoading?{key:"connecting",text:"Loading "+state.historyWindow+" of history…"}:state.historyError?{key:"offline",text:"History unavailable · try again"}:{key:"history",text:"History · "+state.historyWindow+" · retained local sessions"}):state.connection;el("live-state-dot").dataset.state=c.key;el("live-state-text").textContent=c.text;el("live-browser-state-dot").dataset.state=c.key;el("live-browser-state").textContent=c.text;} + function historyLoadedCount(){return currentSnapshot().sessions.filter(function(s){return isNavigationRoot(s)&&!isLiveSession(s);}).length;} + function historyTotal(){var p=state.project&&projectCatalog().find(function(item){return item.id===state.project;});return p&&Number.isFinite(Number(p.historicalCount))?Number(p.historicalCount):state.historyPagination&&state.historyPagination.total;} + function renderConnection(){var history=state.scope==="history",p=state.historyPagination,c=history?(state.historyLoading?{key:"connecting",text:"Loading "+state.historyWindow+" of history…"}:state.historyLoadingMore?{key:"connecting",text:"Loading older sessions…"}:state.historyError?{key:"offline",text:"History unavailable · try again"}:{key:"history",text:"History · "+state.historyWindow+" · "+historyLoadedCount()+""+(historyTotal()!=null?" of "+historyTotal():"")+" retained sessions"}):state.connection;el("live-state-dot").dataset.state=c.key;el("live-state-text").textContent=c.text;el("live-browser-state-dot").dataset.state=c.key;el("live-browser-state").textContent=c.text;} function setConnection(k,t){state.connection={key:k,text:t};renderConnection();} function sessionKey(s){return String(s&&s.key||(hostOf(s)+":"+(s&&s.id||"")));} function presenceState(s){return s&&s.presence&&s.presence.state||"unknown";} @@ -87,7 +89,10 @@ export const LIVE_JS = ` // receiveSnapshot()/state.snapshot: that bucket is fed by the live SSE // stream (still connected in the background so switching back to Live is // instant), and a delta arriving mid-browse must never clobber this view. - function fetchHistory(){state.historyLoading=true;state.historyError=false;render();fetch("/api/live/history?window="+encodeURIComponent(state.historyWindow),{cache:"no-store",headers:{"x-dash-token":DASH_TOKEN}}).then(function(r){if(!r.ok)throw Error();return r.json();}).then(function(d){state.historyLoading=false;state.historySnapshot=sessionsOf(d);state.lastAt=Date.now();state.events=[];state.positions={};state.seen={};state.node=null;state.fitFor=null;var projects=projectCatalog();if(!projects.some(function(p){return p.id===state.project;}))state.project=projects[0]&&projects[0].id||null;var list=visibleSessions();selectSession(list[0]&&sessionKey(list[0]),true);render();}).catch(function(){state.historyLoading=false;state.historyError=true;render();});} + function historyUrl(projectKey,pageToken){var q="/api/live/history?window="+encodeURIComponent(state.historyWindow)+"&limit=100";if(projectKey)q+="&projectKey="+encodeURIComponent(projectKey);if(pageToken)q+="&pageToken="+encodeURIComponent(pageToken);return q;} + function mergeHistoryPage(d,append){var incoming=sessionsOf(d);if(!append){state.historySnapshot=incoming;return;}var by={};state.historySnapshot.sessions.concat(incoming.sessions).forEach(function(s){by[sessionKey(s)]=s;});state.historySnapshot=Object.assign({},incoming,{sessions:Object.keys(by).map(function(k){return by[k];})});} + function fetchHistory(options){options=options||{};var append=!!options.append,projectKey=options.projectKey===undefined?state.historyProjectKey:options.projectKey;if(state.historyLoading||state.historyLoadingMore)return;if(append&&(!state.historyPagination||!state.historyPagination.hasMore))return;var request=++state.historyRequest;state.historyProjectKey=projectKey||null;state.historyLoading=!append;state.historyLoadingMore=append;state.historyError=false;if(!append){state.historyPagination=null;state.historyCoverage=null;state.selected=null;state.node=null;}render();var token=append&&state.historyPagination&&state.historyPagination.nextPageToken,url=historyUrl(state.historyProjectKey,token);fetch(url,{cache:"no-store",headers:{"x-dash-token":DASH_TOKEN}}).then(function(r){if(!r.ok)throw Error();return r.json();}).then(function(d){if(request!==state.historyRequest)return;mergeHistoryPage(d,append);state.historyPagination=d.pagination||null;state.historyCoverage=d.coverage||null;state.historyLoading=false;state.historyLoadingMore=false;state.lastAt=Date.now();state.events=[];if(!append){state.positions={};state.seen={};state.fitFor=null;var projects=projectCatalog();if(!projects.some(function(p){return p.id===state.project;}))state.project=projects[0]&&projects[0].id||null;}var list=visibleSessions();if(!state.selected||!sessionById(state.selected))selectSession(list[0]&&sessionKey(list[0]),true);render();}).catch(function(){if(request!==state.historyRequest)return;state.historyLoading=false;state.historyLoadingMore=false;state.historyError=true;render();});} + function loadMoreHistory(){fetchHistory({append:true});} function graphData(s){ var all=s&&s.nodes||[],edges=s&&s.edges||[],isTool={tool:1,skill:1,plugin:1,mcp:1},allTools=all.filter(function(n){return isTool[n.kind];}),nodes=all.filter(function(n){return!isTool[n.kind];}),owner={},history={}; edges.forEach(function(e){var t=allTools.find(function(n){return n.id===e.target;}),src=allTools.find(function(n){return n.id===e.source;});if(t)owner[t.id]=e.source;if(src)owner[src.id]=e.target;}); @@ -119,13 +124,16 @@ export const LIVE_JS = ` function showTooltip(target){if(!target||!target.dataset.node)return;var found=tooltipValue(target.dataset.node),value=found.value;if(!value)return;var view=displayEntityState(value,found.session),owner=found.data.owner[value.id],ownerNode=found.data.nodes.find(function(n){return n.id===owner;}),tip=el("live-tooltip"),r=target.getBoundingClientRect();tip.innerHTML=""+esc(label(value))+""+esc(kindName(value.kind)+" · "+view.label)+(ownerNode?"
Owned by "+esc(label(ownerNode)):"")+"
"+esc(hostName(value)+" · "+inferenceProviderName(value))+"
Evidence: "+esc(value.confidence||"not reported");tip.hidden=false;var w=Math.min(300,window.innerWidth-24),left=Math.max(12,Math.min(window.innerWidth-w-12,r.right+10)),top=Math.max(12,Math.min(window.innerHeight-tip.offsetHeight-12,r.top));tip.style.width=w+"px";tip.style.left=left+"px";tip.style.top=top+"px";target.setAttribute("aria-describedby","live-tooltip");} function hideTooltip(target){if(target&&document.activeElement===target)return;el("live-tooltip").hidden=true;if(target)target.removeAttribute("aria-describedby");} function showRowTooltip(target){if(!target)return;var tip=el("live-tooltip"),r=target.getBoundingClientRect(),heading=target.querySelector(".live-session-main span,.live-project span"),description=target.getAttribute("aria-label")||target.title||target.textContent;tip.innerHTML=""+esc(heading&&heading.textContent||"Session")+""+esc(description);tip.hidden=false;var w=Math.min(300,window.innerWidth-24),left=Math.max(12,Math.min(window.innerWidth-w-12,r.right+10)),top=Math.max(12,Math.min(window.innerHeight-tip.offsetHeight-12,r.top));tip.style.width=w+"px";tip.style.left=left+"px";tip.style.top=top+"px";target.setAttribute("aria-describedby","live-tooltip");} - function projectCatalog(){var snap=currentSnapshot(),given=Array.isArray(snap.projects)?snap.projects:[],by={};snap.sessions.forEach(function(s){if(!s.project||s.project==="unknown")return;var id=String(s.projectKey||projectName(s)),p=by[id]||(by[id]={id:id,name:projectName(s),sessionCount:0,liveCount:0,historicalCount:0,liveChildCount:0,historicalChildCount:0,updatedAt:s.updatedAt}),live=isLiveSession(s);if(isNavigationRoot(s)){p.sessionCount++;if(live)p.liveCount++;else p.historicalCount++;}else if(live)p.liveChildCount++;else p.historicalChildCount++;if(Date.parse(s.updatedAt||0)>Date.parse(p.updatedAt||0))p.updatedAt=s.updatedAt;});given.forEach(function(p){var name=p.label||p.name||p.project;if(!name||name==="unknown")return;var id=String(p.id||p.key||p.projectKey||name),derived=by[id]||{};if(Object.keys(derived).length)by[id]=Object.assign({},p,derived,{id:id,name:name||derived.name});});return Object.keys(by).map(function(k){return by[k];}).sort(function(a,b){return Number(b.liveCount||0)-Number(a.liveCount||0)||Date.parse(b.updatedAt||0)-Date.parse(a.updatedAt||0)||a.name.localeCompare(b.name);});} + function projectCatalog(){var snap=currentSnapshot(),given=Array.isArray(snap.projects)?snap.projects:[],by={};snap.sessions.forEach(function(s){if(!s.project||s.project==="unknown")return;var id=String(s.projectKey||projectName(s)),p=by[id]||(by[id]={id:id,name:projectName(s),sessionCount:0,liveCount:0,historicalCount:0,liveChildCount:0,historicalChildCount:0,updatedAt:s.updatedAt}),live=isLiveSession(s);if(isNavigationRoot(s)){p.sessionCount++;if(live)p.liveCount++;else p.historicalCount++;}else if(live)p.liveChildCount++;else p.historicalChildCount++;if(Date.parse(s.updatedAt||0)>Date.parse(p.updatedAt||0))p.updatedAt=s.updatedAt;});given.forEach(function(p){var name=p.label||p.name||p.project;if(!name||name==="unknown")return;var id=String(p.id||p.key||p.projectKey||name),derived=by[id],base=Object.assign({},p,{id:id,name:name});if(state.scope==="history"){var count=Number(p.historicalCount!=null?p.historicalCount:(p.sessionCount!=null?p.sessionCount:(derived&&derived.sessionCount)||0)),children=Number(p.historicalChildCount!=null?p.historicalChildCount:(p.childSessionCount!=null?p.childSessionCount:(derived&&derived.historicalChildCount)||0));by[id]=Object.assign({},derived||{},base,{id:id,name:name,historicalCount:count,historicalChildCount:children,sessionCount:count,liveCount:0,liveChildCount:0});}else if(derived)by[id]=Object.assign({},base,derived,{id:id,name:name});});return Object.keys(by).map(function(k){return by[k];}).sort(function(a,b){return Number(b.liveCount||0)-Number(a.liveCount||0)||Date.parse(b.updatedAt||0)-Date.parse(a.updatedAt||0)||a.name.localeCompare(b.name);});} function duration(s){var start=s.startedAt||s.createdAt,end=s.endedAt||s.completedAt||((s.status==="completed"||s.status==="failed"||s.status==="cancelled")&&s.updatedAt),a=Date.parse(start||""),b=Date.parse(end||""),ms=b-a;if(!Number.isFinite(a)||!Number.isFinite(b)||ms<=0)return ago(s.updatedAt)||"activity time unavailable";var m=Math.max(1,Math.round(ms/60000));return m<60?m+"m":Math.floor(m/60)+"h "+m%60+"m";} function sessionMarkup(s,child){var live=state.scope==="live"&&isLiveSession(s),key=sessionKey(s),view=displaySessionState(s),identity=hostName(s)+" · "+inferenceProviderName(s),name=s.title||s.summary||(child?"Worker thread":hostName(s)+" session"),description=name+". "+view.label+". "+duration(s)+". "+workspaceDescription(s);return'";} function workerMarkup(s,n){var view=displayEntityState(n,s),worker=n&&n.host?n:s,description=(label(n)||"Worker")+". Worker view. "+view.label+". "+workspaceDescription(s);return'";} function renderSessions(){var allProjects=projectCatalog(),projects=allProjects.filter(function(p){return Number(state.scope==="live"?p.liveCount:p.historicalCount)>0;}),list=visibleSessions(),current=projects.find(function(p){return p.id===state.project;}),selected=sessionById(state.selected),browser=el("live-browser"),scopedRoots=currentSnapshot().sessions.filter(function(s){return isNavigationRoot(s)&&(state.scope==="live"?isLiveSession(s):!isLiveSession(s));});if(!current){state.project=projects[0]&&projects[0].id||null;current=projects[0];list=visibleSessions();}browser.dataset.level=state.browserLevel;el("live-view-summary").textContent=state.scope==="live"?(scopedRoots.length?scopedRoots.length+" active session"+(scopedRoots.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s"):"No live sessions across 0 projects"):(scopedRoots.length+" historical session"+(scopedRoots.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s"));el("live-browser-kicker").textContent=state.browserLevel==="sessions"?"PROJECT":state.scope==="live"?"LIVE WORKSPACES":"HISTORY";el("live-browser-title").textContent=state.browserLevel==="sessions"&¤t?short(current.name,26):"Projects";el("live-project-count").textContent=String(projects.length);el("live-project-list").innerHTML=projects.map(function(p){var sessions=Number(state.scope==="live"?p.liveCount:p.historicalCount),workers=Number(state.scope==="live"?p.liveChildCount:p.historicalChildCount),description=p.name+". "+sessions+" "+(state.scope==="live"?"live":"historical")+" sessions"+(workers?" and "+workers+" workers":"")+". "+(ago(p.updatedAt)||"Activity time unavailable");return'";}).join("")||'
'+(state.scope==="live"?"No projects have a live session.":"No retained project history found.")+"
";var selectedRoot=rootSession(selected);if(!selectedRoot||!list.some(function(s){return sessionKey(s)===sessionKey(selectedRoot);})){selectSession(list[0]&&sessionKey(list[0]),true);list=visibleSessions();selected=sessionById(state.selected);selectedRoot=rootSession(selected);}el("live-session-heading").textContent=current?short(current.name,36):"Sessions";el("live-count").textContent=String(list.length);el("live-session-list").innerHTML=list.map(function(s){var expanded=sessionKey(s)===sessionKey(selectedRoot),children=expanded?childSessions(s).filter(function(child){return state.scope==="live"?isLiveSession(child):!isLiveSession(child);}):[],workers=expanded?embeddedWorkers(s).filter(function(worker){var working=entityState(worker,s).key==="working";return state.scope==="live"?working:!working;}):[],nested=children.map(function(child){return sessionMarkup(child,true);}).concat(workers.map(function(worker){return workerMarkup(s,worker);}));return'
'+sessionMarkup(s,false)+(nested.length?'
'+nested.join("")+"
":"")+"
";}).join("")||'
No '+(state.scope==="live"?"live":"historical")+' sessions for this project.
';el("live-session-context-identity").textContent=selected?hostName(selected)+" · "+inferenceProviderName(selected):"—";el("live-session-context-project").textContent=selected?projectName(selected):"Choose a session";var selectedState=displaySessionState(selected);el("live-session-context-status").textContent=selected?selectedState.label+" · "+duration(selected):"Waiting for local evidence";} + function historyMoreMarkup(){var p=state.historyPagination,c=state.historyCoverage;if(state.scope!=="history"||!p)return"";if(state.historyLoadingMore)return'
Loading older sessions…
';if(state.historyError)return'
Could not load older sessions.
';if(p.hasMore)return'
More retained sessions available.
';if(c&&!c.complete)return'
History scan incomplete; some retained files were not included.
';return'
All retained sessions loaded.
';} + function observeHistorySentinel(){if(state.historyObserver){state.historyObserver.disconnect();state.historyObserver=null;}if(state.scope!=="history"||!state.historyPagination||!state.historyPagination.hasMore||typeof IntersectionObserver!=="function")return;var root=el("live-session-list"),target=el("live-history-more");if(!root||!target)return;state.historyObserver=new IntersectionObserver(function(entries){if(entries.some(function(entry){return entry.isIntersecting;}))loadMoreHistory();},{root:root,rootMargin:"180px"});state.historyObserver.observe(target);} + function renderHistorySessions(){var allProjects=projectCatalog(),projects=allProjects.filter(function(p){return Number(p.historicalCount||0)>0;}),list=visibleSessions(),current=projects.find(function(p){return p.id===state.project;}),selected=sessionById(state.selected),browser=el("live-browser");if(!current){state.project=projects[0]&&projects[0].id||null;current=projects[0];list=visibleSessions();}var total=historyTotal(),exact=state.historyPagination&&state.historyPagination.totalExact,summary;if(!state.project)summary=projects.length+" project"+(projects.length===1?"":"s")+" with retained history";else summary=(list.length?"Showing ":"No ")+list.length+(total!=null?" of "+(exact?total:"at least "+total):"")+" historical session"+(list.length===1?"":"s")+" across "+projects.length+" project"+(projects.length===1?"":"s");if(state.historyCoverage&&!state.historyCoverage.complete)summary+=" · scan incomplete";browser.dataset.level=state.browserLevel;el("live-view-summary").textContent=summary;el("live-browser-kicker").textContent=state.browserLevel==="sessions"?"PROJECT":"HISTORY";el("live-browser-title").textContent=state.browserLevel==="sessions"&¤t?short(current.name,26):"Projects";el("live-project-count").textContent=String(projects.length);el("live-project-list").innerHTML=projects.map(function(p){var sessions=Number(p.historicalCount||0),workers=Number(p.historicalChildCount||0),hosts=p.hosts?Object.keys(p.hosts).sort().map(function(host){return hostName({host:host})+" "+p.hosts[host];}).join(" · "):"",description=p.name+". "+sessions+" historical sessions"+(workers?" and "+workers+" workers":"")+". "+(hosts?hosts+" · ":"")+(ago(p.updatedAt)||"Activity time unavailable");return'';}).join("")||'
No retained project history found.
';var selectedRoot=rootSession(selected);if(!selectedRoot||!list.some(function(s){return sessionKey(s)===sessionKey(selectedRoot);})){selectSession(list[0]&&sessionKey(list[0]),true);list=visibleSessions();selected=sessionById(state.selected);selectedRoot=rootSession(selected);}el("live-session-heading").textContent=current?short(current.name,36):"Sessions";el("live-count").textContent=total!=null?String(list.length)+"/"+String(total):String(list.length);el("live-session-list").innerHTML=list.map(function(s){var expanded=sessionKey(s)===sessionKey(selectedRoot),children=expanded?childSessions(s).filter(function(child){return!isLiveSession(child);}):[],workers=expanded?embeddedWorkers(s).filter(function(worker){return entityState(worker,s).key!=="working";}):[],nested=children.map(function(child){return sessionMarkup(child,true);}).concat(workers.map(function(worker){return workerMarkup(s,worker);}));return'
'+sessionMarkup(s,false)+(nested.length?'
'+nested.join("")+"
":"")+"
";}).join("")||'
No historical sessions for this project.
';el("live-session-list").insertAdjacentHTML("beforeend",historyMoreMarkup());el("live-session-context-identity").textContent=selected?hostName(selected)+" · "+inferenceProviderName(selected):"—";el("live-session-context-project").textContent=selected?projectName(selected):"Choose a session";var selectedState=displaySessionState(selected);el("live-session-context-status").textContent=selected?selectedState.label+" · "+duration(selected):"Waiting for local evidence";observeHistorySentinel();} function renderHealth(){var h=state.snapshot.health||{},bad=0;el("live-health").innerHTML=Object.keys(h).sort().map(function(n){var v=h[n]||{};if(v.status==="error")bad++;return''+esc(n)+' · '+esc(v.status||"unknown")+" · "+Number(v.files||0)+" files · "+Number(v.events||0)+" events · "+Number(v.errors||0)+" errors";}).join("");el("live-health-toggle").textContent=bad?bad+" source issue"+(bad===1?"":"s"):"Sources";} - function render(){el("panel-observability").dataset.scope=state.scope;document.querySelectorAll("[data-live-scope]").forEach(function(item){item.setAttribute("aria-selected",String(item.dataset.liveScope===state.scope));});var windowGroup=el("observability-window");if(windowGroup)windowGroup.hidden=state.scope!=="history";el("observability-title").textContent=state.scope==="live"?"Live agent activity":"Session history";el("live-sub").textContent=state.scope==="live"?"Follow current sessions, agents, tools, and supported evidence as work happens.":"Inspect retained, inert evidence and deterministic playback from completed work.";el("live-transcript-kicker").textContent=state.scope==="live"?"LIVE EVIDENCE":"SESSION EVIDENCE";el("live-transcript-title").textContent=state.scope==="live"?"Session stream":"Recorded session";el("live-transcript-search").placeholder=state.scope==="live"?"Search this stream…":"Search retained evidence…";el("live-transcript-list").setAttribute("aria-live",state.scope==="live"?"polite":"off");renderConnection();renderSessions();renderHealth();el("live-cursor").textContent=currentSnapshot().cursor||"";var source=sessionById(state.selected),s=state.playback.mode==="review"&&state.playback.session||source;renderGraph(s);renderPlayback();if(s&&state.fitFor!==sessionKey(s)){state.fitFor=sessionKey(s);(window.requestAnimationFrame||setTimeout)(function(){fit();});}} + function render(){el("panel-observability").dataset.scope=state.scope;document.querySelectorAll("[data-live-scope]").forEach(function(item){item.setAttribute("aria-selected",String(item.dataset.liveScope===state.scope));});var windowGroup=el("observability-window");if(windowGroup)windowGroup.hidden=state.scope!=="history";el("observability-title").textContent=state.scope==="live"?"Live agent activity":"Session history";el("live-sub").textContent=state.scope==="live"?"Follow current sessions, agents, tools, and supported evidence as work happens.":"Inspect retained, inert evidence from completed work. Scroll to load older sessions.";el("live-transcript-kicker").textContent=state.scope==="live"?"LIVE EVIDENCE":"SESSION EVIDENCE";el("live-transcript-title").textContent=state.scope==="live"?"Session stream":"Recorded session";el("live-transcript-search").placeholder=state.scope==="live"?"Search this stream…":"Search retained evidence…";el("live-transcript-list").setAttribute("aria-live",state.scope==="live"?"polite":"off");renderConnection();if(state.scope==="history")renderHistorySessions();else renderSessions();renderHealth();el("live-cursor").textContent=currentSnapshot().cursor||"";var source=sessionById(state.selected),s=state.playback.mode==="review"&&state.playback.session||source;renderGraph(s);renderPlayback();if(s&&state.fitFor!==sessionKey(s)){state.fitFor=sessionKey(s);(window.requestAnimationFrame||setTimeout)(function(){fit();});}} function closeTranscript(){if(state.transcript.source)state.transcript.source.close();state.transcript.source=null;state.transcript.status="idle";} function transcriptStatus(k,t){state.transcript.status=k;var e=el("live-transcript-state");e.dataset.state=k;e.textContent=t;} function transcriptItems(v){if(Array.isArray(v))return v;if(v&&v.snapshot&&Array.isArray(v.snapshot.events))return v.snapshot.events;if(v&&Array.isArray(v.events))return v.events;if(v&&Array.isArray(v.items))return v.items;if(v&&Array.isArray(v.turns))return v.turns;if(v&&v.item)return[v.item];if(v&&v.turn)return[v.turn];return v&&typeof v==="object"?[v]:[];} @@ -147,16 +155,16 @@ export const LIVE_JS = ` function selectSession(id,open){var s=sessionById(id),key=s?sessionKey(s):id;if(state.selected===key&&state.transcript.session===key)return;stopPlayback();state.selected=key||null;state.node=null;state.fitFor=null;state.camera={x:24,y:24,k:1};if(open!==false){if(isLiveSession(s)){state.playback.mode="live";openTranscript(s);}else if(s)loadPlayback(s);}} function selectNode(id){state.node=id||null;render();renderTranscript();var details=el("live-selection");if(id)details.open=true;var found=id&&tooltipValue(id).value;el("live-interaction-status").textContent=found?label(found)+" selected. Details opened.":"Showing all actors.";} function setTranscriptCollapsed(collapsed,persist){collapsed=!!collapsed;state.transcriptCollapsed=collapsed;var workspace=el("live-workspace"),panel=el("live-transcript-panel"),body=el("live-transcript-body"),button=el("live-transcript-toggle"),expanded=!collapsed;if(workspace)workspace.dataset.transcriptCollapsed=String(collapsed);if(panel)panel.dataset.collapsed=String(collapsed);if(body)body.hidden=collapsed;if(button){button.setAttribute("aria-expanded",String(expanded));button.setAttribute("aria-label",expanded?"Collapse Session stream":"Expand Session stream");button.title=expanded?"Collapse Session stream and expand Agent activity":"Expand Session stream";}if(persist!==false){try{localStorage.setItem(TRANSCRIPT_COLLAPSE_KEY,String(collapsed));}catch(_){}}if(el("live-interaction-status"))el("live-interaction-status").textContent=collapsed?"Session stream collapsed. Agent activity has more room.":"Session stream expanded.";} - function setScope(scope,sync){if(scope!=="live"&&scope!=="history")return false;if(scope===state.scope){render();return true;}closeTranscript();stopPlayback();state.scope=scope;state.browserLevel="projects";state.project=null;state.selected=null;state.node=null;state.transcript.follow=scope==="live";state.transcript.unread=0;state.playback.mode="live";if(scope==="history")fetchHistory();else render();if(sync!==false&&window.AKDashboardSyncHash)window.AKDashboardSyncHash();return true;} + function setScope(scope,sync){if(scope!=="live"&&scope!=="history")return false;if(scope===state.scope){render();return true;}var priorProject=state.project;closeTranscript();stopPlayback();if(state.historyObserver){state.historyObserver.disconnect();state.historyObserver=null;}state.scope=scope;state.browserLevel="projects";state.project=scope==="history"?priorProject:null;state.historyProjectKey=state.project;state.selected=null;state.node=null;state.transcript.follow=scope==="live";state.transcript.unread=0;state.playback.mode="live";if(scope==="history")fetchHistory({projectKey:state.project});else render();if(sync!==false&&window.AKDashboardSyncHash)window.AKDashboardSyncHash();return true;} function bind(){ setTranscriptCollapsed(state.transcriptCollapsed,false);el("live-transcript-toggle").addEventListener("click",function(){setTranscriptCollapsed(!state.transcriptCollapsed,true);}); el("live-pause").addEventListener("click",function(){state.paused=!state.paused;this.setAttribute("aria-pressed",String(state.paused));this.innerHTML=state.paused?"▶ Resume":"⏸ Pause";el("live-canvas").dataset.paused=String(state.paused);var svg=el("live-graph");if(state.paused&&svg.pauseAnimations)svg.pauseAnimations();else if(!state.paused&&svg.unpauseAnimations)svg.unpauseAnimations();if(!state.paused){var q=state.pending.slice();state.pending=[];if(state.overflow){state.overflow=false;connect();}else q.forEach(function(p){receive(p.kind,p.data);});}}); el("live-health-toggle").addEventListener("click",function(){var h=el("live-health"),open=h.hidden;h.hidden=!open;this.setAttribute("aria-expanded",String(open));if(open){var r=this.getBoundingClientRect(),w=Math.min(360,window.innerWidth-24);h.style.width=w+"px";h.style.left=Math.max(12,Math.min(window.innerWidth-w-12,r.right-w))+"px";h.style.top=Math.min(window.innerHeight-h.offsetHeight-12,r.bottom+8)+"px";}}); - el("live-project-list").addEventListener("click",function(e){var b=e.target.closest("[data-project]");if(!b)return;state.project=b.dataset.project;state.browserLevel="sessions";var list=visibleSessions();selectSession(list[0]&&sessionKey(list[0]));render();(el("live-session-list").querySelector("[data-session]")||el("live-browser-back")).focus();}); + el("live-project-list").addEventListener("click",function(e){var b=e.target.closest("[data-project]");if(!b)return;state.project=b.dataset.project;state.browserLevel="sessions";if(state.scope==="history"){fetchHistory({projectKey:state.project});return;}var list=visibleSessions();selectSession(list[0]&&sessionKey(list[0]));render();(el("live-session-list").querySelector("[data-session]")||el("live-browser-back")).focus();}); var scopeTabs=Array.from(document.querySelectorAll("[data-live-scope]"));function activateScope(button){if(button)setScope(button.dataset.liveScope,true);}scopeTabs.forEach(function(button){button.addEventListener("click",function(){activateScope(this);});});el("live-scope-tabs").addEventListener("keydown",function(e){if(!/^(ArrowLeft|ArrowRight|Home|End)$/.test(e.key))return;var current=Math.max(0,scopeTabs.indexOf(document.activeElement)),next=e.key==="Home"?0:e.key==="End"?scopeTabs.length-1:(current+(e.key==="ArrowRight"?1:-1)+scopeTabs.length)%scopeTabs.length;e.preventDefault();scopeTabs[next].focus();activateScope(scopeTabs[next]);}); - var windowChips=el("observability-window");if(windowChips)windowChips.addEventListener("click",function(e){var b=e.target.closest("[data-history-window]");if(!b)return;var token=b.dataset.historyWindow;if(token===state.historyWindow)return;state.historyWindow=token;var all=windowChips.querySelectorAll("[data-history-window]");for(var i=0;i} + * @returns {ReturnType & {coverage: object}} */ historySnapshot({ sinceMs = null } = {}) { - const claude = discoverJsonl(this.#options.roots.claude, { + return this.#scanHistory({ sinceMs }); + } + + /** + * Return one stable, project-scoped page from a retained history snapshot. + * The snapshot is cached briefly so scrolling does not rescan or reorder the + * same history set between requests. pageToken is intentionally opaque to + * callers and contains no filesystem identity. + * @param {{ sinceMs?: number|null, projectKey?: string|null, + * limit?: number, pageToken?: string|null }} [options] + */ + historyPage({ sinceMs = null, projectKey = null, limit = HISTORY_DEFAULT_PAGE_SIZE, + pageToken = null } = {}) { + const pageSize = Math.min(HISTORY_MAX_PAGE_SIZE, Math.max(1, + Number.parseInt(String(limit), 10) || HISTORY_DEFAULT_PAGE_SIZE)); + let entry; + let offset = 0; + if (pageToken) { + const token = decodeHistoryPageToken(pageToken); + entry = this.#historyPages.get(token.snapshotId); + if (!entry || Date.now() - entry.createdAt > HISTORY_PAGE_TTL_MS + || entry.projectKey !== (projectKey ?? null)) { + throw invalidHistoryPageToken(); + } + offset = Number.isInteger(token.offset) && token.offset >= 0 + && token.offset <= entry.sessions.length ? token.offset : -1; + if (offset < 0) throw invalidHistoryPageToken(); + } else { + const snapshot = this.#scanHistory({ sinceMs }); + const sessions = snapshot.sessions + .filter((session) => !projectKey || session.projectKey === projectKey) + .sort(compareHistorySessions); + entry = { snapshot, sessions, projectKey: projectKey ?? null, sinceMs: sinceMs ?? null, + snapshotId: randomUUID(), createdAt: Date.now() }; + this.#historyPages.set(entry.snapshotId, entry); + while (this.#historyPages.size > HISTORY_PAGE_CACHE_SIZE) { + this.#historyPages.delete(this.#historyPages.keys().next().value); + } + } + + const sessions = entry.sessions.slice(offset, offset + pageSize); + const nextOffset = offset + sessions.length; + const hasMore = nextOffset < entry.sessions.length; + return { + ...entry.snapshot, + sessions, + pagination: { + pageSize, offset, returned: sessions.length, + total: entry.sessions.length, + totalExact: entry.snapshot.coverage.complete, + hasMore, + nextPageToken: hasMore + ? encodeHistoryPageToken({ snapshotId: entry.snapshotId, offset: nextOffset }) : null, + }, + }; + } + + #scanHistory({ sinceMs = null } = {}) { + const claude = discoverJsonlDetailed(this.#options.roots.claude, { maxDepth: 3, maxFiles: HISTORY_MAX_FILES, sinceMs, accept: () => true, }); - const codex = discoverJsonl(this.#options.roots.codex, { - maxDepth: 4, maxFiles: HISTORY_MAX_FILES, sinceMs, accept: (name) => name.startsWith('rollout-'), + const codex = discoverJsonlDetailed(this.#options.roots.codex, { + maxDepth: 4, maxFiles: HISTORY_MAX_FILES, sinceMs, + accept: (name) => name.startsWith('rollout-'), }); let projection = emptyLiveProjection(); const ingest = (file, adapter, context) => { @@ -298,10 +364,10 @@ export class LiveSessionsService { } } }; - for (const file of claude) { + for (const file of claude.files) { ingest(file, 'claude', { adapter: 'claude', sessionId: path.basename(file, '.jsonl'), project: 'unknown' }); } - for (const file of codex) { + for (const file of codex.files) { ingest(file, 'codex', { adapter: 'codex', sessionId: codexTranscriptId(file), meta: {} }); } // A one-shot scan never observes the process ending, so the reducer's @@ -316,7 +382,19 @@ export class LiveSessionsService { projection = sweepLiveProjection(projection, { now: this.#options.now(), quiescentMs: 0, expiryMs: 0, pendingExpiryMs: 0, }); - return serializeLiveProjection(projection); + const snapshot = serializeLiveProjection(projection); + return { + ...snapshot, + coverage: { + complete: !claude.truncated && !codex.truncated, + timeBasis: 'file-mtime', + scannedAt: this.#options.now(), + sources: { + claude: historyDiscoveryCoverage(claude), + codex: historyDiscoveryCoverage(codex), + }, + }, + }; } /** Configuration reads are per-project, so memoize by session cwd. */ @@ -552,3 +630,39 @@ export class LiveSessionsService { }); } } + +function compareHistorySessions(left, right) { + return Date.parse(right.updatedAt ?? 0) - Date.parse(left.updatedAt ?? 0) + || String(left.host ?? '').localeCompare(String(right.host ?? '')) + || String(left.id ?? '').localeCompare(String(right.id ?? '')); +} + +function historyDiscoveryCoverage(discovery) { + return { + candidateFiles: discovery.candidateCount, + returnedFiles: discovery.returnedCount, + fileLimit: HISTORY_MAX_FILES, + truncated: discovery.truncated, + }; +} + +function encodeHistoryPageToken(value) { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +function decodeHistoryPageToken(value) { + try { + const parsed = JSON.parse(Buffer.from(String(value), 'base64url').toString('utf8')); + if (!parsed || typeof parsed.snapshotId !== 'string' + || !Number.isInteger(parsed.offset) || parsed.offset < 0) throw new Error('invalid'); + return parsed; + } catch { + throw invalidHistoryPageToken(); + } +} + +function invalidHistoryPageToken() { + return Object.assign(new Error('invalid history page token'), { + code: 'INVALID_HISTORY_PAGE_TOKEN', + }); +} diff --git a/src/lib/live/native-transcript-discovery.mjs b/src/lib/live/native-transcript-discovery.mjs index 3cfdcf0..a94db60 100644 --- a/src/lib/live/native-transcript-discovery.mjs +++ b/src/lib/live/native-transcript-discovery.mjs @@ -5,6 +5,8 @@ const safeEntries = (dir) => { try { return fs.readdirSync(dir, { withFileTypes: true }); } catch { return []; } }; +const DISCOVERY_HARD_LIMIT = 16_384; + /** * @param {string} root * @param {{ maxDepth: number, maxFiles: number, accept: (name: string) => boolean, @@ -14,11 +16,27 @@ const safeEntries = (dir) => { * existing caller (the live tailer, project-discovery) relies on. */ export function discoverJsonl(root, { maxDepth, maxFiles, accept, sinceMs = null }) { + return discoverJsonlDetailed(root, { maxDepth, maxFiles, accept, sinceMs }).files; +} + +/** + * Discover files while retaining enough bounded-source evidence for callers to + * say whether the returned set is complete. The legacy discoverJsonl() wrapper + * deliberately keeps returning only paths for existing tailer callers. + */ +export function discoverJsonlDetailed(root, { maxDepth, maxFiles, accept, sinceMs = null }) { const found = []; + let truncated = false; const visit = (dir, depth) => { - if (depth > maxDepth || found.length >= 4096) return; + if (depth > maxDepth || found.length >= DISCOVERY_HARD_LIMIT) { + if (found.length >= DISCOVERY_HARD_LIMIT) truncated = true; + return; + } for (const entry of safeEntries(dir)) { - if (found.length >= 4096) break; + if (found.length >= DISCOVERY_HARD_LIMIT) { + truncated = true; + break; + } const file = path.join(dir, entry.name); if (entry.isDirectory()) visit(file, depth + 1); else if (entry.isFile() && entry.name.endsWith('.jsonl') && accept(entry.name)) { @@ -30,8 +48,14 @@ export function discoverJsonl(root, { maxDepth, maxFiles, accept, sinceMs = null } }; visit(root, 0); - return found.sort((a, b) => b.mtimeMs - a.mtimeMs) - .slice(0, maxFiles).map((entry) => entry.file); + const ordered = found.sort((a, b) => b.mtimeMs - a.mtimeMs); + const files = ordered.slice(0, maxFiles).map((entry) => entry.file); + return { + files, + candidateCount: ordered.length, + returnedCount: files.length, + truncated: truncated || ordered.length > files.length, + }; } /** diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index fd8351b..0e7a858 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -1380,6 +1380,41 @@ async function main() { assert(historyCalls[2].sinceMs === null, '?window=all must scan without a cutoff'); }); + await test('GET /api/live/history adds pagination metadata without changing the legacy shape', async () => { + const pageCalls = []; + live.historyPage = (opts) => { + pageCalls.push(opts); + return { + schemaVersion: 2, cursor: null, + sessions: [{ id: 'h1', project: 'agentic-kit', projectKey: 'project:test', nodes: [], edges: [] }], + projects: [{ id: 'project:test', label: 'agentic-kit', sessionCount: 2 }], + pagination: { + pageSize: opts.limit, offset: 0, returned: 1, total: 2, + totalExact: true, hasMore: true, nextPageToken: 'opaque-page-token', + }, + coverage: { complete: true, timeBasis: 'file-mtime' }, + }; + }; + const r = await get(liveSrv.url + 'api/live/history?window=1y&limit=1&projectKey=project%3Atest', liveSrv.token); + assert(r.status === 200, 'expected 200, got ' + r.status); + const body = JSON.parse(r.body); + assert(body.pagination.nextPageToken === 'opaque-page-token', 'pagination token must pass through'); + assert(body.coverage.complete === true, 'coverage must pass through'); + assert(pageCalls[0].limit === 1, 'limit must pass through'); + assert(pageCalls[0].projectKey === 'project:test', 'project key must pass through'); + assert(!r.body.includes('/Users/private'), 'pagination must preserve the same privacy scrubber'); + + const historyPage = live.historyPage; + live.historyPage = undefined; + try { + const legacy = await get(liveSrv.url + 'api/live/history?window=1y&limit=1', liveSrv.token); + assert(legacy.status === 200, 'new callers must fall back to a pre-pagination service'); + assert(!JSON.parse(legacy.body).pagination, 'legacy fallback must preserve the old response shape'); + } finally { + live.historyPage = historyPage; + } + }); + await test('GET /api/live/events with no token → 401 before any subscribe', async () => { const before = liveCalls.subscribe; const r = await getRaw(liveSrv.port, '/api/live/events'); @@ -1806,7 +1841,7 @@ async function main() { // is the suite where it matters most — the traversal-guard and credential- // leak tests live here and were the reviewer's cited example of a block // that could silently vanish with the old harness never noticing. - const EXPECTED = 72; + const EXPECTED = 73; if (passed + failed !== EXPECTED) { console.error(`\nPLAN MISMATCH: expected ${EXPECTED} tests, ran ${passed + failed}`); process.exit(1); diff --git a/tests/kit/live-service.test.mjs b/tests/kit/live-service.test.mjs index 97349de..eeff9f1 100644 --- a/tests/kit/live-service.test.mjs +++ b/tests/kit/live-service.test.mjs @@ -673,3 +673,44 @@ test('historySnapshot() date-windows a one-shot scan without disturbing the live // projection is still empty — historySnapshot() must not have populated it. assert.deepEqual(service.snapshot().sessions, []); }); + +test('historyPage() pages the complete cross-host set after materialization', (t) => { + const sb = sandbox(); + for (let index = 0; index < 1001; index++) { + const id = `claude-${String(index).padStart(4, '0')}`; + fs.writeFileSync(path.join(sb.claude, `${id}.jsonl`), line({ + type: 'user', sessionId: id, timestamp: '2026-08-01T10:00:00Z', + cwd: '/Users/private-user/work/claude-project', message: { role: 'user' }, + })); + } + const codexFile = path.join(sb.codex, 'rollout-2026-08-02T10-00-00-codex.jsonl'); + fs.writeFileSync(codexFile, line({ + type: 'session_meta', timestamp: '2026-08-02T10:00:00Z', + payload: { id: 'codex-1', cwd: '/Users/private-user/work/codex-project', model_provider: 'openai' }, + })); + const service = new LiveSessionsService({ + roots: sb.roots, readCodexState: () => null, + now: () => '2026-08-03T12:00:00Z', + }); + t.after(() => service.close()); + + const all = service.historySnapshot(); + assert.equal(all.sessions.length, 1002, 'history must not apply the live 100-session bound'); + assert.equal(all.coverage.complete, true); + assert.equal(all.coverage.sources.claude.returnedFiles, 1001); + assert.equal(all.coverage.sources.codex.returnedFiles, 1); + + const seen = new Set(); + let page = service.historyPage({ limit: 100 }); + while (page) { + for (const session of page.sessions) { + assert.equal(seen.has(session.key), false, `duplicate session ${session.key}`); + seen.add(session.key); + } + if (!page.pagination.hasMore) break; + page = service.historyPage({ limit: 100, pageToken: page.pagination.nextPageToken }); + } + assert.equal(seen.size, 1002); + assert.equal([...seen].filter((key) => key.startsWith('claude:')).length, 1001); + assert.equal([...seen].filter((key) => key.startsWith('codex:')).length, 1); +}); diff --git a/tests/ui/dashboard-ui.mjs b/tests/ui/dashboard-ui.mjs index 57d1776..f403bf0 100644 --- a/tests/ui/dashboard-ui.mjs +++ b/tests/ui/dashboard-ui.mjs @@ -661,10 +661,25 @@ const LIVE_STUB = { start: async () => {}, snapshot: async () => LIVE_SNAPSHOT, // The fixture is static, so History's ?window= is a no-op here — the real - // LiveSessionsService.historySnapshot() date-windowing is covered by + // LiveSessionsService history scan and pagination are covered by // tests/kit/live-service.test.mjs; this stub only needs to exist so the // Observability → History tab has something to render end-to-end. historySnapshot: async () => LIVE_SNAPSHOT, + historyPage: async () => ({ + ...LIVE_SNAPSHOT, + pagination: { + pageSize: 100, offset: 0, returned: LIVE_SNAPSHOT.sessions.length, + total: LIVE_SNAPSHOT.sessions.length, totalExact: true, + hasMore: false, nextPageToken: null, + }, + coverage: { + complete: true, timeBasis: 'file-mtime', scannedAt: new Date().toISOString(), + sources: { + claude: { candidateFiles: 2, returnedFiles: 2, fileLimit: 8192, truncated: false }, + codex: { candidateFiles: 1, returnedFiles: 1, fileLimit: 8192, truncated: false }, + }, + }, + }), replay: async () => ({ reset: false, events: [] }), subscribe: () => () => {}, close: async () => {},