From 3f4f2d70af1b6db1bec8c96e557e8021fd96a46f Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 05:40:28 +0100 Subject: [PATCH] refactor(stores): apply branded types to conflicts and health Map keys --- v2/src/lib/stores/conflicts.svelte.ts | 40 +++-- v2/src/lib/stores/conflicts.test.ts | 245 +++++++++++++------------- v2/src/lib/stores/health.svelte.ts | 29 +-- 3 files changed, 163 insertions(+), 151 deletions(-) diff --git a/v2/src/lib/stores/conflicts.svelte.ts b/v2/src/lib/stores/conflicts.svelte.ts index f1cadd7..e5427f7 100644 --- a/v2/src/lib/stores/conflicts.svelte.ts +++ b/v2/src/lib/stores/conflicts.svelte.ts @@ -3,8 +3,10 @@ // Detects when two or more sessions write to the same file (file overlap conflict). // Also detects external filesystem writes (S-1 Phase 2) via inotify events. +import { SessionId, ProjectId, type SessionId as SessionIdType, type ProjectId as ProjectIdType } from '../types/ids'; + /** Sentinel session ID for external (non-agent) writes */ -export const EXTERNAL_SESSION_ID = '__external__'; +export const EXTERNAL_SESSION_ID = SessionId('__external__'); export interface FileConflict { /** Absolute file path */ @@ -12,7 +14,7 @@ export interface FileConflict { /** Short display name (last path segment) */ shortName: string; /** Session IDs that have written to this file */ - sessionIds: string[]; + sessionIds: SessionIdType[]; /** Timestamp of most recent write */ lastWriteTs: number; /** True if this conflict involves an external (non-agent) writer */ @@ -20,7 +22,7 @@ export interface FileConflict { } export interface ProjectConflicts { - projectId: string; + projectId: ProjectIdType; /** Active file conflicts (2+ sessions writing same file) */ conflicts: FileConflict[]; /** Total conflicting files */ @@ -32,21 +34,21 @@ export interface ProjectConflicts { // --- State --- interface FileWriteEntry { - sessionIds: Set; + sessionIds: Set; lastWriteTs: number; } // projectId -> filePath -> FileWriteEntry -let projectFileWrites = $state>>(new Map()); +let projectFileWrites = $state>>(new Map()); // projectId -> set of acknowledged file paths (suppresses badge until new conflict on that file) -let acknowledgedFiles = $state>>(new Map()); +let acknowledgedFiles = $state>>(new Map()); // sessionId -> worktree path (null = main working tree) -let sessionWorktrees = $state>(new Map()); +let sessionWorktrees = $state>(new Map()); // projectId -> filePath -> timestamp of most recent agent write (for external write heuristic) -let agentWriteTimestamps = $state>>(new Map()); +let agentWriteTimestamps = $state>>(new Map()); // Time window: if an fs event arrives within this window after an agent tool_call write, // it's attributed to the agent (suppressed). Otherwise it's external. @@ -55,12 +57,12 @@ const AGENT_WRITE_GRACE_MS = 2000; // --- Public API --- /** Register the worktree path for a session (null = main working tree) */ -export function setSessionWorktree(sessionId: string, worktreePath: string | null): void { +export function setSessionWorktree(sessionId: SessionIdType, worktreePath: string | null): void { sessionWorktrees.set(sessionId, worktreePath ?? null); } /** Check if two sessions are in different worktrees (conflict suppression) */ -function areInDifferentWorktrees(sessionIdA: string, sessionIdB: string): boolean { +function areInDifferentWorktrees(sessionIdA: SessionIdType, sessionIdB: SessionIdType): boolean { const wtA = sessionWorktrees.get(sessionIdA) ?? null; const wtB = sessionWorktrees.get(sessionIdB) ?? null; // Both null = same main tree, both same string = same worktree → not different @@ -70,7 +72,7 @@ function areInDifferentWorktrees(sessionIdA: string, sessionIdB: string): boolea } /** Record that a session wrote to a file. Returns true if this creates a new conflict. */ -export function recordFileWrite(projectId: string, sessionId: string, filePath: string): boolean { +export function recordFileWrite(projectId: ProjectIdType, sessionId: SessionIdType, filePath: string): boolean { let projectMap = projectFileWrites.get(projectId); if (!projectMap) { projectMap = new Map(); @@ -119,7 +121,7 @@ export function recordFileWrite(projectId: string, sessionId: string, filePath: * the write is attributed to the agent and suppressed. * Returns true if this creates a new external write conflict. */ -export function recordExternalWrite(projectId: string, filePath: string, timestampMs: number): boolean { +export function recordExternalWrite(projectId: ProjectIdType, filePath: string, timestampMs: number): boolean { // Timing heuristic: check if any agent recently wrote this file const tsMap = agentWriteTimestamps.get(projectId); if (tsMap) { @@ -141,7 +143,7 @@ export function recordExternalWrite(projectId: string, filePath: string, timesta } /** Get the count of external write conflicts for a project */ -export function getExternalConflictCount(projectId: string): number { +export function getExternalConflictCount(projectId: ProjectIdType): number { const projectMap = projectFileWrites.get(projectId); if (!projectMap) return 0; const ackSet = acknowledgedFiles.get(projectId); @@ -158,7 +160,7 @@ export function getExternalConflictCount(projectId: string): number { * Count sessions that are in a real conflict with the given session * (same worktree or both in main tree). Returns total including the session itself. */ -function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): number { +function countRealConflictSessions(entry: FileWriteEntry, forSessionId: SessionIdType): number { let count = 0; for (const sid of entry.sessionIds) { if (sid === forSessionId || !areInDifferentWorktrees(sid, forSessionId)) { @@ -169,7 +171,7 @@ function countRealConflictSessions(entry: FileWriteEntry, forSessionId: string): } /** Get all conflicts for a project (excludes acknowledged and worktree-suppressed) */ -export function getProjectConflicts(projectId: string): ProjectConflicts { +export function getProjectConflicts(projectId: ProjectIdType): ProjectConflicts { const projectMap = projectFileWrites.get(projectId); if (!projectMap) return { projectId, conflicts: [], conflictCount: 0, externalConflictCount: 0 }; @@ -196,7 +198,7 @@ export function getProjectConflicts(projectId: string): ProjectConflicts { } /** Check if a project has any unacknowledged real conflicts */ -export function hasConflicts(projectId: string): boolean { +export function hasConflicts(projectId: ProjectIdType): boolean { const projectMap = projectFileWrites.get(projectId); if (!projectMap) return false; const ackSet = acknowledgedFiles.get(projectId); @@ -232,7 +234,7 @@ function hasRealConflict(entry: FileWriteEntry): boolean { } /** Acknowledge all current conflicts for a project (suppresses badge until new conflict) */ -export function acknowledgeConflicts(projectId: string): void { +export function acknowledgeConflicts(projectId: ProjectIdType): void { const projectMap = projectFileWrites.get(projectId); if (!projectMap) return; @@ -246,7 +248,7 @@ export function acknowledgeConflicts(projectId: string): void { } /** Remove a session from all file write tracking (call on session end) */ -export function clearSessionWrites(projectId: string, sessionId: string): void { +export function clearSessionWrites(projectId: ProjectIdType, sessionId: SessionIdType): void { const projectMap = projectFileWrites.get(projectId); if (!projectMap) return; @@ -267,7 +269,7 @@ export function clearSessionWrites(projectId: string, sessionId: string): void { } /** Clear all conflict tracking for a project */ -export function clearProjectConflicts(projectId: string): void { +export function clearProjectConflicts(projectId: ProjectIdType): void { projectFileWrites.delete(projectId); acknowledgedFiles.delete(projectId); agentWriteTimestamps.delete(projectId); diff --git a/v2/src/lib/stores/conflicts.test.ts b/v2/src/lib/stores/conflicts.test.ts index 9dcb12f..86a1991 100644 --- a/v2/src/lib/stores/conflicts.test.ts +++ b/v2/src/lib/stores/conflicts.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'; +import { SessionId, ProjectId } from '../types/ids'; import { recordFileWrite, recordExternalWrite, @@ -14,6 +15,14 @@ import { EXTERNAL_SESSION_ID, } from './conflicts.svelte'; +// Test helpers — branded IDs +const P1 = ProjectId('proj-1'); +const P2 = ProjectId('proj-2'); +const SA = SessionId('sess-a'); +const SB = SessionId('sess-b'); +const SC = SessionId('sess-c'); +const SD = SessionId('sess-d'); + beforeEach(() => { clearAllConflicts(); }); @@ -21,66 +30,66 @@ beforeEach(() => { describe('conflicts store', () => { describe('recordFileWrite', () => { it('returns false for first write to a file', () => { - expect(recordFileWrite('proj-1', 'sess-a', '/src/main.ts')).toBe(false); + expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); }); it('returns false for same session writing same file again', () => { - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); - expect(recordFileWrite('proj-1', 'sess-a', '/src/main.ts')).toBe(false); + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P1, SA, '/src/main.ts')).toBe(false); }); it('returns true when a second session writes same file (new conflict)', () => { - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); - expect(recordFileWrite('proj-1', 'sess-b', '/src/main.ts')).toBe(true); + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P1, SB, '/src/main.ts')).toBe(true); }); it('returns false when third session writes already-conflicted file', () => { - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/main.ts'); - expect(recordFileWrite('proj-1', 'sess-c', '/src/main.ts')).toBe(false); + recordFileWrite(P1, SA, '/src/main.ts'); + recordFileWrite(P1, SB, '/src/main.ts'); + expect(recordFileWrite(P1, SC, '/src/main.ts')).toBe(false); }); it('tracks writes per project independently', () => { - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); - expect(recordFileWrite('proj-2', 'sess-b', '/src/main.ts')).toBe(false); + recordFileWrite(P1, SA, '/src/main.ts'); + expect(recordFileWrite(P2, SB, '/src/main.ts')).toBe(false); }); }); describe('getProjectConflicts', () => { it('returns empty for unknown project', () => { - const result = getProjectConflicts('nonexistent'); + const result = getProjectConflicts(ProjectId('nonexistent')); expect(result.conflicts).toEqual([]); expect(result.conflictCount).toBe(0); }); it('returns empty when no overlapping writes', () => { - recordFileWrite('proj-1', 'sess-a', '/src/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/b.ts'); - const result = getProjectConflicts('proj-1'); + recordFileWrite(P1, SA, '/src/a.ts'); + recordFileWrite(P1, SB, '/src/b.ts'); + const result = getProjectConflicts(P1); expect(result.conflicts).toEqual([]); expect(result.conflictCount).toBe(0); }); it('returns conflict when two sessions write same file', () => { - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/main.ts'); - const result = getProjectConflicts('proj-1'); + recordFileWrite(P1, SA, '/src/main.ts'); + recordFileWrite(P1, SB, '/src/main.ts'); + const result = getProjectConflicts(P1); expect(result.conflictCount).toBe(1); expect(result.conflicts[0].filePath).toBe('/src/main.ts'); expect(result.conflicts[0].shortName).toBe('main.ts'); - expect(result.conflicts[0].sessionIds).toContain('sess-a'); - expect(result.conflicts[0].sessionIds).toContain('sess-b'); + expect(result.conflicts[0].sessionIds).toContain(SA); + expect(result.conflicts[0].sessionIds).toContain(SB); }); it('returns multiple conflicts sorted by recency', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/old.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/old.ts'); + recordFileWrite(P1, SA, '/src/old.ts'); + recordFileWrite(P1, SB, '/src/old.ts'); vi.setSystemTime(2000); - recordFileWrite('proj-1', 'sess-a', '/src/new.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/new.ts'); - const result = getProjectConflicts('proj-1'); + recordFileWrite(P1, SA, '/src/new.ts'); + recordFileWrite(P1, SB, '/src/new.ts'); + const result = getProjectConflicts(P1); expect(result.conflictCount).toBe(2); // Most recent first expect(result.conflicts[0].filePath).toBe('/src/new.ts'); @@ -90,18 +99,18 @@ describe('conflicts store', () => { describe('hasConflicts', () => { it('returns false for unknown project', () => { - expect(hasConflicts('nonexistent')).toBe(false); + expect(hasConflicts(ProjectId('nonexistent'))).toBe(false); }); it('returns false with no overlapping writes', () => { - recordFileWrite('proj-1', 'sess-a', '/src/a.ts'); - expect(hasConflicts('proj-1')).toBe(false); + recordFileWrite(P1, SA, '/src/a.ts'); + expect(hasConflicts(P1)).toBe(false); }); it('returns true with overlapping writes', () => { - recordFileWrite('proj-1', 'sess-a', '/src/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); + recordFileWrite(P1, SA, '/src/a.ts'); + recordFileWrite(P1, SB, '/src/a.ts'); + expect(hasConflicts(P1)).toBe(true); }); }); @@ -111,50 +120,50 @@ describe('conflicts store', () => { }); it('counts conflicts across projects', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - recordFileWrite('proj-2', 'sess-c', '/b.ts'); - recordFileWrite('proj-2', 'sess-d', '/b.ts'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P2, SC, '/b.ts'); + recordFileWrite(P2, SD, '/b.ts'); expect(getTotalConflictCount()).toBe(2); }); }); describe('clearSessionWrites', () => { it('removes session from file write tracking', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); - clearSessionWrites('proj-1', 'sess-b'); - expect(hasConflicts('proj-1')).toBe(false); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + clearSessionWrites(P1, SB); + expect(hasConflicts(P1)).toBe(false); }); it('cleans up empty entries', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - clearSessionWrites('proj-1', 'sess-a'); - expect(getProjectConflicts('proj-1').conflictCount).toBe(0); + recordFileWrite(P1, SA, '/a.ts'); + clearSessionWrites(P1, SA); + expect(getProjectConflicts(P1).conflictCount).toBe(0); }); it('no-ops for unknown project', () => { - clearSessionWrites('nonexistent', 'sess-a'); // Should not throw + clearSessionWrites(ProjectId('nonexistent'), SA); // Should not throw }); }); describe('clearProjectConflicts', () => { it('clears all tracking for a project', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - clearProjectConflicts('proj-1'); - expect(hasConflicts('proj-1')).toBe(false); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + clearProjectConflicts(P1); + expect(hasConflicts(P1)).toBe(false); expect(getTotalConflictCount()).toBe(0); }); }); describe('clearAllConflicts', () => { it('clears everything', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - recordFileWrite('proj-2', 'sess-c', '/b.ts'); - recordFileWrite('proj-2', 'sess-d', '/b.ts'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P2, SC, '/b.ts'); + recordFileWrite(P2, SD, '/b.ts'); clearAllConflicts(); expect(getTotalConflictCount()).toBe(0); }); @@ -162,80 +171,80 @@ describe('conflicts store', () => { describe('acknowledgeConflicts', () => { it('suppresses conflict from counts after acknowledge', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); - acknowledgeConflicts('proj-1'); - expect(hasConflicts('proj-1')).toBe(false); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); expect(getTotalConflictCount()).toBe(0); - expect(getProjectConflicts('proj-1').conflictCount).toBe(0); + expect(getProjectConflicts(P1).conflictCount).toBe(0); }); it('resurfaces conflict when new write arrives on acknowledged file', () => { - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - acknowledgeConflicts('proj-1'); - expect(hasConflicts('proj-1')).toBe(false); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); // Third session writes same file — should resurface - recordFileWrite('proj-1', 'sess-c', '/a.ts'); + recordFileWrite(P1, SC, '/a.ts'); // recordFileWrite returns false for already-conflicted file, but the ack should be cleared - expect(hasConflicts('proj-1')).toBe(true); + expect(hasConflicts(P1)).toBe(true); }); it('no-ops for unknown project', () => { - acknowledgeConflicts('nonexistent'); // Should not throw + acknowledgeConflicts(ProjectId('nonexistent')); // Should not throw }); }); describe('worktree suppression', () => { it('suppresses conflict between sessions in different worktrees', () => { - setSessionWorktree('sess-a', null); // main tree - setSessionWorktree('sess-b', '/tmp/wt-1'); // worktree - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(false); + setSessionWorktree(SA, null); // main tree + setSessionWorktree(SB, '/tmp/wt-1'); // worktree + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(false); expect(getTotalConflictCount()).toBe(0); }); it('detects conflict between sessions in same worktree', () => { - setSessionWorktree('sess-a', '/tmp/wt-1'); - setSessionWorktree('sess-b', '/tmp/wt-1'); - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); + setSessionWorktree(SA, '/tmp/wt-1'); + setSessionWorktree(SB, '/tmp/wt-1'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); }); it('detects conflict between sessions both in main tree', () => { - setSessionWorktree('sess-a', null); - setSessionWorktree('sess-b', null); - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); + setSessionWorktree(SA, null); + setSessionWorktree(SB, null); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); }); it('suppresses conflict when two worktrees differ', () => { - setSessionWorktree('sess-a', '/tmp/wt-1'); - setSessionWorktree('sess-b', '/tmp/wt-2'); - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(false); + setSessionWorktree(SA, '/tmp/wt-1'); + setSessionWorktree(SB, '/tmp/wt-2'); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(false); }); it('sessions without worktree info conflict normally (backward compat)', () => { // No setSessionWorktree calls — both default to null (main tree) - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); + recordFileWrite(P1, SA, '/a.ts'); + recordFileWrite(P1, SB, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); }); it('clearSessionWrites cleans up worktree tracking', () => { - setSessionWorktree('sess-a', '/tmp/wt-1'); - recordFileWrite('proj-1', 'sess-a', '/a.ts'); - clearSessionWrites('proj-1', 'sess-a'); + setSessionWorktree(SA, '/tmp/wt-1'); + recordFileWrite(P1, SA, '/a.ts'); + clearSessionWrites(P1, SA); // Subsequent session in main tree should not be compared against stale wt data - recordFileWrite('proj-1', 'sess-b', '/a.ts'); - recordFileWrite('proj-1', 'sess-c', '/a.ts'); - expect(hasConflicts('proj-1')).toBe(true); + recordFileWrite(P1, SB, '/a.ts'); + recordFileWrite(P1, SC, '/a.ts'); + expect(hasConflicts(P1)).toBe(true); }); }); @@ -243,45 +252,45 @@ describe('conflicts store', () => { it('suppresses external write within grace period after agent write', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + recordFileWrite(P1, SA, '/src/main.ts'); // External write arrives 500ms later — within 2s grace period vi.setSystemTime(1500); - const result = recordExternalWrite('proj-1', '/src/main.ts', 1500); + const result = recordExternalWrite(P1, '/src/main.ts', 1500); expect(result).toBe(false); - expect(getExternalConflictCount('proj-1')).toBe(0); + expect(getExternalConflictCount(P1)).toBe(0); vi.useRealTimers(); }); it('detects external write outside grace period', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + recordFileWrite(P1, SA, '/src/main.ts'); // External write arrives 3s later — outside 2s grace period vi.setSystemTime(4000); - const result = recordExternalWrite('proj-1', '/src/main.ts', 4000); + const result = recordExternalWrite(P1, '/src/main.ts', 4000); expect(result).toBe(true); - expect(getExternalConflictCount('proj-1')).toBe(1); + expect(getExternalConflictCount(P1)).toBe(1); vi.useRealTimers(); }); it('ignores external write to file no agent has written', () => { - recordFileWrite('proj-1', 'sess-a', '/src/other.ts'); - const result = recordExternalWrite('proj-1', '/src/unrelated.ts', Date.now()); + recordFileWrite(P1, SA, '/src/other.ts'); + const result = recordExternalWrite(P1, '/src/unrelated.ts', Date.now()); expect(result).toBe(false); }); it('ignores external write for project with no agent writes', () => { - const result = recordExternalWrite('proj-1', '/src/main.ts', Date.now()); + const result = recordExternalWrite(P1, '/src/main.ts', Date.now()); expect(result).toBe(false); }); it('marks conflict as external in getProjectConflicts', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + recordFileWrite(P1, SA, '/src/main.ts'); vi.setSystemTime(4000); - recordExternalWrite('proj-1', '/src/main.ts', 4000); - const result = getProjectConflicts('proj-1'); + recordExternalWrite(P1, '/src/main.ts', 4000); + const result = getProjectConflicts(P1); expect(result.conflictCount).toBe(1); expect(result.externalConflictCount).toBe(1); expect(result.conflicts[0].isExternal).toBe(true); @@ -292,24 +301,24 @@ describe('conflicts store', () => { it('external conflicts can be acknowledged', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + recordFileWrite(P1, SA, '/src/main.ts'); vi.setSystemTime(4000); - recordExternalWrite('proj-1', '/src/main.ts', 4000); - expect(hasConflicts('proj-1')).toBe(true); - acknowledgeConflicts('proj-1'); - expect(hasConflicts('proj-1')).toBe(false); - expect(getExternalConflictCount('proj-1')).toBe(0); + recordExternalWrite(P1, '/src/main.ts', 4000); + expect(hasConflicts(P1)).toBe(true); + acknowledgeConflicts(P1); + expect(hasConflicts(P1)).toBe(false); + expect(getExternalConflictCount(P1)).toBe(0); vi.useRealTimers(); }); it('clearAllConflicts clears external write timestamps', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/main.ts'); + recordFileWrite(P1, SA, '/src/main.ts'); clearAllConflicts(); // After clearing, external writes should not create conflicts (no agent writes tracked) vi.setSystemTime(4000); - const result = recordExternalWrite('proj-1', '/src/main.ts', 4000); + const result = recordExternalWrite(P1, '/src/main.ts', 4000); expect(result).toBe(false); vi.useRealTimers(); }); @@ -317,12 +326,12 @@ describe('conflicts store', () => { it('external conflict coexists with agent-agent conflict', () => { vi.useFakeTimers(); vi.setSystemTime(1000); - recordFileWrite('proj-1', 'sess-a', '/src/agent.ts'); - recordFileWrite('proj-1', 'sess-b', '/src/agent.ts'); - recordFileWrite('proj-1', 'sess-a', '/src/ext.ts'); + recordFileWrite(P1, SA, '/src/agent.ts'); + recordFileWrite(P1, SB, '/src/agent.ts'); + recordFileWrite(P1, SA, '/src/ext.ts'); vi.setSystemTime(4000); - recordExternalWrite('proj-1', '/src/ext.ts', 4000); - const result = getProjectConflicts('proj-1'); + recordExternalWrite(P1, '/src/ext.ts', 4000); + const result = getProjectConflicts(P1); expect(result.conflictCount).toBe(2); expect(result.externalConflictCount).toBe(1); const extConflict = result.conflicts.find(c => c.isExternal); diff --git a/v2/src/lib/stores/health.svelte.ts b/v2/src/lib/stores/health.svelte.ts index bdfe91b..43ba9da 100644 --- a/v2/src/lib/stores/health.svelte.ts +++ b/v2/src/lib/stores/health.svelte.ts @@ -1,6 +1,7 @@ // Project health tracking — Svelte 5 runes // Tracks per-project activity state, burn rate, context pressure, and attention scoring +import type { SessionId as SessionIdType, ProjectId as ProjectIdType } from '../types/ids'; import { getAgentSession, type AgentSession } from './agents.svelte'; import { getProjectConflicts } from './conflicts.svelte'; import { scoreAttention } from '../utils/attention-scorer'; @@ -10,8 +11,8 @@ import { scoreAttention } from '../utils/attention-scorer'; export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled'; export interface ProjectHealth { - projectId: string; - sessionId: string | null; + projectId: ProjectIdType; + sessionId: SessionIdType | null; /** Current activity state */ activityState: ActivityState; /** Name of currently running tool (if any) */ @@ -56,8 +57,8 @@ const DEFAULT_CONTEXT_LIMIT = 200_000; // --- State --- interface ProjectTracker { - projectId: string; - sessionId: string | null; + projectId: ProjectIdType; + sessionId: SessionIdType | null; lastActivityTs: number; // epoch ms lastToolName: string | null; toolInFlight: boolean; @@ -67,15 +68,15 @@ interface ProjectTracker { costSnapshots: Array<[number, number]>; } -let trackers = $state>(new Map()); -let stallThresholds = $state>(new Map()); // projectId → ms +let trackers = $state>(new Map()); +let stallThresholds = $state>(new Map()); // projectId → ms let tickTs = $state(Date.now()); let tickInterval: ReturnType | null = null; // --- Public API --- /** Register a project for health tracking */ -export function trackProject(projectId: string, sessionId: string | null): void { +export function trackProject(projectId: ProjectIdType, sessionId: SessionIdType | null): void { const existing = trackers.get(projectId); if (existing) { existing.sessionId = sessionId; @@ -93,12 +94,12 @@ export function trackProject(projectId: string, sessionId: string | null): void } /** Remove a project from health tracking */ -export function untrackProject(projectId: string): void { +export function untrackProject(projectId: ProjectIdType): void { trackers.delete(projectId); } /** Set per-project stall threshold in minutes (null to use default) */ -export function setStallThreshold(projectId: string, minutes: number | null): void { +export function setStallThreshold(projectId: ProjectIdType, minutes: number | null): void { if (minutes === null) { stallThresholds.delete(projectId); } else { @@ -107,7 +108,7 @@ export function setStallThreshold(projectId: string, minutes: number | null): vo } /** Update session ID for a tracked project */ -export function updateProjectSession(projectId: string, sessionId: string): void { +export function updateProjectSession(projectId: ProjectIdType, sessionId: SessionIdType): void { const t = trackers.get(projectId); if (t) { t.sessionId = sessionId; @@ -115,7 +116,7 @@ export function updateProjectSession(projectId: string, sessionId: string): void } /** Record activity — call on every agent message. Auto-starts tick if stopped. */ -export function recordActivity(projectId: string, toolName?: string): void { +export function recordActivity(projectId: ProjectIdType, toolName?: string): void { const t = trackers.get(projectId); if (!t) return; t.lastActivityTs = Date.now(); @@ -128,7 +129,7 @@ export function recordActivity(projectId: string, toolName?: string): void { } /** Record tool completion */ -export function recordToolDone(projectId: string): void { +export function recordToolDone(projectId: ProjectIdType): void { const t = trackers.get(projectId); if (!t) return; t.lastActivityTs = Date.now(); @@ -136,7 +137,7 @@ export function recordToolDone(projectId: string): void { } /** Record a token/cost snapshot for burn rate calculation */ -export function recordTokenSnapshot(projectId: string, totalTokens: number, costUsd: number): void { +export function recordTokenSnapshot(projectId: ProjectIdType, totalTokens: number, costUsd: number): void { const t = trackers.get(projectId); if (!t) return; const now = Date.now(); @@ -272,7 +273,7 @@ function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth { } /** Get health for a single project (reactive via tickTs) */ -export function getProjectHealth(projectId: string): ProjectHealth | null { +export function getProjectHealth(projectId: ProjectIdType): ProjectHealth | null { // Touch tickTs to make this reactive to the timer const now = tickTs; const t = trackers.get(projectId);