Skip to content

Commit 8f9ea00

Browse files
authored
Merge pull request #17 from firstsun-dev/worktree-snuggly-riding-sedgewick
fix: dashboard bulk-analyze false failures + codex/copilot message-id collision
2 parents 421afd7 + e5215c5 commit 8f9ea00

8 files changed

Lines changed: 76 additions & 14 deletions

File tree

cli/src/providers/codex.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,7 @@ function parseFormatA(content: string): ParsedSession | null {
231231
} : null;
232232

233233
messages.push({
234-
id: `codex-assistant-${messages.length}`,
234+
id: `${sessionId}:assistant-${messages.length}`,
235235
sessionId: sessionId,
236236
type: 'assistant',
237237
content: text.slice(0, 10000),
@@ -306,7 +306,7 @@ function parseFormatA(content: string): ParsedSession | null {
306306
const msgText = (payload.message as string) || '';
307307
if (msgText && !isSystemContextMessage(msgText)) {
308308
messages.push({
309-
id: (payload.id as string) || `codex-user-${messages.length}`,
309+
id: `${sessionId}:${(payload.id as string) || `user-${messages.length}`}`,
310310
sessionId: sessionId,
311311
type: 'user',
312312
content: msgText.slice(0, 10000),
@@ -522,7 +522,7 @@ function parseFormatB(content: string): ParsedSession | null {
522522
if (currentToolCalls.length === 0 && !currentThinking) return;
523523

524524
messages.push({
525-
id: `codex-assistant-${messages.length}`,
525+
id: `${sessionId}:assistant-${messages.length}`,
526526
sessionId: sessionId,
527527
type: 'assistant',
528528
content: '',
@@ -548,7 +548,7 @@ function parseFormatB(content: string): ParsedSession | null {
548548
const userContent = extractFormatBContent(item.content);
549549
if (userContent && !isSystemContextMessage(userContent)) {
550550
messages.push({
551-
id: `codex-user-${messages.length}`,
551+
id: `${sessionId}:user-${messages.length}`,
552552
sessionId: sessionId,
553553
type: 'user',
554554
content: userContent.slice(0, 10000),

cli/src/providers/copilot-cli.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,7 @@ function parseCopilotSession(filePath: string): ParsedSession | null {
188188
if (!text && currentToolCalls.length === 0) return;
189189

190190
messages.push({
191-
id: `copilot-assistant-${messages.length}`,
191+
id: `${sessionId}:assistant-${messages.length}`,
192192
sessionId: sessionId,
193193
type: 'assistant',
194194
content: text.slice(0, 10000),
@@ -251,7 +251,7 @@ function parseCopilotSession(filePath: string): ParsedSession | null {
251251
const userContent = extractText(data);
252252
if (userContent) {
253253
messages.push({
254-
id: (data.id as string) || `copilot-user-${messages.length}`,
254+
id: `${sessionId}:${(data.id as string) || `user-${messages.length}`}`,
255255
sessionId: sessionId,
256256
type: 'user',
257257
content: userContent.slice(0, 10000),

dashboard/src/components/sessions/SessionListPanel.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { extractPQScore } from '@/lib/score-utils';
2121
import { SearchX, Terminal, EyeOff, CalendarDays } from 'lucide-react';
2222
import { useDeletedSessionCount } from '@/hooks/useSessions';
2323
import { useQueuedSessionIds } from '@/hooks/useAnalysisQueue';
24+
import { useAnalyzedSessionIds } from '@/hooks/useAnalyzedSessionIds';
2425
import { SaveFilterPopover } from '@/components/filters/SaveFilterPopover';
2526
import { SavedFiltersDropdown } from '@/components/filters/SavedFiltersDropdown';
2627
import { SourceToolSelect } from '@/components/filters/SourceToolSelect';
@@ -100,10 +101,11 @@ export function SessionListPanel({
100101

101102
const { data: deletedCount = 0 } = useDeletedSessionCount(projectId);
102103
const queuedSessionIds = useQueuedSessionIds();
103-
const analyzedSessionIds = useMemo(
104-
() => new Set(insights.map((i) => i.session_id)),
105-
[insights]
106-
);
104+
// Sourced from analysis_usage, not `insights` — insights has no safe row cap to
105+
// rely on for "is this session analyzed" at scale (a single session's analysis
106+
// produces 5-10+ insight rows), so a capped insights query would silently
107+
// misclassify already-analyzed sessions as unanalyzed on large histories.
108+
const { data: analyzedSessionIds = new Set<string>() } = useAnalyzedSessionIds();
107109

108110
const insightCountsBySession = useMemo(() => {
109111
const map = new Map<string, Record<string, number>>();
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { useQuery } from '@tanstack/react-query';
2+
import { fetchAnalyzedSessionIds } from '@/lib/api';
3+
4+
/**
5+
* Session IDs with a completed session analysis, sourced from analysis_usage
6+
* (one row per session) rather than the insights table — insights has no row
7+
* cap safe to rely on for "is this session analyzed" checks at scale, since a
8+
* single session can produce 5-10+ insight rows.
9+
*/
10+
export function useAnalyzedSessionIds() {
11+
return useQuery({
12+
queryKey: ['analyzedSessionIds'],
13+
queryFn: () => fetchAnalyzedSessionIds().then((r) => new Set(r.sessionIds)),
14+
});
15+
}

dashboard/src/lib/api.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,6 +552,12 @@ export function fetchAnalysisQueue() {
552552
return request<AnalysisQueueStatus>('/analysis/queue');
553553
}
554554

555+
// ── Analyzed session IDs ───────────────────────────────────────────────────────
556+
557+
export function fetchAnalyzedSessionIds() {
558+
return request<{ sessionIds: string[] }>('/analysis/analyzed-session-ids');
559+
}
560+
555561
// ── Facets ─────────────────────────────────────────────────────────────────────
556562

557563
export interface FacetRow {

dashboard/src/pages/DashboardPage.tsx

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Link } from 'react-router';
33
import { useDashboardStats, useDailyStats } from '@/hooks/useAnalytics';
44
import { useSessions } from '@/hooks/useSessions';
55
import { useInsights } from '@/hooks/useInsights';
6+
import { useAnalyzedSessionIds } from '@/hooks/useAnalyzedSessionIds';
67
import { useProjects } from '@/hooks/useProjects';
78
import { StatsHero } from '@/components/dashboard/StatsHero';
89
import { DashboardActivityChart } from '@/components/dashboard/DashboardActivityChart';
@@ -34,19 +35,24 @@ export default function DashboardPage() {
3435
const { data: dailyStats = [], isLoading: dailyLoading, isError: dailyError, refetch: refetchDaily } = useDailyStats(range, effectiveHomeId);
3536
const { data: sessions = [], isLoading: sessionsLoading, isError: sessionsError, refetch: refetchSessions } = useSessions({ limit: 500, ...(homeId !== 'all' && { homeId }) });
3637
const { data: insights = [], isLoading: insightsLoading } = useInsights();
38+
const { data: analyzedSessionIds, isLoading: analyzedIdsLoading } = useAnalyzedSessionIds();
3739
const { data: projects = [] } = useProjects();
3840

39-
const loading = statsLoading || sessionsLoading || insightsLoading || dailyLoading;
41+
const loading = statsLoading || sessionsLoading || insightsLoading || dailyLoading || analyzedIdsLoading;
4042
const hasError = statsError || sessionsError || dailyError;
4143

4244
const todayLabel = new Date().toLocaleDateString(undefined, {
4345
month: 'long',
4446
day: 'numeric',
4547
});
4648

47-
// Sessions not yet analyzed
48-
const analyzedSessionIds = new Set(insights.map((i) => i.session_id));
49-
const unanalyzedSessions = sessions.filter((s) => !analyzedSessionIds.has(s.id));
49+
// Sessions not yet analyzed. Sourced from analysis_usage (via useAnalyzedSessionIds),
50+
// not the insights list — insights has no safe row cap to rely on at scale (a single
51+
// session's analysis produces 5-10+ insight rows), so a capped insights query would
52+
// silently misclassify already-analyzed sessions as unanalyzed on large histories.
53+
const unanalyzedSessions = analyzedSessionIds
54+
? sessions.filter((s) => !analyzedSessionIds.has(s.id))
55+
: [];
5056

5157
// Compute stats for hero — all from dashStats (range-filtered)
5258
const totalTokens = dashStats

server/src/routes/analysis.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
AnalysisResultSchema,
1212
AnalysisUsageResponseSchema,
1313
AnalysisUsageQuerySchema,
14+
AnalyzedSessionIdsResponseSchema,
1415
RecurringInsightResultSchema,
1516
} from '../schemas/analysis.js';
1617
import {
@@ -100,6 +101,32 @@ app.openapi(usageRoute, (c) => {
100101
}, 200);
101102
});
102103

104+
// GET /api/analysis/analyzed-session-ids
105+
// Returns every session_id that has a completed 'session' analysis in analysis_usage.
106+
// Used by the dashboard to determine which sessions still need analysis — deliberately
107+
// sourced from analysis_usage (one row per session, PRIMARY KEY (session_id, analysis_type))
108+
// rather than the insights table, which has no such bound: a single session's analysis
109+
// produces 5-10+ insight rows, so a capped/paginated insights query silently truncates
110+
// on large histories and misclassifies already-analyzed sessions as unanalyzed.
111+
const analyzedSessionIdsRoute = createRoute({
112+
method: 'get',
113+
path: '/analyzed-session-ids',
114+
responses: {
115+
200: {
116+
content: { 'application/json': { schema: AnalyzedSessionIdsResponseSchema } },
117+
description: 'Session IDs with a completed session analysis',
118+
},
119+
},
120+
});
121+
122+
app.openapi(analyzedSessionIdsRoute, (c) => {
123+
const db = getDb();
124+
const rows = db.prepare(
125+
`SELECT session_id FROM analysis_usage WHERE analysis_type = 'session'`
126+
).all() as Array<{ session_id: string }>;
127+
return c.json({ sessionIds: rows.map((r) => r.session_id) }, 200);
128+
});
129+
103130
// POST /api/analysis/session
104131
// Body: { sessionId: string }
105132
// Fetches session + messages from SQLite, runs LLM analysis, saves insights, returns results.

server/src/schemas/analysis.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ export const AnalysisUsageQuerySchema = z.object({
8383

8484
export const SessionIdBodyResponseSchema = AnalysisResultSchema;
8585

86+
export const AnalyzedSessionIdsResponseSchema = z
87+
.object({
88+
sessionIds: z.array(z.string()),
89+
})
90+
.openapi('AnalyzedSessionIdsResponse');
91+
8692
/** Mirrors server/src/llm/recurring-insights.ts RecurringInsightResult. */
8793
export const RecurringInsightResultSchema = z
8894
.object({

0 commit comments

Comments
 (0)