Skip to content

Commit 5cb0d71

Browse files
authored
refactor(iris): split ContextStore into persistence/scoring/repository/snapshot units (#122)
* test(iris): cover active-clear-without-presence and migration-session quirks in ContextStore Two safety-net tests added before the upcoming ContextStore split refactor: 1. removeExercise/removeCourse clears activeContext when the requested id matches activeContext.id, even if the item is absent from recentExercises/allExercises (the current code is id-match driven, not list-presence driven). 2. migrateState() preserves sessions and activeSessionId across version bumps, while loadState() strips them for current-version records. The asymmetry is intentional and now under test. These quirks are otherwise uncovered by the existing 39-test suite and would be easy to silently lose during the upcoming class extraction. * refactor(iris): extract priority scoring from ContextStore Move PRIORITY/TIME_WINDOW weight tables, MS_PER_DAY/MS_PER_HOUR units, the two list comparators, and calculateExercisePriority / calculateCoursePriority into a new pure module src/extension/services/iris/contextPriorityScorer.ts. The calculate functions become free functions (they only depended on their argument and Date.now()). PRIORITY and TIME_WINDOW stay module-internal. ContextStore drops from 611 to 498 LOC and now imports the four public functions. No behavior change. ContextStore test suite (incl. the 3 Task 0 safety-net tests) still green. * refactor(iris): extract ContextStore persistence layer Move the load/save/migrate persistence logic out of ContextStore into a dedicated ContextPersistence class. Move the StoredState interface to a small shared types file (contextStateTypes.ts) so persistence and ContextStore can share the type without one importing the other's class. Behavior is preserved verbatim, including the asymmetry covered by the Task 0 migration safety-net test: - load() of a current-version record strips sessions and activeSessionId - migrate() of an older-version record preserves both via ?? fallbacks ContextStore: 498 → 435 LOC. New files: contextPersistence.ts (62 LOC), contextStateTypes.ts (12 LOC). * refactor(iris): extract ContextStore snapshot projection Move the snapshot building logic out of ContextStore into a pure buildContextSnapshot function in contextSnapshot.ts. SESSION_KEY_SEPARATOR and getContextKey move with it (they were only used by snapshot in this file; SessionManager has its own private copies). ContextStore.snapshot() is now a one-liner that delegates to the pure function. ContextStore: 435 → 401 LOC. * refactor(iris): extract tracked-item repository from ContextStore Move all exercise/course tracking logic out of ContextStore into a new TrackedItemRepository class that mirrors SessionManager's callback-injection pattern. ARCHIVE_LIMITS, ExerciseInput, and CourseInput move with it; ContextStore re-exports the two input types so any future callers that import them through the facade keep working. The repository owns: upsertExercise / upsertCourse, removeExercise / removeCourse (no active-context interaction — that stays in the facade), recalculate*Priorities (recent-only, preserving invariant), trim*History, getExerciseById, getWorkspaceExercise, plus the private workspace-flag clearing and upsertList helpers. All mutation rules are ported verbatim: existing-lookup order (allExercises first), workspace-flag exclusivity (cleared in both lists before merge), recent-only priority recalc. ContextStore is now a thin facade that wires the four collaborators (persistence, snapshot, repository, session manager) and keeps active context state + the change event. removeExercise/removeCourse preserve the current double-save and id-match-driven clearing semantics (invariants 6 and 7), locked in by Task 0 safety-net tests. ContextStore: 401 → 225 LOC. New file: trackedItemRepository.ts (218 LOC). No behavior change. * refactor(iris): restore explicit ExerciseInput/CourseInput on ContextStore facade After Task 4, registerExercise / registerCourse exposed Parameters<TrackedItemRepository[...]>[0] derived types in their public signatures, which structurally matched the original ExerciseInput / CourseInput but coupled the facade's public API to the repository class. Import the two interfaces explicitly and reference them by name in the parameter positions. The facade re-exports them so existing import paths through ContextStore keep working. Pure source-level cleanup, no behavior or runtime change. * refactor(iris): reorganize services/iris into chat/ context/ transport/ Sort the now-17 iris/ files into three concern-based subfolders so the directory listing maps to architecture instead of alphabet: - chat/ UI/webview routing (chat*, helpContent, messageUtils, irisWebSocketMessageHandler) - context/ state storage (contextStore + the Task 1-4 splits, sessionManager, sessionSyncUtils) - transport/ WS session infrastructure (irisWebSocketSessionClient) iris/index.ts public surface is unchanged — every existing import via '../services/iris' keeps working. Only one external file (chatViewStatePresenter) used a deep path and was updated. Internal imports between iris/ files were rewritten to the new relative paths. Mechanical move via git mv (so renames are tracked); no behavior change. * chore(iris): demote unused exports to module-private to satisfy knip Knip flagged getContextKey, BuildSnapshotOptions, ExerciseInput/CourseInput re-export, and TrackedItemRepositoryOptions as unused exports. None are consumed outside their declaring modules — drop the export keyword.
1 parent 691abd6 commit 5cb0d71

24 files changed

Lines changed: 863 additions & 675 deletions

extension/src/extension/provider/chatViewStatePresenter.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import * as vscode from 'vscode';
22
import type { StoredSession, ContextSnapshot } from '../types';
3-
import type { ContextStore } from '../services/iris/contextStore';
3+
import type { ContextStore } from '../services/iris/context/contextStore';
44
import { ExtensionMsg } from '../../shared/messageContracts';
55
import type { ExtMsg, ExtensionToWebviewMessage } from '../../shared/messageContracts';
66

extension/src/extension/services/iris/chatContextManager.ts renamed to extension/src/extension/services/iris/chat/chatContextManager.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { IrisChatSessionService } from './chatSessionService';
2-
import { IrisWebSocketSessionClient } from './irisWebSocketSessionClient';
3-
import { ActiveContext, ChatContextType, ContextSnapshot, TrackedExercise } from '../../types';
4-
import { logger, LogCategory } from '../loggingService';
5-
import { ExtensionMsg } from '../../../shared/messageContracts';
6-
import type { IrisServiceDeps } from './sessionSyncUtils';
2+
import { IrisWebSocketSessionClient } from '../transport/irisWebSocketSessionClient';
3+
import { ActiveContext, ChatContextType, ContextSnapshot, TrackedExercise } from '../../../types';
4+
import { logger, LogCategory } from '../../loggingService';
5+
import { ExtensionMsg } from '../../../../shared/messageContracts';
6+
import type { IrisServiceDeps } from '../context/sessionSyncUtils';
77

88
// ── Policy helpers (pure functions) ──────────────────────────────
99

extension/src/extension/services/iris/chatDiagnosticsService.ts renamed to extension/src/extension/services/iris/chat/chatDiagnosticsService.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import { ContextStore } from './contextStore';
2-
import { ArtemisApiService } from '../../api';
3-
import { ExerciseRegistry } from '../exerciseRegistry';
4-
import { logger } from '../loggingService';
5-
import { fetchSessionsWithMessages } from './sessionSyncUtils';
1+
import { ContextStore } from '../context/contextStore';
2+
import { ArtemisApiService } from '../../../api';
3+
import { ExerciseRegistry } from '../../exerciseRegistry';
4+
import { logger } from '../../loggingService';
5+
import { fetchSessionsWithMessages } from '../context/sessionSyncUtils';
66

77
interface DebugSessionsResult {
88
report: string;

extension/src/extension/services/iris/chatMessageService.ts renamed to extension/src/extension/services/iris/chat/chatMessageService.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
import * as vscode from 'vscode';
2-
import { ArtemisWebsocketService } from '../websocket/artemisWebsocketService';
3-
import { IrisWebSocketSessionClient } from './irisWebSocketSessionClient';
2+
import { ArtemisWebsocketService } from '../../websocket/artemisWebsocketService';
3+
import { IrisWebSocketSessionClient } from '../transport/irisWebSocketSessionClient';
44
import { IrisChatSessionService } from './chatSessionService';
5-
import { ActiveContext } from '../../types';
6-
import { checkWorkspaceFiles } from '../workspace/workspaceFileChecker';
7-
import { StruggleContext } from '../telemetry';
8-
import { logger, LogCategory } from '../loggingService';
9-
import { ExtensionMsg } from '../../../shared/messageContracts';
10-
import type { IrisServiceDeps } from './sessionSyncUtils';
5+
import { ActiveContext } from '../../../types';
6+
import { checkWorkspaceFiles } from '../../workspace/workspaceFileChecker';
7+
import { StruggleContext } from '../../telemetry';
8+
import { logger, LogCategory } from '../../loggingService';
9+
import { ExtensionMsg } from '../../../../shared/messageContracts';
10+
import type { IrisServiceDeps } from '../context/sessionSyncUtils';
1111

1212
interface SendMessageInput {
1313
text: string;

extension/src/extension/services/iris/chatSessionService.ts renamed to extension/src/extension/services/iris/chat/chatSessionService.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
1-
import { ActiveContext, ApiError, type IrisChatMessage, type IrisSettingsResponse } from '../../types';
2-
import type { IrisWebSocketSessionClient } from './irisWebSocketSessionClient';
1+
import { ActiveContext, ApiError, type IrisChatMessage, type IrisSettingsResponse } from '../../../types';
2+
import type { IrisWebSocketSessionClient } from '../transport/irisWebSocketSessionClient';
33
import { extractIrisMessageContent } from './messageUtils';
4-
import { logger, LogCategory } from '../loggingService';
5-
import { ExtensionMsg } from '../../../shared/messageContracts';
6-
import { fetchSessionsWithMessages, importSessionsToStore } from './sessionSyncUtils';
7-
import type { IrisServiceDeps } from './sessionSyncUtils';
4+
import { logger, LogCategory } from '../../loggingService';
5+
import { ExtensionMsg } from '../../../../shared/messageContracts';
6+
import { fetchSessionsWithMessages, importSessionsToStore } from '../context/sessionSyncUtils';
7+
import type { IrisServiceDeps } from '../context/sessionSyncUtils';
88

99
/**
1010
* Orchestrates Iris chat session lifecycle (create, load, switch).

extension/src/extension/services/iris/helpContent.ts renamed to extension/src/extension/services/iris/chat/helpContent.ts

File renamed without changes.

extension/src/extension/services/iris/irisWebSocketMessageHandler.ts renamed to extension/src/extension/services/iris/chat/irisWebSocketMessageHandler.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
import * as vscode from 'vscode';
2-
import { ArtemisWebsocketService } from '../websocket/artemisWebsocketService';
3-
import { IrisWebSocketSessionClient } from './irisWebSocketSessionClient';
4-
import type { IrisChatMessage, IrisStageDTO } from '../../types';
5-
import { logger, LogCategory } from '../loggingService';
2+
import { ArtemisWebsocketService } from '../../websocket/artemisWebsocketService';
3+
import { IrisWebSocketSessionClient } from '../transport/irisWebSocketSessionClient';
4+
import type { IrisChatMessage, IrisStageDTO } from '../../../types';
5+
import { logger, LogCategory } from '../../loggingService';
66
import { extractIrisMessageContent } from './messageUtils';
7-
import { ExtensionMsg } from '../../../shared/messageContracts';
8-
import type { ExtensionToWebviewMessage, WebSocketDisplayStatus } from '../../../shared/messageContracts';
7+
import { ExtensionMsg } from '../../../../shared/messageContracts';
8+
import type { ExtensionToWebviewMessage, WebSocketDisplayStatus } from '../../../../shared/messageContracts';
99

1010
type ReconnectResult =
1111
| { status: 'reconnected' }

extension/src/extension/services/iris/messageUtils.ts renamed to extension/src/extension/services/iris/chat/messageUtils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { IrisChatMessageContent } from '../../types';
1+
import type { IrisChatMessageContent } from '../../../types';
22

33
export function extractIrisMessageContent(content: unknown): string {
44
if (content && Array.isArray(content) && content.length > 0) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import * as vscode from 'vscode';
2+
import type { StoredState } from './contextStateTypes';
3+
import { logger } from '../../loggingService';
4+
5+
const STORE_KEY = 'iris.contextStore';
6+
const STORE_VERSION = 1;
7+
8+
export class ContextPersistence {
9+
constructor(private readonly _context: vscode.ExtensionContext) {}
10+
11+
public load(): StoredState {
12+
const raw = this._context.globalState.get<StoredState>(STORE_KEY);
13+
if (!raw) {
14+
return this.defaultState();
15+
}
16+
if (raw.version !== STORE_VERSION) {
17+
return this.migrate(raw);
18+
}
19+
// Don't load sessions from storage - always start fresh
20+
return {
21+
...raw,
22+
sessions: {},
23+
activeSessionId: null,
24+
};
25+
}
26+
27+
public save(state: StoredState): void {
28+
// Don't persist sessions and activeSessionId - only save exercise/course tracking
29+
const stateToPersist: StoredState = {
30+
...state,
31+
sessions: {}, // Never persist sessions
32+
activeSessionId: null, // Never persist active session
33+
};
34+
this._context.globalState.update(STORE_KEY, stateToPersist).then(undefined, (err: unknown) => logger.error('Failed to persist state', undefined, err));
35+
}
36+
37+
private migrate(previous: StoredState): StoredState {
38+
return {
39+
version: STORE_VERSION,
40+
activeContext: previous.activeContext ?? null,
41+
activeSessionId: previous.activeSessionId ?? null,
42+
recentExercises: previous.recentExercises ?? [],
43+
recentCourses: previous.recentCourses ?? [],
44+
allExercises: previous.allExercises ?? [],
45+
allCourses: previous.allCourses ?? [],
46+
sessions: previous.sessions ?? {},
47+
};
48+
}
49+
50+
private defaultState(): StoredState {
51+
return {
52+
version: STORE_VERSION,
53+
activeContext: null,
54+
activeSessionId: null,
55+
recentExercises: [],
56+
recentCourses: [],
57+
allExercises: [],
58+
allCourses: [],
59+
sessions: {},
60+
};
61+
}
62+
}
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import { TrackedCourse, TrackedExercise } from '../../../types';
2+
3+
/**
4+
* Priority scoring weights for exercise ranking in the context selector.
5+
*
6+
* The priority system uses additive scores to surface the most relevant exercise:
7+
* - WORKSPACE_BOOST (1000): Dominant — current workspace always ranks first.
8+
* - DUE_SOON_MAX/FLOOR (200/170): Upcoming deadlines get high urgency.
9+
* Floor of 170 ensures even exercises due in 7 days outrank RECENTLY_RELEASED (100).
10+
* Score decays linearly from 200→170 as daysUntilDue goes 0→7.
11+
* - RECENTLY_RELEASED (100): Newly released exercises surface for a week.
12+
* - VIEWED_RECENTLY (50): Small recency bonus for exercises opened in last 24h.
13+
* - FULLY_SCORED_PENALTY (-100): Completed exercises deprioritized.
14+
*
15+
* Tiebreaker: newer release dates get a micro-bonus (~0.001 points/day).
16+
*/
17+
const PRIORITY = {
18+
WORKSPACE_BOOST: 1000,
19+
RECENTLY_RELEASED: 100,
20+
DUE_SOON_MAX: 200,
21+
DUE_SOON_FLOOR: 170,
22+
VIEWED_RECENTLY: 50,
23+
FULLY_SCORED_PENALTY: -100,
24+
COURSE_VIEWED_RECENTLY: 100,
25+
} as const;
26+
27+
/** Time windows for priority scoring (see PRIORITY for how these are used). */
28+
const TIME_WINDOW = {
29+
RECENT_RELEASE_DAYS: 7,
30+
DUE_SOON_DAYS: 7,
31+
VIEWED_RECENTLY_HOURS: 24,
32+
} as const;
33+
34+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
35+
const MS_PER_HOUR = 60 * 60 * 1000;
36+
37+
function now(): number {
38+
return Date.now();
39+
}
40+
41+
// ── Comparators ───────────────────────────────────────────────────
42+
43+
/** Sort by priority descending, break ties by most-recently-viewed. */
44+
export function byPriorityThenRecency(
45+
a: { priority: number; lastViewed?: number },
46+
b: { priority: number; lastViewed?: number },
47+
): number {
48+
return b.priority - a.priority || (b.lastViewed ?? 0) - (a.lastViewed ?? 0);
49+
}
50+
51+
/** Sort by lastViewed descending (most recent first). */
52+
export function byLastViewedDesc(
53+
a: { lastViewed?: number },
54+
b: { lastViewed?: number },
55+
): number {
56+
return (b.lastViewed ?? 0) - (a.lastViewed ?? 0);
57+
}
58+
59+
export function calculateExercisePriority(exercise: TrackedExercise): number {
60+
const current = now();
61+
let priority = 0;
62+
63+
if (exercise.isWorkspace) {
64+
priority += PRIORITY.WORKSPACE_BOOST;
65+
}
66+
67+
if (exercise.releaseDate) {
68+
const releaseTime = new Date(exercise.releaseDate).getTime();
69+
const daysSinceRelease = (current - releaseTime) / MS_PER_DAY;
70+
if (daysSinceRelease >= 0 && daysSinceRelease <= TIME_WINDOW.RECENT_RELEASE_DAYS) {
71+
priority += PRIORITY.RECENTLY_RELEASED;
72+
}
73+
}
74+
75+
if (exercise.dueDate) {
76+
const dueTime = new Date(exercise.dueDate).getTime();
77+
const daysUntilDue = (dueTime - current) / MS_PER_DAY;
78+
if (daysUntilDue >= 0 && daysUntilDue <= TIME_WINDOW.DUE_SOON_DAYS) {
79+
// Higher urgency closer to deadline (scales from DUE_SOON_MAX down to DUE_SOON_FLOOR)
80+
const dueSoonSpread = PRIORITY.DUE_SOON_MAX - PRIORITY.DUE_SOON_FLOOR;
81+
const urgencyDecay = Math.floor(daysUntilDue * dueSoonSpread / TIME_WINDOW.DUE_SOON_DAYS);
82+
priority += Math.max(PRIORITY.DUE_SOON_MAX - urgencyDecay, PRIORITY.DUE_SOON_FLOOR);
83+
}
84+
}
85+
86+
if (exercise.lastViewed) {
87+
const hoursSinceView = (current - exercise.lastViewed) / MS_PER_HOUR;
88+
if (hoursSinceView <= TIME_WINDOW.VIEWED_RECENTLY_HOURS) {
89+
priority += PRIORITY.VIEWED_RECENTLY;
90+
}
91+
}
92+
93+
// Tiny tiebreaker: newer releases rank slightly higher
94+
if (exercise.releaseDate) {
95+
const releaseTime = new Date(exercise.releaseDate).getTime();
96+
priority += Math.floor(releaseTime / MS_PER_DAY / 1000);
97+
}
98+
99+
if (exercise.score === 100) {
100+
priority += PRIORITY.FULLY_SCORED_PENALTY;
101+
}
102+
103+
return priority;
104+
}
105+
106+
export function calculateCoursePriority(course: TrackedCourse): number {
107+
const current = now();
108+
let priority = 0;
109+
110+
if (course.lastViewed) {
111+
const hoursSinceView = (current - course.lastViewed) / MS_PER_HOUR;
112+
if (hoursSinceView <= TIME_WINDOW.VIEWED_RECENTLY_HOURS) {
113+
priority += PRIORITY.COURSE_VIEWED_RECENTLY;
114+
}
115+
}
116+
117+
// Tiny tiebreaker: more recently viewed courses rank slightly higher
118+
priority += Math.floor(((course.lastViewed ?? current) / MS_PER_DAY) / 1000);
119+
return priority;
120+
}

0 commit comments

Comments
 (0)