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