feat(task-change-ledger): merge review hardening

This commit is contained in:
777genius 2026-04-21 17:22:01 +03:00
commit 7b486b7fea
92 changed files with 3890 additions and 577 deletions

View file

@ -642,7 +642,8 @@ export class TeamGraphAdapter {
reviewerName: isReviewCycle ? reviewerName : null,
reviewMode: isReviewCycle ? (reviewerName ? 'assigned' : 'manual') : undefined,
reviewerColor: reviewerName ? memberColorByName.get(reviewerName) : undefined,
changePresence: task.changePresence,
changePresence:
task.changePresence === 'needs_attention' ? 'has_changes' : task.changePresence,
displayId: task.displayId ?? undefined,
ownerId: ownerMemberId,
needsClarification: task.needsClarification ?? null,

View file

@ -454,7 +454,8 @@ async function handleGetGitFileLog(
async function handleLoadDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
scopeKey: string,
scopeToken: string | null = null
): Promise<
IpcResult<{
hunkDecisions: Record<string, HunkDecision>;
@ -462,19 +463,23 @@ async function handleLoadDecisions(
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>
> {
return wrapReviewHandler('loadDecisions', () => reviewDecisionStore.load(teamName, scopeKey));
return wrapReviewHandler('loadDecisions', () =>
reviewDecisionStore.load(teamName, scopeKey, scopeToken ?? undefined)
);
}
async function handleSaveDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string,
scopeToken: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile: Record<string, Record<number, string>> | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('saveDecisions', () =>
reviewDecisionStore.save(teamName, scopeKey, {
scopeToken,
hunkDecisions,
fileDecisions,
hunkContextHashesByFile: hunkContextHashesByFile ?? undefined,
@ -485,7 +490,10 @@ async function handleSaveDecisions(
async function handleClearDecisions(
_event: IpcMainInvokeEvent,
teamName: string,
scopeKey: string
scopeKey: string,
scopeToken: string | null = null
): Promise<IpcResult<void>> {
return wrapReviewHandler('clearDecisions', () => reviewDecisionStore.clear(teamName, scopeKey));
return wrapReviewHandler('clearDecisions', () =>
reviewDecisionStore.clear(teamName, scopeKey, scopeToken ?? undefined)
);
}

View file

@ -177,6 +177,7 @@ import type {
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
TaskChangePresenceState,
TaskComment,
TaskRef,
TeamAgentRuntimeSnapshot,
@ -917,7 +918,7 @@ async function handleGetData(
async function handleGetTaskChangePresence(
_event: IpcMainInvokeEvent,
teamName: unknown
): Promise<IpcResult<Record<string, 'has_changes' | 'no_changes' | 'unknown'>>> {
): Promise<IpcResult<Record<string, TaskChangePresenceState>>> {
const validated = validateTeamName(teamName);
if (!validated.valid) {
return { success: false, error: validated.error ?? 'Invalid teamName' };

View file

@ -31,6 +31,7 @@ import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient';
import type { TeamLogSourceTracker } from './TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types';
import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
const logger = createLogger('Service:ChangeExtractorService');
@ -759,11 +760,8 @@ export class ChangeExtractorService {
return;
}
if (
result.files.length === 0 &&
result.confidence !== 'high' &&
result.confidence !== 'medium'
) {
const resolvedPresence = resolveTaskChangePresenceFromResult(result);
if (!resolvedPresence) {
return;
}
@ -789,7 +787,7 @@ export class ChangeExtractorService {
{
taskId,
taskSignature: descriptor.taskSignature,
presence: result.files.length > 0 ? 'has_changes' : 'no_changes',
presence: resolvedPresence,
writtenAt: now,
logSourceGeneration: snapshot.logSourceGeneration,
}

View file

@ -269,16 +269,7 @@ export class FileContentResolver {
modified: string | null;
source: FileChangeWithContent['contentSource'];
} | null {
const ledgerSnippets = snippets
.filter((snippet) => snippet.ledger && !snippet.isError)
.sort((a, b) => {
const aTime = Date.parse(a.timestamp);
const bTime = Date.parse(b.timestamp);
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) {
return aTime - bTime;
}
return a.toolUseId.localeCompare(b.toolUseId);
});
const ledgerSnippets = snippets.filter((snippet) => snippet.ledger && !snippet.isError);
if (ledgerSnippets.length === 0) {
return null;
@ -289,8 +280,19 @@ export class FileContentResolver {
if (!first || !last) {
return null;
}
const original = first.originalFullContent ?? (first.operation === 'create' ? '' : null);
const modified = last.modifiedFullContent ?? (last.operation === 'delete' ? '' : null);
const canUseSyntheticOriginal =
first.originalFullContent === null &&
first.operation === 'create' &&
last.modifiedFullContent !== null &&
!first.beforeState?.unavailableReason;
const canUseSyntheticModified =
last.modifiedFullContent === null &&
last.operation === 'delete' &&
first.originalFullContent !== null &&
!last.afterState?.unavailableReason;
const original = first.originalFullContent ?? (canUseSyntheticOriginal ? '' : null);
const modified = last.modifiedFullContent ?? (canUseSyntheticModified ? '' : null);
if (original === null && modified === null) {
return null;
}

View file

@ -454,9 +454,6 @@ export class ReviewApplierService {
}
const fullReject = fileRejected || allHunksRejected;
const hasSnapshot = ledgerSnippets.some(
(snippet) => snippet.type === 'shell-snapshot' || snippet.type === 'hook-snapshot'
);
const hasUnavailableState = ledgerSnippets.some(
(snippet) =>
snippet.ledger?.beforeState?.unavailableReason ||
@ -465,26 +462,26 @@ export class ReviewApplierService {
const relation = this.resolveLedgerRelation(ledgerSnippets);
if (!fullReject) {
if (relation?.kind === 'rename') {
if (relation?.kind === 'rename' || relation?.kind === 'copy') {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger rename partial reject requires manual review.',
error: `Ledger ${relation.kind} partial reject requires manual review.`,
};
}
if (!hasSnapshot) {
return { handled: false };
}
if (original === null || modified === null) {
return {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger snapshot content is unavailable; partial reject requires manual review.',
error: 'Ledger full text is unavailable; partial reject requires manual review.',
};
}
const guard = await this.checkLedgerCurrentHash(filePath, lastLedger.afterState?.sha256);
const guard = await this.checkLedgerCurrentHash(
filePath,
lastLedger.afterState?.sha256 ?? lastLedger.afterHash ?? undefined
);
if (!guard.ok) {
return guard.outcome;
}
@ -494,7 +491,7 @@ export class ReviewApplierService {
handled: true,
status: 'error',
code: 'manual-review-required',
error: 'Ledger snapshot partial reject could not be applied safely.',
error: 'Ledger partial reject could not be applied safely.',
};
}
try {
@ -598,7 +595,7 @@ export class ReviewApplierService {
return {
handled: true,
status: 'error',
code: hasUnavailableState ? 'manual-review-required' : 'unavailable',
code: 'manual-review-required',
error:
'Ledger before content is unavailable; rejecting this change requires manual review.',
};

View file

@ -1,5 +1,6 @@
import { getTeamsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import { createHash } from 'crypto';
import * as fs from 'fs';
import * as path from 'path';
@ -10,6 +11,7 @@ import type { HunkDecision } from '@shared/types';
const logger = createLogger('ReviewDecisionStore');
export interface ReviewDecisionsData {
scopeToken?: string;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
/** filePath -> (hunkIndex -> contextHash) */
@ -17,50 +19,60 @@ export interface ReviewDecisionsData {
updatedAt: string;
}
interface ReviewDecisionsDataV2 extends ReviewDecisionsData {
version: 2;
scopeKey: string;
scopeToken: string;
}
export class ReviewDecisionStore {
private getDirPath(teamName: string): string {
private getLegacyDirPath(teamName: string): string {
return path.join(getTeamsBasePath(), teamName, 'review-decisions');
}
private getFilePath(teamName: string, scopeKey: string): string {
return path.join(this.getDirPath(teamName), `${scopeKey}.json`);
private getLegacyFilePath(teamName: string, scopeKey: string): string {
return path.join(this.getLegacyDirPath(teamName), `${scopeKey}.json`);
}
async load(
teamName: string,
scopeKey: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
const filePath = this.getFilePath(teamName, scopeKey);
private getV2DirPath(teamName: string, scopeKey: string): string {
return path.join(
this.getLegacyDirPath(teamName),
'v2',
encodeURIComponent(scopeKey)
);
}
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
logger.error(`Corrupted review decisions file for ${teamName}/${scopeKey}`);
return null;
}
private getV2FilePath(teamName: string, scopeKey: string, scopeToken: string): string {
const scopeHash = createHash('sha256').update(scopeToken).digest('hex');
return path.join(this.getV2DirPath(teamName, scopeKey), `${scopeHash}.json`);
}
private parseStoredData(parsed: unknown): ReviewDecisionsData | ReviewDecisionsDataV2 | null {
if (!parsed || typeof parsed !== 'object') {
return null;
}
const data = parsed as Partial<ReviewDecisionsData>;
const data = parsed as Partial<ReviewDecisionsDataV2>;
const isV2 =
data.version === 2 &&
typeof data.scopeKey === 'string' &&
typeof data.scopeToken === 'string';
if (data.version !== undefined && !isV2) {
return null;
}
return data as ReviewDecisionsData | ReviewDecisionsDataV2;
}
private extractDecisions(
data: ReviewDecisionsData | ReviewDecisionsDataV2,
scopeToken?: string
): {
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null {
const hunkDecisions: Record<string, HunkDecision> =
data.hunkDecisions && typeof data.hunkDecisions === 'object' ? data.hunkDecisions : {};
const fileDecisions: Record<string, HunkDecision> =
@ -70,37 +82,162 @@ export class ReviewDecisionStore {
? data.hunkContextHashesByFile
: undefined;
if (scopeToken) {
if (typeof data.scopeToken !== 'string' || data.scopeToken !== scopeToken) {
return null;
}
}
return { hunkDecisions, fileDecisions, hunkContextHashesByFile };
}
private async loadFromPath(
filePath: string,
scopeToken?: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
let raw: string;
try {
raw = await fs.promises.readFile(filePath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return null;
}
logger.error(`Failed to read review decisions at ${filePath}: ${String(error)}`);
return null;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
logger.error(`Corrupted review decisions file at ${filePath}`);
return null;
}
const data = this.parseStoredData(parsed);
return data ? this.extractDecisions(data, scopeToken) : null;
}
private async pruneScopeDir(teamName: string, scopeKey: string): Promise<void> {
const dirPath = this.getV2DirPath(teamName, scopeKey);
let entries: string[];
try {
entries = await fs.promises.readdir(dirPath);
} catch {
return;
}
if (entries.length <= 16) {
return;
}
const files = await Promise.all(
entries
.filter((entry) => entry.endsWith('.json'))
.map(async (entry) => {
const filePath = path.join(dirPath, entry);
try {
const stats = await fs.promises.stat(filePath);
return { filePath, mtimeMs: stats.mtimeMs };
} catch {
return null;
}
})
);
const staleFiles = files
.filter((entry): entry is { filePath: string; mtimeMs: number } => !!entry)
.sort((a, b) => b.mtimeMs - a.mtimeMs)
.slice(16);
await Promise.all(
staleFiles.map((entry) =>
fs.promises.unlink(entry.filePath).catch(() => undefined)
)
);
}
async load(
teamName: string,
scopeKey: string,
scopeToken?: string
): Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null> {
if (scopeToken) {
const exact = await this.loadFromPath(
this.getV2FilePath(teamName, scopeKey, scopeToken),
scopeToken
);
if (exact) {
return exact;
}
}
return this.loadFromPath(this.getLegacyFilePath(teamName, scopeKey), scopeToken);
}
async save(
teamName: string,
scopeKey: string,
data: {
scopeToken: string;
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
}
): Promise<void> {
try {
const payload: ReviewDecisionsData = {
const payload: ReviewDecisionsDataV2 = {
version: 2,
scopeKey,
scopeToken: data.scopeToken,
hunkDecisions: data.hunkDecisions,
fileDecisions: data.fileDecisions,
hunkContextHashesByFile: data.hunkContextHashesByFile,
updatedAt: new Date().toISOString(),
};
const filePath = this.getV2FilePath(teamName, scopeKey, data.scopeToken);
await atomicWriteAsync(
this.getFilePath(teamName, scopeKey),
filePath,
JSON.stringify(payload, null, 2)
);
await this.pruneScopeDir(teamName, scopeKey);
} catch (error) {
logger.error(`Failed to save review decisions for ${teamName}/${scopeKey}: ${String(error)}`);
}
}
async clear(teamName: string, scopeKey: string): Promise<void> {
async clear(teamName: string, scopeKey: string, scopeToken?: string): Promise<void> {
try {
await fs.promises.unlink(this.getFilePath(teamName, scopeKey));
if (scopeToken) {
await fs.promises
.unlink(this.getV2FilePath(teamName, scopeKey, scopeToken))
.catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
const legacyPath = this.getLegacyFilePath(teamName, scopeKey);
const legacy = await this.loadFromPath(legacyPath, scopeToken);
if (legacy) {
await fs.promises.unlink(legacyPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') throw error;
});
}
return;
}
await fs.promises.unlink(this.getLegacyFilePath(teamName, scopeKey)).catch((error) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
});
await fs.promises.rm(this.getV2DirPath(teamName, scopeKey), {
recursive: true,
force: true,
});
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error(

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,7 @@ import type { FSWatcher } from 'chokidar';
const logger = createLogger('Service:TeamLogSourceTracker');
const BOARD_TASK_LOG_FRESHNESS_DIRNAME = '.board-task-log-freshness';
const BOARD_TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
const BOARD_TASK_CHANGES_DIRNAME = '.board-task-changes';
const BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX = '.json';
interface TeamLogSourceSnapshot {
@ -42,6 +43,28 @@ interface TrackingState {
lifecycleVersion: number;
}
type DecodedFreshnessTaskId =
| { kind: 'task-id'; taskId: string }
| { kind: 'opaque-safe-segment' }
| { kind: 'invalid' };
function isOpaqueSafeTaskIdSegment(segment: string): boolean {
return /^task-id-[0-9a-f]{32}$/.test(segment);
}
export function shouldIgnoreLogSourceWatcherPath(
projectDir: string,
watchedPath: string
): boolean {
const relativePath = path.relative(projectDir, watchedPath);
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
return false;
}
const parts = relativePath.split(path.sep).filter(Boolean);
return parts[0] === BOARD_TASK_CHANGES_DIRNAME;
}
export class TeamLogSourceTracker {
private readonly stateByTeam = new Map<string, TrackingState>();
private emitter: ((event: TeamChangeEvent) => void) | null = null;
@ -276,6 +299,7 @@ export class TeamLogSourceTracker {
ignorePermissionErrors: true,
followSymlinks: false,
depth: 3,
ignored: (watchedPath) => shouldIgnoreLogSourceWatcherPath(projectDir, watchedPath),
awaitWriteFinish: {
stabilityThreshold: 250,
pollInterval: 50,
@ -343,37 +367,68 @@ export class TeamLogSourceTracker {
return true;
}
const taskId = this.decodeTaskLogFreshnessTaskId(relativePath);
if (!taskId) {
const decoded = this.decodeTaskLogFreshnessTaskId(relativePath);
if (decoded.kind === 'invalid') {
return true;
}
if (decoded.kind === 'opaque-safe-segment') {
void this.emitTaskFreshnessSignalFromFile(teamName, changedPath);
return true;
}
this.emitter?.({
type: 'task-log-change',
teamName,
taskId,
taskId: decoded.taskId,
});
return true;
}
private decodeTaskLogFreshnessTaskId(fileName: string): string | null {
private decodeTaskLogFreshnessTaskId(fileName: string): DecodedFreshnessTaskId {
if (!fileName.endsWith(BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX)) {
return null;
return { kind: 'invalid' };
}
const encodedTaskId = fileName.slice(0, -BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX.length);
if (!encodedTaskId) {
return null;
return { kind: 'invalid' };
}
if (isOpaqueSafeTaskIdSegment(encodedTaskId)) {
return { kind: 'opaque-safe-segment' };
}
try {
const taskId = decodeURIComponent(encodedTaskId);
return taskId.trim().length > 0 ? taskId : null;
return taskId.trim().length > 0
? { kind: 'task-id', taskId }
: { kind: 'invalid' };
} catch {
return null;
return { kind: 'invalid' };
}
}
private async emitTaskFreshnessSignalFromFile(teamName: string, filePath: string): Promise<void> {
try {
const raw = await fs.readFile(filePath, 'utf8');
const parsed = JSON.parse(raw) as Record<string, unknown>;
const taskId =
typeof parsed.taskId === 'string' && parsed.taskId.trim().length > 0
? parsed.taskId.trim()
: null;
if (taskId) {
this.emitter?.({
type: 'task-log-change',
teamName,
taskId,
});
return;
}
} catch {
// Deletions or partially unavailable files still need a team-level refresh.
}
this.emitLogSourceChange(teamName);
}
private async recompute(teamName: string): Promise<TeamLogSourceSnapshot> {
const state = this.getOrCreateState(teamName);
if (this.getActiveConsumerCount(state) === 0) {

View file

@ -10,7 +10,10 @@ import {
} from './taskChangePresenceCacheSchema';
import { TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION } from './taskChangePresenceCacheTypes';
import type { PersistedTaskChangePresenceIndex } from './taskChangePresenceCacheTypes';
import type {
PersistedTaskChangePresence,
PersistedTaskChangePresenceIndex,
} from './taskChangePresenceCacheTypes';
import type { TaskChangePresenceRepository } from './TaskChangePresenceRepository';
const logger = createLogger('Service:JsonTaskChangePresenceRepository');
@ -87,7 +90,7 @@ export class JsonTaskChangePresenceRepository implements TaskChangePresenceRepos
entry: {
taskId: string;
taskSignature: string;
presence: 'has_changes' | 'no_changes';
presence: PersistedTaskChangePresence;
writtenAt: string;
logSourceGeneration: string;
}

View file

@ -1,4 +1,5 @@
import {
LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
type PersistedTaskChangePresence,
type PersistedTaskChangePresenceEntry,
type PersistedTaskChangePresenceIndex,
@ -10,7 +11,9 @@ function isIsoString(value: unknown): value is string {
}
function normalizePresence(value: unknown): PersistedTaskChangePresence | null {
return value === 'has_changes' || value === 'no_changes' ? value : null;
return value === 'has_changes' || value === 'needs_attention' || value === 'no_changes'
? value
: null;
}
function normalizeEntry(taskId: string, value: unknown): PersistedTaskChangePresenceEntry | null {
@ -47,8 +50,11 @@ export function normalizePersistedTaskChangePresenceIndex(
}
const raw = value as Record<string, unknown>;
const rawVersion =
typeof raw.version === 'number' ? raw.version : Number.NaN;
if (
raw.version !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION ||
(rawVersion !== TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION &&
rawVersion !== LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION) ||
typeof raw.teamName !== 'string' ||
typeof raw.projectFingerprint !== 'string' ||
raw.projectFingerprint.length === 0 ||

View file

@ -1,6 +1,7 @@
import type { TaskChangePresenceState } from '@shared/types/team';
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
export const LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 1;
export const TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION = 2;
export type PersistedTaskChangePresence = Exclude<TaskChangePresenceState, 'unknown'>;

View file

@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as path from 'path';
@ -14,8 +15,40 @@ interface ParsedFreshnessSignal {
transcriptFileBasename?: string;
}
function isWindowsReservedArtifactSegment(segment: string): boolean {
const stem = segment.split('.')[0]?.toUpperCase() ?? '';
return (
!segment ||
stem === 'CON' ||
stem === 'PRN' ||
stem === 'AUX' ||
stem === 'NUL' ||
/^COM[1-9]$/.test(stem) ||
/^LPT[1-9]$/.test(stem)
);
}
function encodeTaskId(taskId: string): string {
return encodeURIComponent(taskId);
const encoded = encodeURIComponent(taskId);
return isWindowsReservedArtifactSegment(encoded)
? `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`
: encoded;
}
function taskIdArtifactSegments(taskId: string): string[] {
const safe = encodeTaskId(taskId);
const legacy = encodeURIComponent(taskId);
return safe === legacy ? [safe] : [safe, legacy];
}
function taskSignalPathCandidates(projectDir: string, taskId: string): string[] {
return taskIdArtifactSegments(taskId).map((segment) =>
path.join(
projectDir,
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
`${segment}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`
)
);
}
function isValidTimestamp(value: unknown): value is string {
@ -30,28 +63,25 @@ export class TeamTaskLogFreshnessReader {
taskIds: string[]
): Promise<Map<string, TaskLogFreshnessSignal>> {
const uniqueTaskIds = [...new Set(taskIds)].filter((taskId) => taskId.trim().length > 0).sort();
const signalFilePaths = uniqueTaskIds.map((taskId) =>
path.join(
projectDir,
BOARD_TASK_LOG_FRESHNESS_DIRNAME,
`${encodeTaskId(taskId)}${BOARD_TASK_LOG_FRESHNESS_FILE_SUFFIX}`
)
const signalFilePathCandidates = uniqueTaskIds.map((taskId) =>
taskSignalPathCandidates(projectDir, taskId)
);
this.cache.retainOnly(new Set(signalFilePaths));
this.cache.retainOnly(new Set(signalFilePathCandidates.flat()));
const rows = await Promise.all(
uniqueTaskIds.map(async (taskId, index) => {
const filePath = signalFilePaths[index];
const parsed = await this.readSignal(filePath);
if (!parsed || parsed.taskId !== taskId) {
const candidates = signalFilePathCandidates[index] ?? [];
const result = await this.readFirstSignal(candidates);
if (!result || result.parsed.taskId !== taskId) {
return null;
}
const parsed = result.parsed;
return [
taskId,
{
taskId,
updatedAt: parsed.updatedAt,
filePath,
filePath: result.filePath,
...(parsed.transcriptFileBasename
? { transcriptFileBasename: parsed.transcriptFileBasename }
: {}),
@ -63,6 +93,18 @@ export class TeamTaskLogFreshnessReader {
return new Map(rows.filter((row): row is NonNullable<typeof row> => row !== null));
}
private async readFirstSignal(
filePaths: string[]
): Promise<{ filePath: string; parsed: ParsedFreshnessSignal } | null> {
for (const filePath of filePaths) {
const parsed = await this.readSignal(filePath);
if (parsed) {
return { filePath, parsed };
}
}
return null;
}
private async readSignal(filePath: string): Promise<ParsedFreshnessSignal | false> {
try {
const stat = await fs.stat(filePath);

View file

@ -1398,16 +1398,17 @@ const electronAPI: ElectronAPI = {
};
},
// Decision persistence
loadDecisions: async (teamName: string, scopeKey: string) => {
loadDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => {
return invokeIpcWithResult<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey);
} | null>(REVIEW_LOAD_DECISIONS, teamName, scopeKey, scopeToken ?? null);
},
saveDecisions: async (
teamName: string,
scopeKey: string,
scopeToken: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile?: Record<string, Record<number, string>>
@ -1416,13 +1417,19 @@ const electronAPI: ElectronAPI = {
REVIEW_SAVE_DECISIONS,
teamName,
scopeKey,
scopeToken,
hunkDecisions,
fileDecisions,
hunkContextHashesByFile ?? null
);
},
clearDecisions: async (teamName: string, scopeKey: string) => {
return invokeIpcWithResult<void>(REVIEW_CLEAR_DECISIONS, teamName, scopeKey);
clearDecisions: async (teamName: string, scopeKey: string, scopeToken?: string) => {
return invokeIpcWithResult<void>(
REVIEW_CLEAR_DECISIONS,
teamName,
scopeKey,
scopeToken ?? null
);
},
onCmdN: (callback: () => void): (() => void) => {
const handler = (): void => callback();

View file

@ -1133,6 +1133,7 @@ export class HttpAPIClient implements ElectronAPI {
saveDecisions: async (
_teamName: string,
_scopeKey: string,
_scopeToken: string,
_hunkDecisions: Record<string, unknown>,
_fileDecisions: Record<string, unknown>,
_hunkContextHashesByFile?: Record<string, Record<number, string>>

View file

@ -51,6 +51,7 @@ import {
buildTaskChangeSignature,
deriveTaskSince,
} from '@renderer/utils/taskChangeRequest';
import { resolveTaskChangePresenceFromResult } from '@renderer/utils/taskChangePresence';
import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils';
import { isLeadMember } from '@shared/utils/leadDetection';
import { getTaskKanbanColumn } from '@shared/utils/reviewState';
@ -103,16 +104,6 @@ import type {
TeamTaskWithKanban,
} from '@shared/types';
function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
): 'has_changes' | 'no_changes' | null {
if (data.files.length > 0) {
return 'has_changes';
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
}
interface TaskDetailDialogProps {
open: boolean;
loading?: boolean;
@ -154,7 +145,7 @@ export const TaskDetailDialog = ({
const { isLight } = useTheme();
const currentTask = task ? (taskMap.get(task.id) ?? task) : null;
const updateTaskFields = useStore((s) => s.updateTaskFields);
const recordTaskHasChanges = useStore((s) => s.recordTaskHasChanges);
const recordTaskChangePresence = useStore((s) => s.recordTaskChangePresence);
const setSelectedTeamTaskChangePresence = useStore((s) => s.setSelectedTeamTaskChangePresence);
const [logsRefreshing, setLogsRefreshing] = useState(false);
@ -391,22 +382,22 @@ export const TaskDetailDialog = ({
const syncTaskChangeSummaryResult = useCallback(
(data: TaskChangeSetV2 | null) => {
setTaskChangesFiles(data?.files ?? null);
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && taskChangeRequestOptions) {
recordTaskHasChanges(
recordTaskChangePresence(
teamName,
currentTask.id,
taskChangeRequestOptions,
!!data?.files.length
nextPresence
);
}
const nextPresence = data ? resolveTaskChangePresenceFromResult(data) : null;
if (currentTask && nextPresence) {
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence);
if (currentTask) {
setSelectedTeamTaskChangePresence(teamName, currentTask.id, nextPresence ?? 'unknown');
}
},
[
currentTask,
recordTaskHasChanges,
recordTaskChangePresence,
setSelectedTeamTaskChangePresence,
taskChangeRequestOptions,
teamName,

View file

@ -159,4 +159,41 @@ describe('KanbanTaskCard change badge', () => {
await Promise.resolve();
});
});
it('still renders the Changes action when changePresence needs attention', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(KanbanTaskCard, {
task: { ...baseTask, changePresence: 'needs_attention' },
teamName: 'my-team',
columnId: 'in_progress',
hasReviewers: true,
compact: false,
taskMap: new Map(),
memberColorMap: new Map([['alice', 'blue']]),
onRequestReview: noop,
onApprove: noop,
onRequestChanges: noop,
onMoveBackToDone: noop,
onStartTask: noop,
onCompleteTask: noop,
onCancelTask: noop,
onViewChanges: noop,
})
);
await Promise.resolve();
});
expect(host.textContent).toContain('Changes');
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
});

View file

@ -257,7 +257,8 @@ export const KanbanTaskCard = memo(
const isReviewManual = columnId === 'review' && !hasReviewers && effectiveReviewer.length === 0;
const metaActions = (
<>
{canDisplay && task.changePresence === 'has_changes' ? (
{canDisplay &&
(task.changePresence === 'has_changes' || task.changePresence === 'needs_attention') ? (
<TaskActionIconButton
label="Changes"
icon={<FileCode className="size-2.5" />}

View file

@ -14,7 +14,12 @@ import { buildSelectionAction } from '@renderer/utils/buildSelectionAction';
import { buildSelectionInfo, SELECTION_DEBOUNCE_MS } from '@renderer/utils/codemirrorSelectionInfo';
import { sortItemsAsTree } from '@renderer/utils/fileTreeBuilder';
import { displayMemberName } from '@renderer/utils/memberHelpers';
import { type TaskChangeRequestOptions } from '@renderer/utils/taskChangeRequest';
import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey';
import { buildReviewDecisionScopeToken } from '@renderer/utils/reviewDecisionScope';
import {
buildTaskChangeSignature,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import { normalizePathForComparison } from '@shared/utils/platformPath';
import { ChevronDown, Clock, X } from 'lucide-react';
@ -122,6 +127,25 @@ export const ChangeReviewDialog = ({
const scopeKey = mode === 'task' ? `task:${taskId ?? ''}` : `agent:${memberName ?? ''}`;
// Filesystem-safe: use `-` instead of `:` for decision persistence key
const decisionScopeKey = mode === 'task' ? `task-${taskId ?? ''}` : `agent-${memberName ?? ''}`;
const decisionScopeToken = useMemo(() => {
if (!activeChangeSet) return null;
if (mode === 'task') {
if (!('taskId' in activeChangeSet) || activeChangeSet.taskId !== taskId) {
return null;
}
} else if (!('memberName' in activeChangeSet) || activeChangeSet.memberName !== memberName) {
return null;
}
return buildReviewDecisionScopeToken({
mode,
taskId,
memberName,
requestSignature:
mode === 'task' ? buildTaskChangeSignature(taskChangeRequestOptions ?? {}) : undefined,
changeSet: activeChangeSet,
});
}, [activeChangeSet, memberName, mode, taskChangeRequestOptions, taskId]);
// Active file from scroll-spy (replaces selectedReviewFilePath for continuous scroll)
const [activeFilePath, setActiveFilePath] = useState<string | null>(null);
@ -813,8 +837,7 @@ export const ChangeReviewDialog = ({
useEffect(() => {
if (!open) return;
// Load persisted decisions from disk
void loadDecisionsFromDisk(teamName, decisionScopeKey);
resetAllReviewState();
// Fetch changeSet
if (mode === 'agent' && memberName) {
@ -836,9 +859,14 @@ export const ChangeReviewDialog = ({
fetchAgentChanges,
fetchTaskChanges,
clearChangeReviewCache,
loadDecisionsFromDisk,
resetAllReviewState,
]);
useEffect(() => {
if (!open || !decisionScopeToken) return;
void loadDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken);
}, [decisionScopeKey, decisionScopeToken, loadDecisionsFromDisk, open, teamName]);
// Persist decisions to disk on change (debounced via store action).
// When decisions go from non-empty to empty (e.g. undo to clean state),
// clear the persisted file so stale decisions don't reload on reopen.
@ -846,21 +874,27 @@ export const ChangeReviewDialog = ({
Object.keys(hunkDecisions).length > 0 || Object.keys(fileDecisions).length > 0;
const hadDecisionsRef = useRef(false);
useEffect(() => {
if (!open) return;
hadDecisionsRef.current = false;
}, [decisionScopeToken]);
useEffect(() => {
if (!open || !decisionScopeToken) return;
if (hasDecisions) {
hadDecisionsRef.current = true;
persistDecisions(teamName, decisionScopeKey);
persistDecisions(teamName, decisionScopeKey, decisionScopeToken);
} else if (hadDecisionsRef.current) {
hadDecisionsRef.current = false;
void clearDecisionsFromDisk(teamName, decisionScopeKey);
void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken);
}
}, [
open,
hasDecisions,
hunkDecisions,
fileDecisions,
fileContents,
fileChunkCounts,
teamName,
decisionScopeKey,
decisionScopeToken,
persistDecisions,
clearDecisionsFromDisk,
]);
@ -1125,7 +1159,8 @@ export const ChangeReviewDialog = ({
for (const file of activeChangeSet.files) {
// File-level decision takes priority (set by Accept All / Reject All)
const fileDec = fileDecisions[file.filePath];
const reviewKey = getFileReviewKey(file);
const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath];
const count = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
if (fileDec === 'accepted') {
@ -1138,8 +1173,9 @@ export const ChangeReviewDialog = ({
}
for (let i = 0; i < count; i++) {
const key = `${file.filePath}:${i}`;
const decision: HunkDecision = hunkDecisions[key] ?? 'pending';
const key = buildHunkDecisionKey(reviewKey, i);
const decision: HunkDecision =
hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending';
if (decision === 'pending') pending++;
else if (decision === 'accepted') accepted++;
else if (decision === 'rejected') rejected++;
@ -1163,7 +1199,7 @@ export const ChangeReviewDialog = ({
// Only cleanup if apply succeeded (no error in store)
const state = useStore.getState();
if (!state.applyError) {
void clearDecisionsFromDisk(teamName, decisionScopeKey);
void clearDecisionsFromDisk(teamName, decisionScopeKey, decisionScopeToken ?? undefined);
resetAllReviewState();
}
}, [
@ -1173,6 +1209,7 @@ export const ChangeReviewDialog = ({
memberName,
clearDecisionsFromDisk,
decisionScopeKey,
decisionScopeToken,
resetAllReviewState,
]);

View file

@ -9,6 +9,7 @@ import {
} from '@codemirror/merge';
import { ChangeSet, type ChangeSpec, EditorState, type StateEffect } from '@codemirror/state';
import { type EditorView } from '@codemirror/view';
import { buildHunkDecisionKey } from '@renderer/utils/reviewKey';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { structuredPatch } from 'diff';
@ -93,7 +94,7 @@ export const mirrorEditsAfterResolve = EditorState.transactionExtender.of((tr) =
*/
export function replayHunkDecisions(
view: EditorView,
filePath: string,
reviewKey: string,
hunkDecisions: Record<string, string>
): void {
const result = getChunks(view.state);
@ -102,7 +103,7 @@ export function replayHunkDecisions(
// Collect decisions that need replaying
const toReplay: { index: number; decision: 'accepted' | 'rejected' }[] = [];
for (let i = 0; i < result.chunks.length; i++) {
const key = `${filePath}:${i}`;
const key = buildHunkDecisionKey(reviewKey, i);
const d = hunkDecisions[key];
if (d === 'accepted' || d === 'rejected') {
toReplay.push({ index: i, decision: d });
@ -134,7 +135,7 @@ export function replayHunkDecisions(
*/
export function replayHunkDecisionsSmart(
view: EditorView,
filePath: string,
reviewKey: string,
hunkDecisions: Record<string, string>,
hunkContextHashes?: Record<number, string>
): void {
@ -171,7 +172,7 @@ export function replayHunkDecisionsSmart(
}
// Collect all decided indices from the decision map (don't assume contiguous 0..N)
const prefix = `${filePath}:`;
const prefix = `${reviewKey}:`;
const decided: { mappedIndex: number; decision: 'accepted' | 'rejected' }[] = [];
const usedMapped = new Set<number>();

View file

@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useLazyFileContent } from '@renderer/hooks/useLazyFileContent';
import { useVisibleFileSection } from '@renderer/hooks/useVisibleFileSection';
import { useStore } from '@renderer/store';
import { getFileReviewKey } from '@renderer/utils/reviewKey';
import {
acceptAllChunks,
@ -189,6 +190,8 @@ export const ContinuousScrollView = ({
const handleEditorViewReady = useCallback(
(filePath: string, view: EditorView | null) => {
if (view) {
const file = files.find((candidate) => candidate.filePath === filePath);
const reviewKey = file ? getFileReviewKey(file) : filePath;
// Skip if this exact view instance was already processed
if (editorViewMapRef.current.get(filePath) === view && replayedViewsRef.current.has(view)) {
return;
@ -202,7 +205,8 @@ export const ContinuousScrollView = ({
setFileChunkCount(filePath, chunks.chunks.length);
}
const fileDecision = fileDecisionsRef.current[filePath];
const fileDecision =
fileDecisionsRef.current[reviewKey] ?? fileDecisionsRef.current[filePath];
if (fileDecision === 'accepted' || fileDecision === 'rejected') {
// Sync file-level "Accept All" / "Reject All" decisions
requestAnimationFrame(() => {
@ -217,9 +221,9 @@ export const ContinuousScrollView = ({
requestAnimationFrame(() => {
replayHunkDecisionsSmart(
view,
filePath,
reviewKey,
hunkDecisionsRef.current,
hunkHashesRef.current[filePath]
hunkHashesRef.current[reviewKey] ?? hunkHashesRef.current[filePath]
);
});
}
@ -229,7 +233,7 @@ export const ContinuousScrollView = ({
// is not needed since view instances are unique and old ones get GC'd)
}
},
[editorViewMapRef, setFileChunkCount]
[editorViewMapRef, files, setFileChunkCount]
);
if (files.length === 0) {
@ -253,11 +257,12 @@ export const ContinuousScrollView = ({
) : null}
{files.map((file) => {
const filePath = file.filePath;
const reviewKey = getFileReviewKey(file);
const content = fileContents[filePath] ?? null;
const hasContent = filePath in fileContents;
const hasEdits = filePath in editedContents;
const isViewed = viewedSet.has(filePath);
const decision = fileDecisions[filePath];
const decision = fileDecisions[reviewKey] ?? fileDecisions[filePath];
const isCollapsed = collapsedFiles.has(filePath);

View file

@ -6,6 +6,7 @@ import { cn } from '@renderer/lib/utils';
import { useStore } from '@renderer/store';
import { getFileHunkCount } from '@renderer/store/slices/changeReviewSlice';
import { buildTree, sortTreeNodes } from '@renderer/utils/fileTreeBuilder';
import { buildHunkDecisionKey, getFileReviewKey } from '@renderer/utils/reviewKey';
import {
Check,
ChevronRight,
@ -47,7 +48,8 @@ function getFileStatus(
fileChunkCounts: Record<string, number>
): FileStatus {
// File-level decision takes priority (set by Accept All / Reject All)
const fileDec = fileDecisions[file.filePath];
const reviewKey = getFileReviewKey(file);
const fileDec = fileDecisions[reviewKey] ?? fileDecisions[file.filePath];
if (fileDec === 'accepted') return 'accepted';
if (fileDec === 'rejected') return 'rejected';
@ -56,8 +58,8 @@ function getFileStatus(
const decisions: HunkDecision[] = [];
for (let i = 0; i < count; i++) {
const key = `${file.filePath}:${i}`;
decisions.push(hunkDecisions[key] ?? 'pending');
const key = buildHunkDecisionKey(reviewKey, i);
decisions.push(hunkDecisions[key] ?? hunkDecisions[`${file.filePath}:${i}`] ?? 'pending');
}
const allAccepted = decisions.every((d) => d === 'accepted');
@ -300,10 +302,18 @@ export const ReviewFileTree = ({
};
const hasAnyRejected = (f: FileChangeSummary): boolean => {
if (fileDecisions[f.filePath] === 'rejected') return true;
const reviewKey = getFileReviewKey(f);
if (fileDecisions[reviewKey] === 'rejected' || fileDecisions[f.filePath] === 'rejected') {
return true;
}
const count = getFileHunkCount(f.filePath, f.snippets.length, fileChunkCounts);
for (let i = 0; i < count; i++) {
if (hunkDecisions[`${f.filePath}:${i}`] === 'rejected') return true;
if (
hunkDecisions[buildHunkDecisionKey(reviewKey, i)] === 'rejected' ||
hunkDecisions[`${f.filePath}:${i}`] === 'rejected'
) {
return true;
}
}
return false;
};

View file

@ -4,6 +4,20 @@ import {
isTaskSummaryCacheableForOptions,
type TaskChangeRequestOptions,
} from '@renderer/utils/taskChangeRequest';
import {
getReviewChangeSetIdentityToken,
type ReviewChangeSetLike,
} from '@renderer/utils/reviewDecisionScope';
import {
buildHunkDecisionKey,
getFileReviewKey,
getReviewKeyForFilePath,
normalizePersistedReviewState,
} from '@renderer/utils/reviewKey';
import {
resolveTaskChangePresenceFromResult,
shouldBackgroundRevalidateTaskPresence,
} from '@renderer/utils/taskChangePresence';
import { computeDiffContextHash } from '@shared/utils/diffContextHash';
import { createLogger } from '@shared/utils/logger';
import { normalizePathForComparison } from '@shared/utils/platformPath';
@ -18,10 +32,12 @@ const taskChangesNegativeCache = new Map<string, number>();
const NEGATIVE_CACHE_TTL = 30_000;
const TASK_CHANGE_WARM_CONCURRENCY = 4;
const CHANGE_REVIEW_SLICE_BOOT_TIME = Date.now();
let latestAgentChangesRequestToken = 0;
let latestTaskChangesRequestToken = 0;
let latestDecisionLoadRequestToken = 0;
/** Debounce timer for persisting decisions to disk */
let persistDebounceTimer: ReturnType<typeof setTimeout> | null = null;
const persistDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
const PERSIST_DEBOUNCE_MS = 500;
import type { AppState } from '../types';
@ -37,11 +53,23 @@ import type {
SnippetDiff,
TaskChangeSet,
TaskChangeSetV2,
TaskChangePresenceState,
} from '@shared/types';
import type { StateCreator } from 'zustand';
const logger = createLogger('changeReviewSlice');
function reviewPathsEqual(left: string, right: string): boolean {
return normalizePathForComparison(left) === normalizePathForComparison(right);
}
function findReviewFileByPath(
files: readonly FileChangeSummary[] | null | undefined,
filePath: string
): FileChangeSummary | undefined {
return files?.find((file) => reviewPathsEqual(file.filePath, filePath));
}
/** Snapshot of review decisions for undo support */
interface DecisionSnapshot {
hunkDecisions: Record<string, HunkDecision>;
@ -52,8 +80,6 @@ export interface ReviewExternalChange {
type: 'change' | 'add' | 'unlink';
}
type ReviewChangeSet = AgentChangeSet | TaskChangeSet | TaskChangeSetV2;
const MAX_REVIEW_UNDO_DEPTH = 10;
/**
@ -70,22 +96,53 @@ function mapReviewError(error: unknown): string {
return message || 'Failed to apply review changes';
}
function wasRestoredBeforeCurrentSession(data: TaskChangeSetV2): boolean {
const computedAtMs = Date.parse(data.computedAt);
if (!Number.isFinite(computedAtMs)) {
return true;
}
return computedAtMs < CHANGE_REVIEW_SLICE_BOOT_TIME;
function clearPersistDecisionTimer(scopeStorageKey: string): void {
const timer = persistDebounceTimers.get(scopeStorageKey);
if (!timer) return;
clearTimeout(timer);
persistDebounceTimers.delete(scopeStorageKey);
}
function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence'>
): 'has_changes' | 'no_changes' | null {
if (data.files.length > 0) {
return 'has_changes';
}
function buildPersistDecisionScopeKey(
teamName: string,
scopeKey: string,
scopeToken?: string
): string {
return scopeToken ? `${teamName}:${scopeKey}:${scopeToken}` : `${teamName}:${scopeKey}`;
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
function clearAllPersistDecisionTimers(): void {
for (const timer of persistDebounceTimers.values()) {
clearTimeout(timer);
}
persistDebounceTimers.clear();
}
function applyTaskChangePresenceCacheUpdate(
taskChangePresenceByKey: Record<string, Exclude<TaskChangePresenceState, 'unknown'>>,
cacheKey: string,
presence: TaskChangePresenceState | null
): Record<string, Exclude<TaskChangePresenceState, 'unknown'>> {
const nextTaskChangePresenceByKey = { ...taskChangePresenceByKey };
if (presence && presence !== 'unknown') {
nextTaskChangePresenceByKey[cacheKey] = presence;
} else {
delete nextTaskChangePresenceByKey[cacheKey];
}
return nextTaskChangePresenceByKey;
}
function syncTaskChangeNegativeCache(
cacheKey: string,
presence: TaskChangePresenceState | null
): void {
if (presence === 'has_changes' || presence === 'needs_attention') {
taskChangesNegativeCache.delete(cacheKey);
} else if (presence === 'no_changes') {
taskChangesNegativeCache.set(cacheKey, Date.now());
} else {
taskChangesNegativeCache.delete(cacheKey);
}
}
export interface ChangeReviewSlice {
@ -118,8 +175,8 @@ export interface ChangeReviewSlice {
// Editable diff state
editedContents: Record<string, string>;
/** Cache: "teamName:taskId:signature" → true/false (has file changes) */
taskHasChanges: Record<string, boolean>;
/** Cache: "teamName:taskId:signature" → resolved task change presence */
taskChangePresenceByKey: Record<string, Exclude<TaskChangePresenceState, 'unknown'>>;
// Phase 1 actions
fetchAgentChanges: (teamName: string, memberName: string) => Promise<void>;
@ -128,11 +185,11 @@ export interface ChangeReviewSlice {
taskId: string,
options: TaskChangeRequestOptions
) => Promise<void>;
recordTaskHasChanges: (
recordTaskChangePresence: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
hasChanges: boolean
presence: TaskChangePresenceState | null
) => void;
selectReviewFile: (filePath: string | null) => void;
clearChangeReview: () => void;
@ -141,9 +198,13 @@ export interface ChangeReviewSlice {
fetchChangeStats: (teamName: string, memberName: string) => Promise<void>;
// Decision persistence actions
loadDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>;
persistDecisions: (teamName: string, scopeKey: string) => void;
clearDecisionsFromDisk: (teamName: string, scopeKey: string) => Promise<void>;
loadDecisionsFromDisk: (teamName: string, scopeKey: string, scopeToken: string) => Promise<void>;
persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => void;
clearDecisionsFromDisk: (
teamName: string,
scopeKey: string,
scopeToken?: string
) => Promise<void>;
// Phase 2 actions
/**
@ -218,14 +279,14 @@ export interface ChangeReviewSlice {
* This function reverses that shift so decisions are stored with stable indices.
*/
function mapCurrentToOriginalIndex(
filePath: string,
reviewKey: string,
currentIdx: number,
hunkDecisions: Record<string, HunkDecision>,
totalChunks: number
): number {
const decided = new Set<number>();
for (let i = 0; i < totalChunks; i++) {
if (`${filePath}:${i}` in hunkDecisions) {
if (buildHunkDecisionKey(reviewKey, i) in hunkDecisions) {
decided.add(i);
}
}
@ -251,11 +312,11 @@ export function getFileHunkCount(
}
function getMaxDecisionIndexForFile(
filePath: string,
reviewKey: string,
hunkDecisions: Record<string, HunkDecision>
): number {
let max = -1;
const prefix = `${filePath}:`;
const prefix = `${reviewKey}:`;
for (const key of Object.keys(hunkDecisions)) {
if (!key.startsWith(prefix)) continue;
const raw = key.slice(prefix.length);
@ -296,38 +357,6 @@ function buildHunkContextHashesForFile(
return out;
}
function encodeFingerprintField(value: string): string {
return `${value.length}:${value}`;
}
function fingerprintSnippet(snippet: SnippetDiff): string {
return [
encodeFingerprintField(normalizePathForComparison(snippet.filePath)),
encodeFingerprintField(snippet.toolUseId),
encodeFingerprintField(snippet.timestamp),
encodeFingerprintField(snippet.type),
encodeFingerprintField(snippet.oldString),
encodeFingerprintField(snippet.newString),
encodeFingerprintField(snippet.replaceAll ? '1' : '0'),
encodeFingerprintField(snippet.isError ? '1' : '0'),
encodeFingerprintField(snippet.contextHash ?? ''),
].join('|');
}
function fingerprintChangeSet(changeSet: ReviewChangeSet): string {
return [...changeSet.files]
.sort((a, b) =>
normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath))
)
.map((file) =>
[
encodeFingerprintField(normalizePathForComparison(file.filePath)),
...file.snippets.map(fingerprintSnippet),
].join('|')
)
.join('||');
}
export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeReviewSlice> = (
set,
get
@ -353,6 +382,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
delete nextFileContentsLoading[filePath];
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath);
delete nextHunkContextHashesByFile[reviewKey];
delete nextHunkContextHashesByFile[filePath];
return {
@ -368,18 +399,22 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
};
const installActiveChangeSetForLoad = (
data: ReviewChangeSet,
data: ReviewChangeSetLike,
extraState?: Partial<ChangeReviewSlice>
): void => {
set((s) => ({
activeChangeSet: data,
changeSetLoading: false,
selectedReviewFilePath: data.files[0]?.filePath ?? null,
hunkDecisions: {},
fileDecisions: {},
fileContents: {},
fileContentsLoading: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
applyError: null,
editedContents: {},
changeSetEpoch: s.changeSetEpoch + 1,
fileContentVersionByPath: {},
reviewExternalChangesByFile: {},
@ -388,7 +423,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
};
const replaceActiveChangeSetAfterStaleRefresh = (
fresh: ReviewChangeSet,
fresh: ReviewChangeSetLike,
applyError: string
): void => {
set((s) => ({
@ -430,14 +465,16 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
summaryOnly: true,
forceFresh: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
set((state) => ({
taskHasChanges: { ...state.taskHasChanges, [cacheKey]: data.files.length > 0 },
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
state.taskChangePresenceByKey,
cacheKey,
nextPresence
),
}));
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
syncTaskChangeNegativeCache(cacheKey, nextPresence);
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown');
} catch {
// Best-effort background revalidation; keep optimistic state on transient failure.
} finally {
@ -472,35 +509,40 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
// Editable diff initial state
editedContents: {},
taskHasChanges: {},
taskChangePresenceByKey: {},
fetchAgentChanges: async (teamName: string, memberName: string) => {
const requestToken = ++latestAgentChangesRequestToken;
set({ changeSetLoading: true, changeSetError: null });
try {
const data = await api.review.getAgentChanges(teamName, memberName);
if (requestToken !== latestAgentChangesRequestToken) return;
installActiveChangeSetForLoad(data, { activeTaskChangeRequestOptions: null });
} catch (error) {
if (requestToken !== latestAgentChangesRequestToken) return;
const message = error instanceof Error ? error.message : 'Failed to fetch agent changes';
logger.error('fetchAgentChanges error:', message);
set({ changeSetError: message, changeSetLoading: false });
}
},
recordTaskHasChanges: (
recordTaskChangePresence: (
teamName: string,
taskId: string,
options: TaskChangeRequestOptions,
hasChanges: boolean
presence: TaskChangePresenceState | null
) => {
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: hasChanges },
}));
if (hasChanges) {
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
set((s) => {
return {
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
s.taskChangePresenceByKey,
cacheKey,
presence
),
};
});
syncTaskChangeNegativeCache(cacheKey, presence);
},
fetchTaskChanges: async (
@ -517,16 +559,14 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const nextPresence = resolveTaskChangePresenceFromResult(data);
installActiveChangeSetForLoad(data, {
activeTaskChangeRequestOptions: options,
taskHasChanges: { ...get().taskHasChanges, [cacheKey]: data.files.length > 0 },
taskChangePresenceByKey: applyTaskChangePresenceCacheUpdate(
get().taskChangePresenceByKey,
cacheKey,
nextPresence
),
});
if (nextPresence) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence);
}
if (data.files.length > 0) {
taskChangesNegativeCache.delete(cacheKey);
} else {
taskChangesNegativeCache.set(cacheKey, Date.now());
}
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence ?? 'unknown');
syncTaskChangeNegativeCache(cacheKey, nextPresence);
} catch (error) {
if (requestToken !== latestTaskChangesRequestToken) return;
const message = error instanceof Error ? error.message : 'Failed to fetch task changes';
@ -540,7 +580,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
clearChangeReview: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
@ -564,13 +607,18 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
clearChangeReviewCache: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
changeSetError: null,
selectedReviewFilePath: null,
activeTaskChangeRequestOptions: null,
hunkDecisions: {},
fileDecisions: {},
fileChunkCounts: {},
reviewUndoStack: [],
hunkContextHashesByFile: {},
@ -586,7 +634,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
resetAllReviewState: () => {
latestAgentChangesRequestToken++;
latestTaskChangesRequestToken++;
latestDecisionLoadRequestToken++;
clearAllPersistDecisionTimers();
set((s) => ({
activeChangeSet: null,
changeSetLoading: false,
@ -611,72 +662,93 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
// ── Decision persistence ──
loadDecisionsFromDisk: async (teamName: string, scopeKey: string) => {
loadDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken: string) => {
const requestToken = ++latestDecisionLoadRequestToken;
try {
const data = await api.review.loadDecisions(teamName, scopeKey);
const data = await api.review.loadDecisions(teamName, scopeKey, scopeToken);
if (requestToken !== latestDecisionLoadRequestToken) return;
const normalized = normalizePersistedReviewState(get().activeChangeSet?.files ?? [], {
hunkDecisions: data?.hunkDecisions,
fileDecisions: data?.fileDecisions,
hunkContextHashesByFile: data?.hunkContextHashesByFile,
});
// Always set decisions — even to empty if no saved file exists.
// This prevents stale decisions from a previous scope leaking through.
set({
hunkDecisions: data?.hunkDecisions ?? {},
fileDecisions: data?.fileDecisions ?? {},
hunkContextHashesByFile: data?.hunkContextHashesByFile ?? {},
hunkDecisions: normalized.hunkDecisions,
fileDecisions: normalized.fileDecisions,
hunkContextHashesByFile: normalized.hunkContextHashesByFile,
});
} catch (error) {
if (requestToken !== latestDecisionLoadRequestToken) return;
logger.error('loadDecisionsFromDisk error:', error);
set({
hunkDecisions: {},
fileDecisions: {},
hunkContextHashesByFile: {},
});
}
},
persistDecisions: (teamName: string, scopeKey: string) => {
if (persistDebounceTimer) {
clearTimeout(persistDebounceTimer);
persistDecisions: (teamName: string, scopeKey: string, scopeToken: string) => {
const scopeStorageKey = buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken);
clearPersistDecisionTimer(scopeStorageKey);
const {
hunkDecisions,
fileDecisions,
hunkContextHashesByFile,
activeChangeSet,
fileContents,
fileChunkCounts,
} = get();
const computed: Record<string, Record<number, string>> = {};
for (const file of activeChangeSet?.files ?? []) {
const fp = file.filePath;
const content = fileContents[fp];
if (!content) continue;
const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts);
const hashes = buildHunkContextHashesForFile(
content.originalFullContent,
content.modifiedFullContent,
expected
);
if (hashes) computed[fp] = hashes;
}
persistDebounceTimer = setTimeout(() => {
const {
hunkDecisions,
fileDecisions,
hunkContextHashesByFile,
activeChangeSet,
fileContents,
fileChunkCounts,
} = get();
const computed: Record<string, Record<number, string>> = {};
for (const file of activeChangeSet?.files ?? []) {
const fp = file.filePath;
const content = fileContents[fp];
if (!content) continue;
const expected = getFileHunkCount(fp, file.snippets.length, fileChunkCounts);
const hashes = buildHunkContextHashesForFile(
content.originalFullContent,
content.modifiedFullContent,
expected
);
if (hashes) computed[fp] = hashes;
}
const mergedHashes: Record<string, Record<number, string>> = {};
for (const file of activeChangeSet?.files ?? []) {
const fp = file.filePath;
const reviewKey = getFileReviewKey(file);
mergedHashes[reviewKey] =
computed[fp] ?? hunkContextHashesByFile[reviewKey] ?? hunkContextHashesByFile[fp] ?? {};
}
set({ hunkContextHashesByFile: mergedHashes });
// Prune to only files in the current scope. This avoids persisting stale file paths
// (e.g. from older sessions) that could confuse future replays.
const mergedHashes: Record<string, Record<number, string>> = {};
for (const file of activeChangeSet?.files ?? []) {
const fp = file.filePath;
mergedHashes[fp] = computed[fp] ?? hunkContextHashesByFile[fp] ?? {};
}
// Keep store in sync so replay can use hashes without reload.
set({ hunkContextHashesByFile: mergedHashes });
const persistedHunkDecisions = { ...hunkDecisions };
const persistedFileDecisions = { ...fileDecisions };
const persistedHashes = { ...mergedHashes };
const timer = setTimeout(() => {
persistDebounceTimers.delete(scopeStorageKey);
void api.review.saveDecisions(
teamName,
scopeKey,
hunkDecisions,
fileDecisions,
mergedHashes
scopeToken,
persistedHunkDecisions,
persistedFileDecisions,
persistedHashes
);
}, PERSIST_DEBOUNCE_MS);
persistDebounceTimers.set(scopeStorageKey, timer);
},
clearDecisionsFromDisk: async (teamName: string, scopeKey: string) => {
clearDecisionsFromDisk: async (teamName: string, scopeKey: string, scopeToken?: string) => {
clearPersistDecisionTimer(buildPersistDecisionScopeKey(teamName, scopeKey, scopeToken));
try {
await api.review.clearDecisions(teamName, scopeKey);
await api.review.clearDecisions(teamName, scopeKey, scopeToken);
} catch (error) {
logger.error('clearDecisionsFromDisk error:', error);
}
@ -699,13 +771,14 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
setHunkDecision: (filePath: string, hunkIndex: number, decision: HunkDecision) => {
const state = get();
const totalChunks = state.fileChunkCounts[filePath] ?? 0;
const reviewKey = getReviewKeyForFilePath(state.activeChangeSet?.files, filePath);
// Map current chunk index to original: after accept/reject, chunks shift in CM.
// We need the original index to keep decisions stable across shifts.
const originalIndex =
totalChunks > 0
? mapCurrentToOriginalIndex(filePath, hunkIndex, state.hunkDecisions, totalChunks)
? mapCurrentToOriginalIndex(reviewKey, hunkIndex, state.hunkDecisions, totalChunks)
: hunkIndex;
const key = `${filePath}:${originalIndex}`;
const key = buildHunkDecisionKey(reviewKey, originalIndex);
set((s) => ({
hunkDecisions: { ...s.hunkDecisions, [key]: decision },
}));
@ -713,7 +786,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
clearHunkDecisionByOriginalIndex: (filePath: string, originalIndex: number) => {
const key = `${filePath}:${originalIndex}`;
const key = buildHunkDecisionKey(
getReviewKeyForFilePath(get().activeChangeSet?.files, filePath),
originalIndex
);
set((s) => {
if (!(key in s.hunkDecisions)) return s;
const next = { ...s.hunkDecisions };
@ -723,8 +799,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
},
setFileDecision: (filePath: string, decision: HunkDecision) => {
const reviewKey = getReviewKeyForFilePath(get().activeChangeSet?.files, filePath);
set((state) => ({
fileDecisions: { ...state.fileDecisions, [filePath]: decision },
fileDecisions: { ...state.fileDecisions, [reviewKey]: decision },
}));
},
@ -762,33 +839,35 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
acceptAllFile: (filePath: string) => {
const state = get();
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
const file = findReviewFileByPath(state.activeChangeSet?.files, filePath);
if (!file) return;
const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts);
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
const newHunkDecisions = { ...state.hunkDecisions };
const reviewKey = getFileReviewKey(file);
for (let i = 0; i < count; i++) {
newHunkDecisions[`${filePath}:${i}`] = 'accepted';
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [filePath]: 'accepted' },
fileDecisions: { ...state.fileDecisions, [reviewKey]: 'accepted' },
});
},
rejectAllFile: (filePath: string) => {
const state = get();
const file = state.activeChangeSet?.files.find((f) => f.filePath === filePath);
const file = findReviewFileByPath(state.activeChangeSet?.files, filePath);
if (!file) return;
const count = getFileHunkCount(filePath, file.snippets.length, state.fileChunkCounts);
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
const newHunkDecisions = { ...state.hunkDecisions };
const reviewKey = getFileReviewKey(file);
for (let i = 0; i < count; i++) {
newHunkDecisions[`${filePath}:${i}`] = 'rejected';
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected';
}
set({
hunkDecisions: newHunkDecisions,
fileDecisions: { ...state.fileDecisions, [filePath]: 'rejected' },
fileDecisions: { ...state.fileDecisions, [reviewKey]: 'rejected' },
});
},
@ -800,10 +879,11 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
newFileDecisions[file.filePath] = 'accepted';
const reviewKey = getFileReviewKey(file);
newFileDecisions[reviewKey] = 'accepted';
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
for (let i = 0; i < count; i++) {
newHunkDecisions[`${file.filePath}:${i}`] = 'accepted';
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'accepted';
}
}
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
@ -817,10 +897,11 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const newFileDecisions: Record<string, HunkDecision> = {};
for (const file of state.activeChangeSet.files) {
newFileDecisions[file.filePath] = 'rejected';
const reviewKey = getFileReviewKey(file);
newFileDecisions[reviewKey] = 'rejected';
const count = getFileHunkCount(file.filePath, file.snippets.length, state.fileChunkCounts);
for (let i = 0; i < count; i++) {
newHunkDecisions[`${file.filePath}:${i}`] = 'rejected';
newHunkDecisions[buildHunkDecisionKey(reviewKey, i)] = 'rejected';
}
}
set({ hunkDecisions: newHunkDecisions, fileDecisions: newFileDecisions });
@ -848,10 +929,11 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
try {
// Lookup snippets from activeChangeSet so backend can use them for reconstruction
const activeChangeSet = get().activeChangeSet;
const fileEntry = activeChangeSet?.files.find((f) => f.filePath === filePath);
const fileEntry = findReviewFileByPath(activeChangeSet?.files, filePath);
const canonicalFilePath = fileEntry?.filePath ?? filePath;
const snippets = fileEntry?.snippets ?? [];
const content = await api.review.getFileContent(teamName, memberName, filePath, snippets);
const content = await api.review.getFileContent(teamName, memberName, canonicalFilePath, snippets);
const latest = get();
if (changeSetEpoch !== latest.changeSetEpoch) return;
if ((latest.fileContentVersionByPath[filePath] ?? 0) !== fileVersion) return;
@ -868,7 +950,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
s.activeChangeSet
) {
const updatedFiles = s.activeChangeSet.files.map((f) =>
f.filePath === filePath
reviewPathsEqual(f.filePath, canonicalFilePath)
? { ...f, linesAdded: content.linesAdded, linesRemoved: content.linesRemoved }
: f
);
@ -902,15 +984,13 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
// Stale check: re-fetch changes and compare content fingerprint
const state = get();
const current = state.activeChangeSet;
const currentFingerprint = current
? fingerprintChangeSet(current as ReviewChangeSet)
: null;
const currentFingerprint = getReviewChangeSetIdentityToken(current);
const staleMessage =
'Changes have been updated since you started reviewing. Please re-review.';
if (memberName && current) {
const fresh = await api.review.getAgentChanges(teamName, memberName);
if (currentFingerprint !== fingerprintChangeSet(fresh)) {
if (currentFingerprint !== getReviewChangeSetIdentityToken(fresh)) {
replaceActiveChangeSetAfterStaleRefresh(fresh, staleMessage);
return;
}
@ -919,7 +999,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
...(state.activeTaskChangeRequestOptions ?? {}),
forceFresh: true,
});
if (currentFingerprint !== fingerprintChangeSet(fresh)) {
if (currentFingerprint !== getReviewChangeSetIdentityToken(fresh)) {
replaceActiveChangeSetAfterStaleRefresh(fresh, staleMessage);
return;
}
@ -936,14 +1016,15 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
const decisions: FileReviewDecision[] = [];
for (const file of activeChangeSet.files) {
const fileDecision = fileDecisions[file.filePath] ?? 'pending';
const reviewKey = getFileReviewKey(file);
const fileDecision = fileDecisions[reviewKey] ?? 'pending';
const hunkDecs: Record<number, HunkDecision> = {};
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(file.filePath, hunkDecisions);
const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const count = Math.max(baseCount, maxIdx + 1);
for (let i = 0; i < count; i++) {
const key = `${file.filePath}:${i}`;
const key = buildHunkDecisionKey(reviewKey, i);
hunkDecs[i] = hunkDecisions[key] ?? 'pending';
}
@ -1009,16 +1090,17 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
get();
if (!activeChangeSet) return null;
const file = activeChangeSet.files.find((f) => f.filePath === filePath);
const file = findReviewFileByPath(activeChangeSet.files, filePath);
if (!file) return null;
const fileDecision = fileDecisions[filePath] ?? 'pending';
const reviewKey = getFileReviewKey(file);
const fileDecision = fileDecisions[reviewKey] ?? 'pending';
const hunkDecs: Record<number, HunkDecision> = {};
const baseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
const baseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
const maxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const count = Math.max(baseCount, maxIdx + 1);
for (let i = 0; i < count; i++) {
hunkDecs[i] = hunkDecisions[`${filePath}:${i}`] ?? 'pending';
hunkDecs[i] = hunkDecisions[buildHunkDecisionKey(reviewKey, i)] ?? 'pending';
}
const hasRejected =
@ -1026,9 +1108,9 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
if (!hasRejected) return null;
try {
const content = fileContents[filePath];
const innerBaseCount = getFileHunkCount(filePath, file.snippets.length, fileChunkCounts);
const innerMaxIdx = getMaxDecisionIndexForFile(filePath, hunkDecisions);
const content = fileContents[file.filePath] ?? fileContents[filePath];
const innerBaseCount = getFileHunkCount(file.filePath, file.snippets.length, fileChunkCounts);
const innerMaxIdx = getMaxDecisionIndexForFile(reviewKey, hunkDecisions);
const hunkContextHashes =
innerMaxIdx < innerBaseCount
? buildHunkContextHashesForFile(
@ -1043,7 +1125,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
memberName,
decisions: [
{
filePath,
filePath: file.filePath,
fileDecision,
hunkDecisions: hunkDecs,
hunkContextHashes,
@ -1065,47 +1147,57 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
removeReviewFile: (filePath: string) => {
set((s) => {
if (!s.activeChangeSet) return s;
const existing = s.activeChangeSet.files.find((f) => f.filePath === filePath);
const existing = findReviewFileByPath(s.activeChangeSet.files, filePath);
if (!existing) return s;
const nextFiles = s.activeChangeSet.files.filter((f) => f.filePath !== filePath);
const nextFiles = s.activeChangeSet.files.filter(
(f) => !reviewPathsEqual(f.filePath, existing.filePath)
);
const totalLinesAdded = nextFiles.reduce((sum, f) => sum + f.linesAdded, 0);
const totalLinesRemoved = nextFiles.reduce((sum, f) => sum + f.linesRemoved, 0);
const nextHunkDecisions = { ...s.hunkDecisions };
const prefix = `${filePath}:`;
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet.files, filePath);
const prefix = `${reviewKey}:`;
for (const key of Object.keys(nextHunkDecisions)) {
if (key.startsWith(prefix)) delete nextHunkDecisions[key];
}
const nextFileDecisions = { ...s.fileDecisions };
delete nextFileDecisions[filePath];
delete nextFileDecisions[reviewKey];
const nextFileChunkCounts = { ...s.fileChunkCounts };
delete nextFileChunkCounts[filePath];
delete nextFileChunkCounts[existing.filePath];
const nextFileContents = { ...s.fileContents };
delete nextFileContents[filePath];
delete nextFileContents[existing.filePath];
const nextFileContentsLoading = { ...s.fileContentsLoading };
delete nextFileContentsLoading[filePath];
delete nextFileContentsLoading[existing.filePath];
const nextEditedContents = { ...s.editedContents };
delete nextEditedContents[filePath];
delete nextEditedContents[existing.filePath];
const nextHashes = { ...s.hunkContextHashesByFile };
delete nextHashes[reviewKey];
delete nextHashes[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
delete nextReviewExternalChangesByFile[filePath];
delete nextReviewExternalChangesByFile[existing.filePath];
const nextFileContentVersionByPath = {
...s.fileContentVersionByPath,
[filePath]: (s.fileContentVersionByPath[filePath] ?? 0) + 1,
[existing.filePath]: (s.fileContentVersionByPath[existing.filePath] ?? 0) + 1,
};
const nextSelected =
s.selectedReviewFilePath === filePath
s.selectedReviewFilePath && reviewPathsEqual(s.selectedReviewFilePath, existing.filePath)
? (nextFiles[0]?.filePath ?? null)
: s.selectedReviewFilePath;
@ -1137,7 +1229,7 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
) => {
set((s) => {
if (!s.activeChangeSet) return s;
if (s.activeChangeSet.files.some((f) => f.filePath === file.filePath)) return s;
if (findReviewFileByPath(s.activeChangeSet.files, file.filePath)) return s;
const idxRaw = options?.index;
const idx =
@ -1186,7 +1278,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
clearReviewStateForFile: (filePath: string) => {
set((s) => {
const nextHunkDecisions = { ...s.hunkDecisions };
const prefix = `${filePath}:`;
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath);
const prefix = `${reviewKey}:`;
for (const key of Object.keys(nextHunkDecisions)) {
if (key.startsWith(prefix) && nextHunkDecisions[key] === 'rejected') {
delete nextHunkDecisions[key];
@ -1194,8 +1287,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
}
const nextFileDecisions = { ...s.fileDecisions };
if (nextFileDecisions[filePath] === 'rejected') {
delete nextFileDecisions[filePath];
if (nextFileDecisions[reviewKey] === 'rejected') {
delete nextFileDecisions[reviewKey];
}
const nextEditedContents = { ...s.editedContents };
@ -1289,6 +1382,8 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
delete nextFileChunkCounts[filePath];
const nextHunkContextHashesByFile = { ...s.hunkContextHashesByFile };
const reviewKey = getReviewKeyForFilePath(s.activeChangeSet?.files, filePath);
delete nextHunkContextHashesByFile[reviewKey];
delete nextHunkContextHashesByFile[filePath];
const nextReviewExternalChangesByFile = { ...s.reviewExternalChangesByFile };
@ -1331,8 +1426,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
: undefined;
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, options);
const summaryCacheable = isTaskSummaryCacheableForOptions(options);
if (summaryCacheable && get().taskHasChanges[cacheKey] === true) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
if (
summaryCacheable &&
(cachedPresence === 'has_changes' || cachedPresence === 'needs_attention')
) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, cachedPresence);
return;
}
if (taskChangesCheckInFlight.has(cacheKey)) return;
@ -1347,23 +1446,29 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
summaryOnly: true,
});
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (data.files.length > 0) {
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: true },
taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: nextPresence },
}));
taskChangesNegativeCache.delete(cacheKey);
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'has_changes');
if (wasRestoredBeforeCurrentSession(data)) {
get().setSelectedTeamTaskChangePresence(teamName, taskId, nextPresence);
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
void revalidateTaskChangePresence(teamName, taskId, options);
}
} else {
} else if (nextPresence === 'no_changes') {
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: false },
taskChangePresenceByKey: { ...s.taskChangePresenceByKey, [cacheKey]: 'no_changes' },
}));
taskChangesNegativeCache.set(cacheKey, Date.now());
if (nextPresence === 'no_changes') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes');
} else if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'no_changes');
} else {
set((s) => {
const nextTaskChangePresenceByKey = { ...s.taskChangePresenceByKey };
delete nextTaskChangePresenceByKey[cacheKey];
return { taskChangePresenceByKey: nextTaskChangePresenceByKey };
});
taskChangesNegativeCache.delete(cacheKey);
if (selectedTask?.changePresence && selectedTask.changePresence !== 'unknown') {
get().setSelectedTeamTaskChangePresence(teamName, taskId, 'unknown');
}
}
@ -1394,7 +1499,12 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
cacheKey: string,
request: { teamName: string; taskId: string; options: TaskChangeRequestOptions }
): Promise<void> => {
if (get().taskHasChanges[cacheKey] === true || taskChangesCheckInFlight.has(cacheKey)) {
const cachedPresence = get().taskChangePresenceByKey[cacheKey];
if (
cachedPresence === 'has_changes' ||
cachedPresence === 'needs_attention' ||
taskChangesCheckInFlight.has(cacheKey)
) {
return;
}
@ -1404,16 +1514,24 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
...request.options,
summaryOnly: true,
});
set((s) => ({
taskHasChanges: { ...s.taskHasChanges, [cacheKey]: data.files.length > 0 },
}));
if (data.files.length > 0) {
const nextPresence = resolveTaskChangePresenceFromResult(data);
if (nextPresence) {
set((s) => ({
taskChangePresenceByKey: {
...s.taskChangePresenceByKey,
[cacheKey]: nextPresence,
},
}));
}
if (nextPresence === 'has_changes' || nextPresence === 'needs_attention') {
taskChangesNegativeCache.delete(cacheKey);
if (wasRestoredBeforeCurrentSession(data)) {
if (shouldBackgroundRevalidateTaskPresence(data, CHANGE_REVIEW_SLICE_BOOT_TIME)) {
void revalidateTaskChangePresence(request.teamName, request.taskId, request.options);
}
} else {
} else if (nextPresence === 'no_changes') {
taskChangesNegativeCache.set(cacheKey, Date.now());
} else {
taskChangesNegativeCache.delete(cacheKey);
}
} catch {
// Best-effort warm path.
@ -1435,16 +1553,16 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
if (cacheKeys.length === 0) return;
const keySet = new Set(cacheKeys);
set((state) => {
const nextTaskHasChanges = { ...state.taskHasChanges };
const nextTaskChangePresenceByKey = { ...state.taskChangePresenceByKey };
let changed = false;
for (const key of keySet) {
if (key in nextTaskHasChanges) {
delete nextTaskHasChanges[key];
if (key in nextTaskChangePresenceByKey) {
delete nextTaskChangePresenceByKey[key];
changed = true;
}
taskChangesNegativeCache.delete(key);
}
return changed ? { taskHasChanges: nextTaskHasChanges } : {};
return changed ? { taskChangePresenceByKey: nextTaskChangePresenceByKey } : {};
});
},

View file

@ -0,0 +1,72 @@
import { normalizePathForComparison } from '@shared/utils/platformPath';
import type { AgentChangeSet, SnippetDiff, TaskChangeSet, TaskChangeSetV2 } from '@shared/types';
export type ReviewChangeSetLike = AgentChangeSet | TaskChangeSet | TaskChangeSetV2;
function encodeFingerprintField(value: string): string {
return `${value.length}:${value}`;
}
function fingerprintSnippet(snippet: SnippetDiff): string {
return [
encodeFingerprintField(normalizePathForComparison(snippet.filePath)),
encodeFingerprintField(snippet.toolUseId),
encodeFingerprintField(snippet.timestamp),
encodeFingerprintField(snippet.type),
encodeFingerprintField(snippet.oldString),
encodeFingerprintField(snippet.newString),
encodeFingerprintField(snippet.replaceAll ? '1' : '0'),
encodeFingerprintField(snippet.isError ? '1' : '0'),
encodeFingerprintField(snippet.contextHash ?? ''),
].join('|');
}
export function fingerprintReviewChangeSet(changeSet: ReviewChangeSetLike): string {
return [...changeSet.files]
.sort((a, b) =>
normalizePathForComparison(a.filePath).localeCompare(normalizePathForComparison(b.filePath))
)
.map((file) =>
[
encodeFingerprintField(normalizePathForComparison(file.filePath)),
...(file.changeKey ? [encodeFingerprintField(file.changeKey)] : []),
...file.snippets.map(fingerprintSnippet),
].join('|')
)
.join('||');
}
export function getReviewChangeSetIdentityToken(
changeSet: ReviewChangeSetLike | null | undefined
): string | null {
if (!changeSet) {
return null;
}
const provenance = 'provenance' in changeSet ? changeSet.provenance : undefined;
if (provenance?.sourceFingerprint) {
return `provenance:${provenance.sourceKind}:${provenance.sourceFingerprint}`;
}
return `content:${fingerprintReviewChangeSet(changeSet)}`;
}
export function buildReviewDecisionScopeToken(params: {
mode: 'agent' | 'task';
taskId?: string;
memberName?: string;
requestSignature?: string | null;
changeSet: ReviewChangeSetLike | null | undefined;
}): string | null {
const identity = getReviewChangeSetIdentityToken(params.changeSet);
if (!identity) {
return null;
}
if (params.mode === 'task') {
return `task:${params.taskId ?? ''}:${params.requestSignature ?? ''}:${identity}`;
}
return `agent:${params.memberName ?? ''}:${identity}`;
}

View file

@ -0,0 +1,104 @@
import type { FileChangeSummary, HunkDecision } from '@shared/types';
import { normalizePathForComparison } from '@shared/utils/platformPath';
function normalizeReviewAlias(alias: string): string {
const slashNormalized = alias.replace(/\\/g, '/');
const relationMatch = /^(rename|copy):(.+)->(.+)$/.exec(slashNormalized);
if (relationMatch) {
return `${relationMatch[1]}:${normalizePathForComparison(relationMatch[2] ?? '')}->${normalizePathForComparison(relationMatch[3] ?? '')}`;
}
const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized);
if (pathKeyMatch) {
return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`;
}
return normalizePathForComparison(alias);
}
export function getFileReviewKey(
file: Pick<FileChangeSummary, 'filePath' | 'changeKey'>
): string {
return file.changeKey ?? file.filePath;
}
export function getReviewKeyForFilePath(
files: readonly Pick<FileChangeSummary, 'filePath' | 'changeKey'>[] | null | undefined,
filePath: string
): string {
const normalizedFilePath = normalizePathForComparison(filePath);
const file = files?.find(
(candidate) => normalizePathForComparison(candidate.filePath) === normalizedFilePath
);
return file ? getFileReviewKey(file) : filePath;
}
export function buildHunkDecisionKey(reviewKey: string, index: number): string {
return `${reviewKey}:${index}`;
}
export function parseHunkDecisionKey(key: string): { reviewKey: string; index: number } | null {
const match = /^(.*):(\d+)$/.exec(key);
if (!match) {
return null;
}
return {
reviewKey: match[1] ?? '',
index: Number.parseInt(match[2] ?? '', 10),
};
}
export function normalizePersistedReviewState(
files: readonly Pick<FileChangeSummary, 'filePath' | 'changeKey'>[],
state: {
fileDecisions?: Record<string, HunkDecision>;
hunkDecisions?: Record<string, HunkDecision>;
hunkContextHashesByFile?: Record<string, Record<number, string>>;
}
): {
fileDecisions: Record<string, HunkDecision>;
hunkDecisions: Record<string, HunkDecision>;
hunkContextHashesByFile: Record<string, Record<number, string>>;
} {
const reviewKeyByAlias = new Map<string, string>();
const addAlias = (alias: string, reviewKey: string): void => {
reviewKeyByAlias.set(alias, reviewKey);
reviewKeyByAlias.set(normalizeReviewAlias(alias), reviewKey);
};
const resolveReviewKey = (alias: string): string | undefined => {
return reviewKeyByAlias.get(alias) ?? reviewKeyByAlias.get(normalizeReviewAlias(alias));
};
for (const file of files) {
const reviewKey = getFileReviewKey(file);
addAlias(reviewKey, reviewKey);
addAlias(file.filePath, reviewKey);
}
const fileDecisions: Record<string, HunkDecision> = {};
for (const [key, decision] of Object.entries(state.fileDecisions ?? {})) {
const reviewKey = resolveReviewKey(key);
if (reviewKey) {
fileDecisions[reviewKey] = decision;
}
}
const hunkDecisions: Record<string, HunkDecision> = {};
for (const [key, decision] of Object.entries(state.hunkDecisions ?? {})) {
const parsed = parseHunkDecisionKey(key);
if (!parsed) {
continue;
}
const reviewKey = resolveReviewKey(parsed.reviewKey);
if (reviewKey) {
hunkDecisions[buildHunkDecisionKey(reviewKey, parsed.index)] = decision;
}
}
const hunkContextHashesByFile: Record<string, Record<number, string>> = {};
for (const [key, hashes] of Object.entries(state.hunkContextHashesByFile ?? {})) {
const reviewKey = resolveReviewKey(key);
if (reviewKey) {
hunkContextHashesByFile[reviewKey] = hashes;
}
}
return { fileDecisions, hunkDecisions, hunkContextHashesByFile };
}

View file

@ -0,0 +1,19 @@
export { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence';
import type { TaskChangeSetV2 } from '@shared/types';
export function shouldBackgroundRevalidateTaskPresence(
data: TaskChangeSetV2,
sessionStartedAtMs: number
): boolean {
if (data.provenance?.sourceKind === 'ledger' && !!data.provenance.sourceFingerprint) {
return false;
}
const computedAtMs = Date.parse(data.computedAt);
if (!Number.isFinite(computedAtMs)) {
return true;
}
return computedAtMs < sessionStartedAtMs;
}

View file

@ -700,7 +700,8 @@ export interface ReviewAPI {
// Decision persistence
loadDecisions: (
teamName: string,
scopeKey: string
scopeKey: string,
scopeToken?: string
) => Promise<{
hunkDecisions: Record<string, HunkDecision>;
fileDecisions: Record<string, HunkDecision>;
@ -713,11 +714,12 @@ export interface ReviewAPI {
saveDecisions: (
teamName: string,
scopeKey: string,
scopeToken: string,
hunkDecisions: Record<string, HunkDecision>,
fileDecisions: Record<string, HunkDecision>,
hunkContextHashesByFile?: Record<string, Record<number, string>>
) => Promise<void>;
clearDecisions: (teamName: string, scopeKey: string) => Promise<void>;
clearDecisions: (teamName: string, scopeKey: string, scopeToken?: string) => Promise<void>;
onCmdN?: (callback: () => void) => (() => void) | undefined;
// Phase 4
getGitFileLog: (

View file

@ -3,6 +3,9 @@ export interface LedgerContentState {
exists?: boolean;
sha256?: string;
sizeBytes?: number;
contentKind?: 'text' | 'binary' | 'unknown';
blobRef?: string;
unavailableCode?: 'binary' | 'too-large' | 'read-error' | 'not-captured' | 'blob-missing';
unavailableReason?: string;
}
@ -45,9 +48,31 @@ export interface SnippetDiff {
afterState?: LedgerContentState;
relation?: LedgerChangeRelation;
executionSeq?: number;
linesAdded?: number;
linesRemoved?: number;
textAvailability?: 'patch-text' | 'full-text' | 'unavailable';
};
}
export interface TaskChangeJournalFileStamp {
bytes: number;
mtimeMs: number;
tailSha256: string | null;
}
export interface TaskChangeJournalStamp {
events?: TaskChangeJournalFileStamp;
notices?: TaskChangeJournalFileStamp;
}
export interface TaskChangeProvenance {
sourceKind: 'ledger' | 'legacy';
sourceFingerprint: string;
journalStamp?: TaskChangeJournalStamp;
bundleSchemaVersion?: number;
integrity?: 'ok' | 'recovered' | 'partial';
}
/** Агрегированные изменения по файлу */
export interface FileChangeSummary {
filePath: string;
@ -56,6 +81,22 @@ export interface FileChangeSummary {
linesAdded: number;
linesRemoved: number;
isNewFile: boolean;
changeKey?: string;
diffStatKnown?: boolean;
ledgerSummary?: {
latestOperation?: 'create' | 'modify' | 'delete';
createdInTask?: boolean;
deletedInTask?: boolean;
contentAvailability?: 'full-text' | 'hash-only' | 'metadata-only';
reviewability?: 'full-text' | 'partial-text' | 'metadata-only';
relation?: LedgerChangeRelation;
beforeState?: LedgerContentState;
afterState?: LedgerContentState;
primaryActorKey?: string;
agentIds?: string[];
memberNames?: string[];
executionSeqRange?: { start: number; end: number };
};
/** Edit timeline for this file (Phase 4) */
timeline?: FileEditTimeline;
}
@ -192,6 +233,34 @@ export interface TaskChangeScope {
toolUseIds: string[];
filePaths: string[];
confidence: TaskScopeConfidence;
primaryActorKey?: string;
primaryAgentId?: string;
primaryMemberName?: string;
agentIds?: string[];
memberNames?: string[];
toolUseCount?: number;
toolUseIdsTruncated?: boolean;
phaseSet?: Array<'work' | 'review'>;
executionSeqRange?: { start: number; end: number };
confidenceBreakdown?: {
capture: 'exact' | 'high' | 'medium' | 'low';
attribution: 'high' | 'medium' | 'low' | 'ambiguous';
reviewability: 'full-text' | 'mixed' | 'metadata-only';
};
contributors?: Array<{
actorKey: string;
agentId?: string;
memberName?: string;
eventCount: number;
noticeCount: number;
touchedFileCount: number;
visibleFileCount: number;
toolUseCount: number;
cumulativeLinesAdded: number;
cumulativeLinesRemoved: number;
firstTimestamp: string;
lastTimestamp: string;
}>;
}
/** Результат парсинга всех границ задач из JSONL файла */
@ -206,6 +275,8 @@ export interface TaskBoundariesResult {
export interface TaskChangeSetV2 extends TaskChangeSet {
scope: TaskChangeScope;
warnings: string[];
diffStatCompleteness?: 'complete' | 'partial';
provenance?: TaskChangeProvenance;
}
// ── Phase 4: Enhanced Features types ──

View file

@ -450,7 +450,11 @@ export interface TeamTask {
}
/** Task enriched for UI/DTO use (overlay from kanban-state.json). */
export type TaskChangePresenceState = 'has_changes' | 'no_changes' | 'unknown';
export type TaskChangePresenceState =
| 'has_changes'
| 'needs_attention'
| 'no_changes'
| 'unknown';
export interface TeamTaskWithKanban extends TeamTask {
/** Set when task is in team kanban (review or approved column). */

View file

@ -0,0 +1,15 @@
import type { TaskChangePresenceState, TaskChangeSetV2 } from '../types';
export function resolveTaskChangePresenceFromResult(
data: Pick<TaskChangeSetV2, 'files' | 'confidence' | 'warnings'>
): Exclude<TaskChangePresenceState, 'unknown'> | null {
if (data.files.length > 0) {
return 'has_changes';
}
if ((data.warnings?.length ?? 0) > 0) {
return 'needs_attention';
}
return data.confidence === 'high' || data.confidence === 'medium' ? 'no_changes' : null;
}

View file

@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"name": "binary",
"taskId": "fixture-binary",
"description": "Metadata-only binary fixture generated from a shell snapshot mutation.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [
"Before content unavailable for fixtures/blob.bin: binary file."
],
"relativePaths": [
"fixtures/blob.bin"
],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-binary","updatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-binary","generatedAt":"2026-04-21T13:29:00.857Z","journalStamp":{"events":{"bytes":1214,"mtimeMs":1776778140855.1409,"tailSha256":"4bb95e22a7a7588131ea9b1a1e45894d32af7b0ae4da25efed97f97930898c42"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.852Z","endTimestamp":"2026-04-21T13:29:00.852Z","toolUseIds":["tool-binary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"mixed"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/fixtures/blob.bin","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","linesAdded":0,"linesRemoved":0,"diffStatKnown":false,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.852Z","lastTimestamp":"2026-04-21T13:29:00.852Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","latestBeforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"latestAfterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"contentAvailability":"hash-only","reviewability":"partial-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1},"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"partial","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-binary","taskRef":"fixture-binary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-jtt7uons2f7","agentId":"alice@test","memberName":"alice","toolUseId":"tool-binary","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/fixtures/blob.bin","relativePath":"fixtures/blob.bin","timestamp":"2026-04-21T13:29:00.852Z","toolStatus":"succeeded","before":null,"after":{"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4,"blobRef":"sha256/c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d"},"beforeState":{"exists":true,"sizeBytes":4,"unavailableReason":"binary file"},"afterState":{"exists":true,"sha256":"c6d44cf418f610e3fe9e1d9294ff43def81c6cdcad6cbb1820cff48d3aa4355d","sizeBytes":4},"linesAdded":1,"linesRemoved":0,"warnings":["Before content unavailable for fixtures/blob.bin: binary file."],"eventId":"6395549e93913abdd0c26120b21a911902c2fa85f5aa84b50f91ca6a2949444a"}

View file

@ -0,0 +1 @@


View file

@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"name": "copy",
"taskId": "fixture-copy",
"description": "Copy relation fixture generated from a committed copy detected via git diff.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/copy.ts"
],
"relationKinds": [
"copy"
]
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-copy","updatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-copy","generatedAt":"2026-04-21T13:29:00.540Z","journalStamp":{"events":{"bytes":1147,"mtimeMs":1776778140538.096,"tailSha256":"08db9858fc25d78d0b9d78b72b054561f2b5df7d758eb1aef2d2c1b14a2b996b"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:00.535Z","endTimestamp":"2026-04-21T13:29:00.535Z","toolUseIds":["tool-copy"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z"}]},"files":[{"changeKey":"copy:src/base.ts->src/copy.ts","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","displayPath":"src/copy.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:00.535Z","lastTimestamp":"2026-04-21T13:29:00.535Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-copy","taskRef":"fixture-copy","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-o2uv73v5zw","agentId":"alice@test","memberName":"alice","toolUseId":"tool-copy","source":"shell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/copy.ts","relativePath":"src/copy.ts","timestamp":"2026-04-21T13:29:00.535Z","toolStatus":"succeeded","before":null,"after":{"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28,"blobRef":"sha256/a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"a58ab88a5e4c20675e2e4b9a139ebe55e04d2f89a52bc2c7fa4d5a7557a24832","sizeBytes":28},"relation":{"kind":"copy","oldPath":"src/base.ts","newPath":"src/copy.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"20f28b7e49e9019165c3e3b57d0104b6b2107447f44df8ba88c3033d6520cd93"}

View file

@ -0,0 +1 @@
export const copied = true;

View file

@ -0,0 +1 @@
export const copied = true;

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "generation-mismatch",
"taskId": "fixture-generation-mismatch",
"description": "Fixture with intentionally mismatched freshness metadata to force journal fallback.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/mismatch.ts"
],
"relationKinds": []
}
}

View file

@ -0,0 +1,18 @@
{
"schemaVersion": 2,
"source": "task-change-ledger",
"taskId": "fixture-generation-mismatch",
"updatedAt": "2026-04-21T13:29:01.353Z",
"journalStamp": {
"events": {
"bytes": 1123,
"mtimeMs": 1776778141346.9556,
"tailSha256": "mismatched-tail-sha256"
}
},
"eventCount": 1,
"noticeCount": 0,
"integrity": "ok",
"bundleSchemaVersion": 2,
"bundleKind": "summary"
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-generation-mismatch","generatedAt":"2026-04-21T13:29:01.353Z","journalStamp":{"events":{"bytes":1123,"mtimeMs":1776778141346.9556,"tailSha256":"80e3ed6cc6bfad47ed0862a64b2aeeb13d78959cc727bbcccc2f0b8d77c5b0d3"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.341Z","endTimestamp":"2026-04-21T13:29:01.341Z","toolUseIds":["tool-generation-mismatch"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/mismatch.ts","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.341Z","lastTimestamp":"2026-04-21T13:29:01.341Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-generation-mismatch","taskRef":"fixture-generation-mismatch","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-blrpcg3sdoq","agentId":"alice@test","memberName":"alice","toolUseId":"tool-generation-mismatch","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/mismatch.ts","relativePath":"src/mismatch.ts","timestamp":"2026-04-21T13:29:01.341Z","toolStatus":"succeeded","before":null,"after":{"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27,"blobRef":"sha256/87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"87dc6709c838182b7415bd2507ead5461b8dc5a491189747c5f87ff3c278e797","sizeBytes":27},"linesAdded":1,"linesRemoved":0,"eventId":"7bd0e6e9290680d76da0498ff931f64285a2f3acfc293ccf0f5756693943c897"}

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "missing-blob",
"taskId": "fixture-missing-blob",
"description": "Fixture with a missing pre-change text blob to validate metadata-only downgrade paths.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/missing.ts"
],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-missing-blob","updatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-missing-blob","generatedAt":"2026-04-21T13:29:01.183Z","journalStamp":{"events":{"bytes":1365,"mtimeMs":1776778141172.5513,"tailSha256":"8d61c0c24f5e7e8cb1023ae74c80dcc12a65df573ad43639f740d8fc0c900240"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.162Z","endTimestamp":"2026-04-21T13:29:01.162Z","toolUseIds":["tool-missing-blob"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z"}]},"files":[{"changeKey":"path:__PROJECT_ROOT__/src/missing.ts","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","linesAdded":1,"linesRemoved":1,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.162Z","lastTimestamp":"2026-04-21T13:29:01.162Z","latestOperation":"modify","createdInTask":false,"deletedInTask":false,"baselineExists":true,"finalExists":true,"latestBeforeHash":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","latestAfterHash":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","latestBeforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"latestAfterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":1,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-missing-blob","taskRef":"fixture-missing-blob","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-koi1xm1bwwb","agentId":"alice@test","memberName":"alice","toolUseId":"tool-missing-blob","source":"shell_snapshot","operation":"modify","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/missing.ts","relativePath":"src/missing.ts","timestamp":"2026-04-21T13:29:01.162Z","toolStatus":"succeeded","before":{"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26,"blobRef":"sha256/65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76"},"after":{"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26,"blobRef":"sha256/826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74"},"beforeState":{"exists":true,"sha256":"65463fdad618e388f1302370c6cc844f31896a918905a336abd07fa09a16de76","sizeBytes":26},"afterState":{"exists":true,"sha256":"826b9894c0687ceebf0ec337dcfe10ef98f288997f79669e5374a60615277f74","sizeBytes":26},"linesAdded":1,"linesRemoved":1,"eventId":"010b63907849617acbf3138427d82382e19dcdfd1cfe03f711d27a88ed6ccbab"}

View file

@ -0,0 +1 @@
export const missing = 2;

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "notices-only",
"taskId": "fixture-notices-only",
"description": "Warning-only ledger fixture with ambiguous multi-scope attribution and no file events.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 0,
"warnings": [
"Task change ledger skipped attribution because multiple task scopes were active."
],
"relativePaths": [],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only-other","updatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-notices-only","updatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"eventCount":0,"noticeCount":1,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only-other","generatedAt":"2026-04-21T13:28:59.589Z","journalStamp":{"notices":{"bytes":527,"mtimeMs":1776778139586.9287,"tailSha256":"a9459d15d714a8ecd00b8a4d7b8927b761413fdee4e5612f142ee79d6a8972d4"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-notices-only","generatedAt":"2026-04-21T13:28:59.588Z","journalStamp":{"notices":{"bytes":513,"mtimeMs":1776778139586.921,"tailSha256":"b93fbb086735973e4d019dc523eadfe7c76a8387a0e0c46ca7670c9d1c916a50"}},"integrity":"ok","eventCount":0,"noticeCount":1,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":[],"startTimestamp":"2026-04-21T13:28:59.581Z","endTimestamp":"2026-04-21T13:28:59.581Z","toolUseIds":["tool-notices-only"],"toolUseCount":1,"phaseSet":[],"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":0,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":0,"noticeCount":1,"touchedFileCount":0,"visibleFileCount":0,"toolUseCount":0,"cumulativeLinesAdded":0,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.581Z","lastTimestamp":"2026-04-21T13:28:59.581Z"}]},"files":[],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":0,"confidence":"high","warningCount":1,"warnings":["Task change ledger skipped attribution because multiple task scopes were active."]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-notices-only-other","taskRef":"fixture-notices-only-other","taskRefKind":"canonical","phase":"review","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"56b59b7d7ade431d01e079f65a23ebe31fa0e8a2472976edf7a85629a2640526"}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-notices-only","taskRef":"fixture-notices-only","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-t7m5r3skiy9","agentId":"alice@test","memberName":"alice","toolUseId":"tool-notices-only","timestamp":"2026-04-21T13:28:59.581Z","severity":"warning","message":"Task change ledger skipped attribution because multiple task scopes were active.","code":"multi-scope-skipped","noticeId":"8c1d4d4642d7a26f4e7d9959d6908c2d6fe14d1ad49df10dccc7f3b4ebdf95e5"}

View file

@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"name": "recovered-journal",
"taskId": "fixture-recovered-journal",
"description": "Recovered journal fixture generated after replaying malformed journal lines.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [
"Task change ledger recovered from malformed journal lines."
],
"relativePaths": [
"src/ok.ts"
],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-recovered-journal","updatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"eventCount":1,"noticeCount":0,"integrity":"recovered","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-recovered-journal","generatedAt":"2026-04-21T13:29:01.277Z","journalStamp":{"events":{"bytes":2236,"mtimeMs":1776778141275.4143,"tailSha256":"65076527bbf6eb40a38b92b15064f89e6a0bfddb02c71521b74a2f736738f35d"}},"integrity":"recovered","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger with recovery from malformed journal lines"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:29:01.263Z","endTimestamp":"2026-04-21T13:29:01.263Z","toolUseIds":["tool-recovered-initial"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/ok.ts","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:29:01.263Z","lastTimestamp":"2026-04-21T13:29:01.263Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":1,"warnings":["Task change ledger recovered from malformed journal lines."]}

View file

@ -0,0 +1,3 @@
{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-initial","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/ok.ts","relativePath":"src/ok.ts","timestamp":"2026-04-21T13:29:01.263Z","toolStatus":"succeeded","before":null,"after":{"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24,"blobRef":"sha256/ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"ad5abda1e1f8bfb618e985a13fbe07068662681d75f2c253244ec898a773c120","sizeBytes":24},"linesAdded":1,"linesRemoved":0,"eventId":"e7b3eca1462d1e2496e50ce01273857230234ae89fbd4177b58ed7dbe7b8c3a5"}
{"bad-json"{"schemaVersion":1,"taskId":"fixture-recovered-journal","taskRef":"fixture-recovered-journal","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-ra9z525g47e","agentId":"alice@test","memberName":"alice","toolUseId":"tool-recovered-trigger","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/recovered.ts","relativePath":"src/recovered.ts","timestamp":"2026-04-21T13:29:01.273Z","toolStatus":"succeeded","before":null,"after":{"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31,"blobRef":"sha256/fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"fd578c66aab455f63586a0a9d91424d938780b7475ff8025cd131a37997bc5c5","sizeBytes":31},"linesAdded":1,"linesRemoved":0,"eventId":"e0410d756f74335ccfa9601554096d676c6f72f88e31cf038e2f73e046633cc6"}

View file

@ -0,0 +1,17 @@
{
"schemaVersion": 1,
"name": "rename",
"taskId": "fixture-rename",
"description": "Rename relation fixture generated from a committed rename shell snapshot.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/new.ts"
],
"relationKinds": [
"rename"
]
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-rename","updatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"eventCount":2,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-rename","generatedAt":"2026-04-21T13:28:59.977Z","journalStamp":{"events":{"bytes":2314,"mtimeMs":1776778139972.8428,"tailSha256":"2e38e7b5785bc7866fc787c236514dc608683bfcfb917a00d77c242821da27fc"}},"integrity":"ok","eventCount":2,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.967Z","endTimestamp":"2026-04-21T13:28:59.967Z","toolUseIds":["tool-rename"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"high","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":2,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":2,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":1,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z"}]},"files":[{"changeKey":"rename:src/old.ts->src/new.ts","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","displayPath":"src/new.ts","linesAdded":0,"linesRemoved":0,"diffStatKnown":true,"eventCount":2,"firstTimestamp":"2026-04-21T13:28:59.967Z","lastTimestamp":"2026-04-21T13:28:59.967Z","latestOperation":"delete","createdInTask":false,"deletedInTask":false,"baselineExists":false,"finalExists":false,"latestBeforeHash":null,"latestAfterHash":null,"latestBeforeState":{"exists":false},"latestAfterState":{"exists":false},"contentAvailability":"full-text","reviewability":"full-text","relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":0,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1,2 @@
{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"create","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/new.ts","relativePath":"src/new.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":null,"after":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":1,"linesRemoved":0,"eventId":"83c57985d61c25bc69b72df159a8f89688dd05731c93f6df854310ab8fffa171"}
{"schemaVersion":1,"taskId":"fixture-rename","taskRef":"fixture-rename","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-4b7f2gb7q9v","agentId":"alice@test","memberName":"alice","toolUseId":"tool-rename","source":"powershell_snapshot","operation":"delete","confidence":"high","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/old.ts","relativePath":"src/old.ts","timestamp":"2026-04-21T13:28:59.967Z","toolStatus":"succeeded","before":{"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29,"blobRef":"sha256/4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9"},"after":null,"beforeState":{"exists":true,"sha256":"4cc3add09a9afbbed466ff8044763f64919804d92341f50935df2d46eed748b9","sizeBytes":29},"afterState":{"exists":false},"relation":{"kind":"rename","oldPath":"src/old.ts","newPath":"src/new.ts"},"linesAdded":0,"linesRemoved":1,"eventId":"417d115a2d185ba5c288253beb73d4697f59021e5bb8c5f6553b2c68c4d7c6e7"}

View file

@ -0,0 +1 @@
export const renamed = true;

View file

@ -0,0 +1,15 @@
{
"schemaVersion": 1,
"name": "v2-summary",
"taskId": "fixture-v2-summary",
"description": "Simple bundle v2 summary fixture generated from an exact file write.",
"projectRootToken": "__PROJECT_ROOT__",
"expected": {
"totalFiles": 1,
"warnings": [],
"relativePaths": [
"src/summary.ts"
],
"relationKinds": []
}
}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","taskId":"fixture-v2-summary","updatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"eventCount":1,"noticeCount":0,"integrity":"ok","bundleSchemaVersion":2,"bundleKind":"summary"}

View file

@ -0,0 +1 @@
{"schemaVersion":2,"source":"task-change-ledger","bundleKind":"summary","taskId":"fixture-v2-summary","generatedAt":"2026-04-21T13:28:59.490Z","journalStamp":{"events":{"bytes":1091,"mtimeMs":1776778139486.1023,"tailSha256":"ae82715bca5c034912612085fd4760cf62a134dc9d5f375725972f9f5503f92f"}},"integrity":"ok","eventCount":1,"noticeCount":0,"scope":{"confidence":{"tier":1,"label":"high","reason":"Derived from task-change ledger"},"primaryActorKey":"member:alice","primaryAgentId":"alice@test","primaryMemberName":"alice","memberName":"alice","agentIds":["alice@test"],"memberNames":["alice"],"startTimestamp":"2026-04-21T13:28:59.479Z","endTimestamp":"2026-04-21T13:28:59.479Z","toolUseIds":["tool-summary"],"toolUseCount":1,"phaseSet":["work"],"executionSeqRange":{"start":1,"end":1},"confidenceBreakdown":{"capture":"exact","attribution":"high","reviewability":"full-text"},"visibleFileCount":1,"contributors":[{"actorKey":"member:alice","agentId":"alice@test","memberName":"alice","eventCount":1,"noticeCount":0,"touchedFileCount":1,"visibleFileCount":1,"toolUseCount":1,"cumulativeLinesAdded":1,"cumulativeLinesRemoved":0,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z"}]},"files":[{"changeKey":"create:__PROJECT_ROOT__/src/summary.ts","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","linesAdded":1,"linesRemoved":0,"diffStatKnown":true,"eventCount":1,"firstTimestamp":"2026-04-21T13:28:59.479Z","lastTimestamp":"2026-04-21T13:28:59.479Z","latestOperation":"create","createdInTask":true,"deletedInTask":false,"baselineExists":false,"finalExists":true,"latestBeforeHash":null,"latestAfterHash":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","latestBeforeState":{"exists":false},"latestAfterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"contentAvailability":"full-text","reviewability":"full-text","primaryActorKey":"member:alice","agentIds":["alice@test"],"memberNames":["alice"],"executionSeqRange":{"start":1,"end":1}}],"totalLinesAdded":1,"totalLinesRemoved":0,"diffStatCompleteness":"complete","totalFiles":1,"confidence":"high","warningCount":0,"warnings":[]}

View file

@ -0,0 +1 @@
{"schemaVersion":1,"taskId":"fixture-v2-summary","taskRef":"fixture-v2-summary","taskRefKind":"canonical","phase":"work","executionSeq":1,"sessionId":"fixture-yas25omycm8","agentId":"alice@test","memberName":"alice","toolUseId":"tool-summary","source":"file_write","operation":"create","confidence":"exact","workspaceRoot":"__PROJECT_ROOT__","filePath":"__PROJECT_ROOT__/src/summary.ts","relativePath":"src/summary.ts","timestamp":"2026-04-21T13:28:59.479Z","toolStatus":"succeeded","before":null,"after":{"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26,"blobRef":"sha256/5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf"},"beforeState":{"exists":false},"afterState":{"exists":true,"sha256":"5bb3b6edb1b3ae9e1d3f2f2db093fa0123f3c15d2c0a22d60132c4dd5247ffcf","sizeBytes":26},"linesAdded":1,"linesRemoved":0,"eventId":"2192b24e939f42bd7936f4fbe620d66c76c88208cdf64ef3c5a3f501939f3839"}

View file

@ -732,6 +732,52 @@ describe('ChangeExtractorService', () => {
);
});
it('writes needs_attention presence entries for warning-only task diff results', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);
await writeTaskFile(tmpDir);
const upsertEntry = vi.fn(async () => undefined);
const ensureTracking = vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}));
const workerClient = {
isAvailable: vi.fn(() => true),
computeTaskChanges: vi.fn(async () =>
makeTaskChangeResult(TASK_ID, {
content: '',
confidence: 'fallback',
warning: 'Ledger skipped attribution because multiple task scopes were active.',
})
),
};
const { service } = createService({
logPaths: [],
taskChangePresenceRepository: { upsertEntry },
teamLogSourceTracker: { ensureTracking },
taskChangeWorkerClient: workerClient,
});
const result = await service.getTaskChanges(TEAM_NAME, TASK_ID, SUMMARY_OPTIONS);
expect(result.files).toHaveLength(0);
expect(result.warnings).toEqual([
'Ledger skipped attribution because multiple task scopes were active.',
]);
expect(upsertEntry).toHaveBeenCalledWith(
TEAM_NAME,
expect.objectContaining({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
}),
expect.objectContaining({
taskId: TASK_ID,
presence: 'needs_attention',
})
);
});
it('does not write no_changes presence entries for uncertain empty task diff results', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-service-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -126,6 +126,45 @@ describe('FileContentResolver', () => {
expect(content.contentSource).toBe('ledger-snapshot');
});
it('does not synthesize empty text for metadata-only ledger lifecycle states', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
readFile.mockRejectedValue(new Error('ENOENT'));
const { FileContentResolver } = await import('@main/services/team/FileContentResolver');
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn().mockResolvedValue([]) } as any);
const content = await resolver.getFileContent('team', 'member', '/tmp/binary-create.bin', [
{
toolUseId: 'ledger-1',
filePath: '/tmp/binary-create.bin',
toolName: 'Bash',
type: 'shell-snapshot',
oldString: '',
newString: '',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-snapshot',
confidence: 'high',
originalFullContent: null,
modifiedFullContent: null,
beforeHash: null,
afterHash: 'hash',
operation: 'create',
beforeState: { exists: false, unavailableReason: 'binary file' },
afterState: { exists: true, sha256: 'hash', unavailableReason: 'binary file' },
},
},
]);
expect(content.originalFullContent).toBeNull();
expect(content.modifiedFullContent).toBeNull();
expect(content.contentSource).toBe('unavailable');
});
it('reuses cached content only when disk bytes and snippets are unchanged', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;

View file

@ -581,6 +581,77 @@ describe('ReviewApplierService', () => {
expect(writeFile).not.toHaveBeenCalled();
expect(unlink).not.toHaveBeenCalled();
});
it('ledger exact partial reject stays in the strict ledger lane and applies inverse hunk patch', async () => {
const fsPromises = await import('fs/promises');
const readFile = fsPromises.readFile as unknown as ReturnType<typeof vi.fn>;
const writeFile = fsPromises.writeFile as unknown as ReturnType<typeof vi.fn>;
const filePath = '/tmp/exact-ledger.ts';
const original = 'const value = 1;\nconst keep = true;\n';
const modified = 'const value = 2;\nconst keep = true;\n';
readFile.mockResolvedValue(modified);
writeFile.mockResolvedValue(undefined);
const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService');
const svc = new ReviewApplierService();
const res = await svc.applyReviewDecisions(
{
teamName: 'team',
decisions: [
{
filePath,
fileDecision: 'pending',
hunkDecisions: { 0: 'rejected' },
},
],
},
new Map([
[
filePath,
{
filePath,
relativePath: 'exact-ledger.ts',
snippets: [
{
toolUseId: 'ledger-1',
filePath,
toolName: 'Edit',
type: 'edit',
oldString: 'const value = 1;\n',
newString: 'const value = 2;\n',
replaceAll: false,
timestamp: '2026-03-01T10:00:00.000Z',
isError: false,
ledger: {
eventId: 'event-1',
source: 'ledger-exact',
confidence: 'exact',
originalFullContent: original,
modifiedFullContent: modified,
beforeHash: sha(original),
afterHash: sha(modified),
operation: 'modify',
beforeState: { exists: true, sha256: sha(original) },
afterState: { exists: true, sha256: sha(modified) },
},
},
],
linesAdded: 1,
linesRemoved: 1,
isNewFile: false,
originalFullContent: original,
modifiedFullContent: modified,
contentSource: 'ledger-exact',
},
],
])
);
expect(res).toMatchObject({ applied: 1, conflicts: 0 });
expect(writeFile).toHaveBeenCalledWith(filePath, original, 'utf8');
});
});
function sha(content: string): string {

View file

@ -0,0 +1,135 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
let teamsBasePath: string;
vi.mock('@main/utils/pathDecoder', () => ({
getTeamsBasePath: () => teamsBasePath,
}));
describe('ReviewDecisionStore', () => {
beforeEach(async () => {
teamsBasePath = await mkdtemp(path.join(tmpdir(), 'review-decision-store-'));
});
afterEach(async () => {
await rm(teamsBasePath, { recursive: true, force: true });
});
it('stores exact-scope decision variants without last-write-wins overwrite', async () => {
const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore');
const store = new ReviewDecisionStore();
await store.save('demo', 'task-123', {
scopeToken: 'task:123:req:a:src:one',
hunkDecisions: { 'file-a:0': 'rejected' },
fileDecisions: { 'file-a': 'rejected' },
});
await store.save('demo', 'task-123', {
scopeToken: 'task:123:req:b:src:two',
hunkDecisions: { 'file-b:0': 'accepted' },
fileDecisions: { 'file-b': 'accepted' },
});
await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toEqual({
hunkDecisions: { 'file-a:0': 'rejected' },
fileDecisions: { 'file-a': 'rejected' },
hunkContextHashesByFile: undefined,
});
await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({
hunkDecisions: { 'file-b:0': 'accepted' },
fileDecisions: { 'file-b': 'accepted' },
hunkContextHashesByFile: undefined,
});
});
it('clears only the exact v2 scope file and leaves sibling variants intact', async () => {
const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore');
const store = new ReviewDecisionStore();
await store.save('demo', 'task-123', {
scopeToken: 'task:123:req:a:src:one',
hunkDecisions: { 'file-a:0': 'rejected' },
fileDecisions: { 'file-a': 'rejected' },
});
await store.save('demo', 'task-123', {
scopeToken: 'task:123:req:b:src:two',
hunkDecisions: { 'file-b:0': 'accepted' },
fileDecisions: { 'file-b': 'accepted' },
});
await store.clear('demo', 'task-123', 'task:123:req:a:src:one');
await expect(store.load('demo', 'task-123', 'task:123:req:a:src:one')).resolves.toBeNull();
await expect(store.load('demo', 'task-123', 'task:123:req:b:src:two')).resolves.toEqual({
hunkDecisions: { 'file-b:0': 'accepted' },
fileDecisions: { 'file-b': 'accepted' },
hunkContextHashesByFile: undefined,
});
});
it('still dual-reads legacy coarse files for matching scope tokens', async () => {
const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore');
const store = new ReviewDecisionStore();
const legacyDir = path.join(teamsBasePath, 'demo', 'review-decisions');
await mkdir(legacyDir, { recursive: true });
await writeFile(
path.join(legacyDir, 'task-123.json'),
JSON.stringify({
scopeToken: 'task:123:req:legacy:src:one',
hunkDecisions: { 'file-a:0': 'rejected' },
fileDecisions: { 'file-a': 'rejected' },
updatedAt: '2026-04-21T10:00:00.000Z',
}),
'utf8'
);
await expect(
store.load('demo', 'task-123', 'task:123:req:legacy:src:one')
).resolves.toEqual({
hunkDecisions: { 'file-a:0': 'rejected' },
fileDecisions: { 'file-a': 'rejected' },
hunkContextHashesByFile: undefined,
});
});
it('writes versioned v2 payloads under the scoped directory', async () => {
const { ReviewDecisionStore } = await import('@main/services/team/ReviewDecisionStore');
const store = new ReviewDecisionStore();
await store.save('demo', 'task-123', {
scopeToken: 'task:123:req:a:src:one',
hunkDecisions: {},
fileDecisions: {},
});
const scopeDir = path.join(
teamsBasePath,
'demo',
'review-decisions',
'v2',
encodeURIComponent('task-123')
);
const entries = await fsEntries(scopeDir);
expect(entries).toHaveLength(1);
const payload = JSON.parse(await readFile(path.join(scopeDir, entries[0]!), 'utf8')) as {
version?: number;
scopeKey?: string;
scopeToken?: string;
};
expect(payload.version).toBe(2);
expect(payload.scopeKey).toBe('task-123');
expect(payload.scopeToken).toBe('task:123:req:a:src:one');
});
});
async function fsEntries(dirPath: string): Promise<string[]> {
try {
return await (await import('fs/promises')).readdir(dirPath);
} catch {
return [];
}
}

View file

@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
@ -8,6 +9,10 @@ import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerRead
const TASK_ID = 'task-1';
function safeTaskIdSegment(taskId: string): string {
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
}
describe('TaskChangeLedgerReader', () => {
let tmpDir: string | null = null;
@ -56,6 +61,41 @@ describe('TaskChangeLedgerReader', () => {
expect(result?.scope.toolUseIds).toEqual(['tool-1']);
});
it('reads ledger artifacts stored under Windows-safe task id segments', async () => {
tmpDir = await fsTempDir();
const taskId = 'CON';
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
await mkdir(bundleDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${safeTaskIdSegment(taskId)}.json`),
JSON.stringify({
schemaVersion: 1,
source: 'task-change-ledger',
taskId,
generatedAt: '2026-03-01T10:00:00.000Z',
eventCount: 0,
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'high',
warnings: ['reserved segment safe path'],
events: [],
}),
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId,
projectDir: tmpDir,
includeDetails: true,
});
expect(result?.warnings).toContain('reserved segment safe path');
});
it('maps ledger state and rename relation into snippets', async () => {
tmpDir = await makeLedgerBundle({
events: [
@ -178,6 +218,132 @@ describe('TaskChangeLedgerReader', () => {
expect(result?.files[0]?.linesAdded).toBe(3);
expect(result?.files[0]?.linesRemoved).toBe(2);
});
it('falls back to journal summary when bundle and freshness describe different generations', async () => {
tmpDir = await fsTempDir();
const bundleDir = path.join(tmpDir, '.board-task-changes', 'bundles');
const eventsDir = path.join(tmpDir, '.board-task-changes', 'events');
const freshnessDir = path.join(tmpDir, '.board-task-change-freshness');
await mkdir(bundleDir, { recursive: true });
await mkdir(eventsDir, { recursive: true });
await mkdir(freshnessDir, { recursive: true });
await writeFile(
path.join(bundleDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
bundleKind: 'summary',
taskId: TASK_ID,
generatedAt: '2026-03-01T10:00:00.000Z',
journalStamp: { events: { bytes: 10, mtimeMs: 1, tailSha256: 'bundle' } },
integrity: 'ok',
eventCount: 1,
noticeCount: 0,
scope: {
confidence: { tier: 1, label: 'high', reason: 'bundle' },
memberName: 'bundle-agent',
agentIds: ['bundle-agent'],
startTimestamp: '2026-03-01T10:00:00.000Z',
endTimestamp: '2026-03-01T10:00:00.000Z',
toolUseIds: ['bundle-tool'],
toolUseCount: 1,
phaseSet: ['work'],
visibleFileCount: 1,
contributors: [],
},
files: [
{
changeKey: 'path:/repo/stale.ts',
filePath: '/repo/stale.ts',
relativePath: 'stale.ts',
linesAdded: 1,
linesRemoved: 0,
diffStatKnown: true,
eventCount: 1,
firstTimestamp: '2026-03-01T10:00:00.000Z',
lastTimestamp: '2026-03-01T10:00:00.000Z',
latestOperation: 'modify',
createdInTask: false,
deletedInTask: false,
latestBeforeHash: null,
latestAfterHash: null,
contentAvailability: 'metadata-only',
reviewability: 'metadata-only',
agentIds: ['bundle-agent'],
},
],
totalLinesAdded: 1,
totalLinesRemoved: 0,
diffStatCompleteness: 'complete',
totalFiles: 1,
confidence: 'high',
warningCount: 0,
warnings: [],
}),
'utf8'
);
await writeFile(
path.join(freshnessDir, `${encodeURIComponent(TASK_ID)}.json`),
JSON.stringify({
schemaVersion: 2,
source: 'task-change-ledger',
taskId: TASK_ID,
updatedAt: '2026-03-01T10:00:01.000Z',
journalStamp: { events: { bytes: 20, mtimeMs: 2, tailSha256: 'freshness' } },
eventCount: 1,
noticeCount: 0,
integrity: 'ok',
bundleSchemaVersion: 2,
bundleKind: 'summary',
}),
'utf8'
);
await writeFile(
path.join(eventsDir, `${encodeURIComponent(TASK_ID)}.jsonl`),
`${JSON.stringify({
schemaVersion: 1,
eventId: 'event-1',
taskId: TASK_ID,
taskRef: TASK_ID,
taskRefKind: 'canonical',
phase: 'work',
executionSeq: 1,
sessionId: 'session-1',
toolUseId: 'journal-tool',
source: 'file_edit',
operation: 'modify',
confidence: 'exact',
workspaceRoot: '/repo',
filePath: '/repo/journal.ts',
relativePath: 'journal.ts',
timestamp: '2026-03-01T10:00:02.000Z',
toolStatus: 'succeeded',
before: null,
after: null,
oldString: 'const a = 1;\n',
newString: 'const a = 2;\n',
linesAdded: 1,
linesRemoved: 1,
})}\n`,
'utf8'
);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: 'team',
taskId: TASK_ID,
projectDir: tmpDir,
projectPath: '/repo',
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.files[0]?.filePath).toBe('/repo/journal.ts');
expect(result?.warnings).toContain('Task change summary fell back to journal reconstruction.');
});
});
async function makeLedgerBundle(params: {

View file

@ -2656,6 +2656,82 @@ describe('TeamDataService', () => {
expect(getMessages).not.toHaveBeenCalled();
});
it('propagates persisted needs_attention presence through lightweight presence reads', async () => {
const task: TeamTask = {
id: 'task-1',
subject: 'Review API',
status: 'completed',
owner: 'alice',
workIntervals: [{ startedAt: '2026-03-01T10:05:00.000Z' }],
historyEvents: [],
};
const descriptor = buildTaskChangePresenceDescriptor({
owner: task.owner,
status: task.status,
intervals: task.workIntervals,
historyEvents: task.historyEvents,
reviewState: 'none',
});
const service = new TeamDataService(
{
listTeams: vi.fn(),
getConfig: vi.fn(async () => ({ name: 'My team', members: [], projectPath: '/repo' })),
} as never,
{
getTasks: vi.fn(async () => [task]),
} as never,
{
listInboxNames: vi.fn(async () => []),
getMessages: vi.fn(async () => []),
} as never,
{} as never,
{} as never,
{
resolveMembers: vi.fn(() => []),
} as never,
{
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
} as never
);
service.setTaskChangePresenceServices(
{
load: vi.fn(async () => ({
version: 2,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: descriptor.taskSignature,
presence: 'needs_attention',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
})),
upsertEntry: vi.fn(async () => undefined),
} as never,
{
getSnapshot: vi.fn(() => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})),
ensureTracking: vi.fn(async () => ({
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
})),
} as never
);
const data = await service.getTaskChangePresence('my-team');
expect(data).toEqual({ 'task-1': 'needs_attention' });
});
it('persists standalone slash metadata when sending directly to the live lead', async () => {
const appendSentMessage = vi.fn((payload) => payload);
const service = new TeamDataService(

View file

@ -1,13 +1,21 @@
import { createHash } from 'crypto';
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { TeamLogSourceTracker } from '../../../../src/main/services/team/TeamLogSourceTracker';
import {
shouldIgnoreLogSourceWatcherPath,
TeamLogSourceTracker,
} from '../../../../src/main/services/team/TeamLogSourceTracker';
import type { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder';
import type { TeamChangeEvent } from '../../../../src/shared/types';
function safeTaskIdSegment(taskId: string): string {
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
}
describe('TeamLogSourceTracker', () => {
let tempDir: string | null = null;
@ -150,4 +158,68 @@ describe('TeamLogSourceTracker', () => {
await tracker.disableTracking('demo', 'stall_monitor');
});
it('emits the task id from Windows-safe hashed task-change freshness files', async () => {
tempDir = await mkdtemp(path.join(tmpdir(), 'team-log-source-tracker-safe-task-'));
const logsFinder = {
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: tempDir!,
sessionIds: [],
})),
} as unknown as TeamMemberLogsFinder;
const tracker = new TeamLogSourceTracker(logsFinder);
const emitter = vi.fn<(event: TeamChangeEvent) => void>();
tracker.setEmitter(emitter);
await tracker.enableTracking('demo', 'change_presence');
emitter.mockClear();
await new Promise((resolve) => setTimeout(resolve, 100));
const taskId = 'CON';
const signalDir = path.join(tempDir, '.board-task-change-freshness');
await mkdir(signalDir, { recursive: true });
await writeFile(
path.join(signalDir, `${safeTaskIdSegment(taskId)}.json`),
JSON.stringify({ taskId, updatedAt: '2026-04-19T12:00:00.000Z' }),
'utf8'
);
await vi.waitFor(() => {
expect(emitter).toHaveBeenCalledWith({
type: 'task-log-change',
teamName: 'demo',
taskId,
});
});
expect(emitter.mock.calls).not.toContainEqual([
expect.objectContaining({ type: 'task-log-change', taskId: safeTaskIdSegment(taskId) }),
]);
await tracker.disableTracking('demo', 'change_presence');
});
it('ignores internal ledger artifact paths but keeps freshness signals visible', () => {
const projectDir = '/tmp/demo-project';
expect(
shouldIgnoreLogSourceWatcherPath(
projectDir,
path.join(projectDir, '.board-task-changes', 'events', 'task.jsonl')
)
).toBe(true);
expect(
shouldIgnoreLogSourceWatcherPath(
projectDir,
path.join(projectDir, '.board-task-changes', 'locks', 'task.lock', 'owner.json')
)
).toBe(true);
expect(
shouldIgnoreLogSourceWatcherPath(
projectDir,
path.join(projectDir, '.board-task-change-freshness', 'task.json')
)
).toBe(false);
});
});

View file

@ -1,3 +1,4 @@
import { createHash } from 'crypto';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
@ -7,6 +8,10 @@ import { TeamTaskLogFreshnessReader } from '../../../../../src/main/services/tea
const tempDirs: string[] = [];
function safeTaskIdSegment(taskId: string): string {
return `task-id-${createHash('sha256').update(taskId).digest('hex').slice(0, 32)}`;
}
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map(async (dirPath) => {
@ -54,4 +59,28 @@ describe('TeamTaskLogFreshnessReader', () => {
transcriptFileBasename: 'session-a.jsonl',
});
});
it('reads Windows-safe hashed freshness files for reserved task ids', async () => {
const projectDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-freshness-'));
tempDirs.push(projectDir);
const signalDir = path.join(projectDir, '.board-task-log-freshness');
await fs.mkdir(signalDir, { recursive: true });
await fs.writeFile(
path.join(signalDir, `${safeTaskIdSegment('CON')}.json`),
JSON.stringify({
taskId: 'CON',
updatedAt: '2026-04-19T12:00:00.000Z',
transcriptFile: 'session-con.jsonl',
}),
'utf8'
);
const signals = await new TeamTaskLogFreshnessReader().readSignals(projectDir, ['CON']);
expect(signals.get('CON')?.filePath).toBe(
path.join(signalDir, `${safeTaskIdSegment('CON')}.json`)
);
expect(signals.get('CON')?.transcriptFileBasename).toBe('session-con.jsonl');
});
});

View file

@ -0,0 +1,88 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
const DEFAULT_PROJECT_ROOT_TOKEN = '__PROJECT_ROOT__';
const FIXTURE_ROOT = path.join(process.cwd(), 'test', 'fixtures', 'team', 'task-change-ledger');
export type TaskChangeLedgerFixtureManifest = {
schemaVersion: number;
name: string;
taskId: string;
description: string;
projectRootToken?: string;
expected?: {
totalFiles?: number;
warnings?: string[];
relativePaths?: string[];
relationKinds?: Array<'rename' | 'copy'>;
};
};
export type MaterializedTaskChangeLedgerFixture = {
rootDir: string;
projectDir: string;
manifest: TaskChangeLedgerFixtureManifest;
cleanup: () => Promise<void>;
};
function replaceTokenInValue<T>(value: T, token: string, replacement: string): T {
if (typeof value === 'string') {
return value.split(token).join(replacement) as T;
}
if (Array.isArray(value)) {
return value.map((item) => replaceTokenInValue(item, token, replacement)) as T;
}
if (value && typeof value === 'object') {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
key,
replaceTokenInValue(item, token, replacement),
])
) as T;
}
return value;
}
async function rewriteProjectRootTokens(rootDir: string, token: string, projectDir: string): Promise<void> {
const jsonStringReplacement = JSON.stringify(projectDir).slice(1, -1);
const entries = await fs.readdir(rootDir, { withFileTypes: true });
for (const entry of entries) {
const entryPath = path.join(rootDir, entry.name);
if (entry.isDirectory()) {
await rewriteProjectRootTokens(entryPath, token, projectDir);
continue;
}
if (!['.json', '.jsonl'].includes(path.extname(entry.name))) {
continue;
}
const raw = await fs.readFile(entryPath, 'utf8');
await fs.writeFile(entryPath, raw.split(token).join(jsonStringReplacement), 'utf8');
}
}
export async function materializeTaskChangeLedgerFixture(
fixtureName: string
): Promise<MaterializedTaskChangeLedgerFixture> {
const sourceDir = path.join(FIXTURE_ROOT, fixtureName);
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), `task-change-ledger-${fixtureName}-`));
await fs.cp(sourceDir, rootDir, { recursive: true });
const manifestPath = path.join(rootDir, 'manifest.json');
const manifest = JSON.parse(
await fs.readFile(manifestPath, 'utf8')
) as TaskChangeLedgerFixtureManifest;
const projectDir = path.join(rootDir, 'project');
const token = manifest.projectRootToken ?? DEFAULT_PROJECT_ROOT_TOKEN;
await rewriteProjectRootTokens(rootDir, token, projectDir);
return {
rootDir,
projectDir,
manifest: replaceTokenInValue(manifest, token, projectDir),
cleanup: async () => {
await fs.rm(rootDir, { recursive: true, force: true });
},
};
}

View file

@ -0,0 +1,421 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ChangeExtractorService } from '@main/services/team/ChangeExtractorService';
import { FileContentResolver } from '@main/services/team/FileContentResolver';
import { ReviewApplierService } from '@main/services/team/ReviewApplierService';
import { TaskChangeLedgerReader } from '@main/services/team/TaskChangeLedgerReader';
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
import { materializeTaskChangeLedgerFixture } from './taskChangeLedgerFixtureUtils';
const TEAM_NAME = 'team-a';
const SUMMARY_OPTIONS = {
owner: 'alice',
status: 'completed',
stateBucket: 'completed' as const,
summaryOnly: true,
};
async function writeTaskFile(baseDir: string, taskId: string, projectPath: string): Promise<void> {
const taskPath = path.join(baseDir, 'tasks', TEAM_NAME, `${taskId}.json`);
await fs.mkdir(path.dirname(taskPath), { recursive: true });
await fs.writeFile(
taskPath,
JSON.stringify(
{
id: taskId,
owner: 'alice',
status: 'completed',
createdAt: '2026-03-01T09:55:00.000Z',
updatedAt: '2026-03-01T10:10:00.000Z',
projectPath,
workIntervals: [{ startedAt: '2026-03-01T10:00:00.000Z', completedAt: '2026-03-01T10:10:00.000Z' }],
historyEvents: [],
},
null,
2
),
'utf8'
);
}
function createLedgerBackedChangeExtractorService(params: {
projectDir: string;
taskChangePresenceRepository?: { upsertEntry: ReturnType<typeof vi.fn> };
teamLogSourceTracker?: {
ensureTracking: ReturnType<
typeof vi.fn<() => Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>>
>;
};
}) {
const findLogFileRefsForTask = vi.fn(async () => {
throw new Error('fallback log reconstruction should not run for ledger fixtures');
});
const computeTaskChanges = vi.fn(async () => {
throw new Error('worker path should not run for ledger fixtures');
});
const service = new ChangeExtractorService(
{
getLogSourceWatchContext: vi.fn(async () => ({
projectDir: params.projectDir,
projectPath: params.projectDir,
})),
findLogFileRefsForTask,
findMemberLogPaths: vi.fn(async () => []),
} as any,
{
parseBoundaries: vi.fn(async () => {
throw new Error('inline parser should not run for ledger fixtures');
}),
} as any,
{ getConfig: vi.fn(async () => ({ projectPath: params.projectDir })) } as any,
undefined,
{
isAvailable: vi.fn(() => true),
computeTaskChanges,
} as any
);
if (params.taskChangePresenceRepository && params.teamLogSourceTracker) {
service.setTaskChangePresenceServices(
params.taskChangePresenceRepository as any,
params.teamLogSourceTracker as any
);
}
return { service, findLogFileRefsForTask, computeTaskChanges };
}
describe('task change ledger golden fixtures', () => {
const cleanups: Array<() => Promise<void>> = [];
afterEach(async () => {
setClaudeBasePathOverride(null);
vi.restoreAllMocks();
while (cleanups.length > 0) {
await cleanups.pop()?.();
}
});
it('reads rename and copy fixtures as grouped ledger changes', async () => {
const renameFixture = await materializeTaskChangeLedgerFixture('rename');
const copyFixture = await materializeTaskChangeLedgerFixture('copy');
cleanups.push(renameFixture.cleanup, copyFixture.cleanup);
const reader = new TaskChangeLedgerReader();
const rename = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: renameFixture.manifest.taskId,
projectDir: renameFixture.projectDir,
projectPath: renameFixture.projectDir,
includeDetails: false,
});
const copy = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: copyFixture.manifest.taskId,
projectDir: copyFixture.projectDir,
projectPath: copyFixture.projectDir,
includeDetails: false,
});
expect(rename?.files).toHaveLength(1);
expect(rename?.files[0]?.changeKey).toBe('rename:src/old.ts->src/new.ts');
expect(rename?.files[0]?.filePath).toBe(path.join(renameFixture.projectDir, 'src', 'new.ts'));
expect(rename?.files[0]?.ledgerSummary?.relation).toEqual({
kind: 'rename',
oldPath: 'src/old.ts',
newPath: 'src/new.ts',
});
expect(copy?.files).toHaveLength(1);
expect(copy?.files[0]?.changeKey).toBe('copy:src/base.ts->src/copy.ts');
expect(copy?.files[0]?.isNewFile).toBe(true);
expect(copy?.files[0]?.filePath).toBe(path.join(copyFixture.projectDir, 'src', 'copy.ts'));
expect(copy?.files[0]?.ledgerSummary?.relation).toEqual({
kind: 'copy',
oldPath: 'src/base.ts',
newPath: 'src/copy.ts',
});
});
it('returns warning-only notice fixtures without synthesizing fake file changes', async () => {
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: true,
});
expect(result).not.toBeNull();
expect(result?.files).toEqual([]);
expect(result?.warnings).toContain(
'Task change ledger skipped attribution because multiple task scopes were active.'
);
});
it('falls back when bundle freshness is intentionally mismatched', async () => {
const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.warnings).toContain(
'Task change summary fell back to journal reconstruction.'
);
});
it('uses journal tail hash, not only size and mtime, when freshness is missing', async () => {
const fixture = await materializeTaskChangeLedgerFixture('v2-summary');
cleanups.push(fixture.cleanup);
const taskId = fixture.manifest.taskId;
const eventPath = path.join(
fixture.projectDir,
'.board-task-changes',
'events',
`${encodeURIComponent(taskId)}.jsonl`
);
const freshnessSignalPath = path.join(
fixture.projectDir,
'.board-task-change-freshness',
`${encodeURIComponent(taskId)}.json`
);
const originalStat = await fs.stat(eventPath);
const raw = await fs.readFile(eventPath, 'utf8');
const mutated = raw.replace(
/"eventId":"([0-9a-f])([0-9a-f]+)"/,
(_match, first: string, rest: string) =>
`"eventId":"${first === 'a' ? 'b' : 'a'}${rest}"`
);
expect(mutated).not.toBe(raw);
expect(mutated.length).toBe(raw.length);
await fs.writeFile(eventPath, mutated, 'utf8');
await fs.utimes(eventPath, originalStat.atime, originalStat.mtime);
await fs.unlink(freshnessSignalPath);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.warnings).toContain(
'Task change summary fell back to journal reconstruction.'
);
});
it('surfaces recovered-journal warnings from real recovered artifacts', async () => {
const fixture = await materializeTaskChangeLedgerFixture('recovered-journal');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const result = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: false,
});
expect(result?.files).toHaveLength(1);
expect(result?.warnings).toContain('Task change ledger recovered from malformed journal lines.');
});
it('keeps missing-blob fixture unavailable instead of synthesizing empty text', async () => {
const fixture = await materializeTaskChangeLedgerFixture('missing-blob');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const changeSet = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: true,
});
const file = changeSet?.files[0];
expect(file).toBeDefined();
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets);
expect(resolved.originalFullContent).toBeNull();
expect(resolved.modifiedFullContent).toBe('export const missing = 2;\n');
expect(resolved.contentSource).toBe('ledger-snapshot');
});
it('rejects grouped copy fixtures by deleting only the copied path', async () => {
const fixture = await materializeTaskChangeLedgerFixture('copy');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const changeSet = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: true,
});
const file = changeSet?.files[0];
expect(file).toBeDefined();
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets);
const service = new ReviewApplierService();
const result = await service.applyReviewDecisions(
{
teamName: TEAM_NAME,
decisions: [
{
filePath: file!.filePath,
fileDecision: 'rejected',
hunkDecisions: { 0: 'rejected' },
},
],
},
new Map([
[
file!.filePath,
{
...file!,
...resolved,
},
],
])
);
expect(result).toMatchObject({ applied: 1, conflicts: 0 });
await expect(fs.stat(path.join(fixture.projectDir, 'src', 'copy.ts'))).rejects.toMatchObject({
code: 'ENOENT',
});
await expect(fs.readFile(path.join(fixture.projectDir, 'src', 'base.ts'), 'utf8')).resolves.toBe(
'export const copied = true;\n'
);
});
it('requires manual review when a fixture is missing original ledger text', async () => {
const fixture = await materializeTaskChangeLedgerFixture('missing-blob');
cleanups.push(fixture.cleanup);
const reader = new TaskChangeLedgerReader();
const changeSet = await reader.readTaskChanges({
teamName: TEAM_NAME,
taskId: fixture.manifest.taskId,
projectDir: fixture.projectDir,
projectPath: fixture.projectDir,
includeDetails: true,
});
const file = changeSet?.files[0];
expect(file).toBeDefined();
const resolver = new FileContentResolver({ findMemberLogPaths: vi.fn(async () => []) } as any);
const resolved = await resolver.getFileContent(TEAM_NAME, 'alice', file!.filePath, file!.snippets);
const service = new ReviewApplierService();
const result = await service.applyReviewDecisions(
{
teamName: TEAM_NAME,
decisions: [
{
filePath: file!.filePath,
fileDecision: 'rejected',
hunkDecisions: { 0: 'rejected' },
},
],
},
new Map([
[
file!.filePath,
{
...file!,
...resolved,
},
],
])
);
expect(result.applied).toBe(0);
expect(result.errors[0]?.code).toBe('manual-review-required');
});
it('uses ledger fixtures as the primary source in ChangeExtractorService', async () => {
const fixture = await materializeTaskChangeLedgerFixture('generation-mismatch');
cleanups.push(fixture.cleanup);
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-ledger-'));
cleanups.push(async () => {
await fs.rm(claudeBaseDir, { recursive: true, force: true });
});
setClaudeBasePathOverride(claudeBaseDir);
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
const { service, findLogFileRefsForTask, computeTaskChanges } =
createLedgerBackedChangeExtractorService({
projectDir: fixture.projectDir,
});
const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS);
expect(result.files).toHaveLength(1);
expect(result.warnings).toContain(
'Task change summary fell back to journal reconstruction.'
);
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
expect(computeTaskChanges).not.toHaveBeenCalled();
});
it('records needs_attention presence from warning-only ledger fixtures', async () => {
const fixture = await materializeTaskChangeLedgerFixture('notices-only');
cleanups.push(fixture.cleanup);
const claudeBaseDir = await fs.mkdtemp(path.join(os.tmpdir(), 'change-extractor-presence-'));
cleanups.push(async () => {
await fs.rm(claudeBaseDir, { recursive: true, force: true });
});
setClaudeBasePathOverride(claudeBaseDir);
await writeTaskFile(claudeBaseDir, fixture.manifest.taskId, fixture.projectDir);
const upsertEntry = vi.fn(async () => undefined);
const ensureTracking = vi.fn(async () => ({
projectFingerprint: 'fixture-project-fingerprint',
logSourceGeneration: 'fixture-log-generation',
}));
const { service, findLogFileRefsForTask } = createLedgerBackedChangeExtractorService({
projectDir: fixture.projectDir,
taskChangePresenceRepository: { upsertEntry },
teamLogSourceTracker: { ensureTracking },
});
const result = await service.getTaskChanges(TEAM_NAME, fixture.manifest.taskId, SUMMARY_OPTIONS);
expect(result.files).toEqual([]);
expect(result.warnings).toContain(
'Task change ledger skipped attribution because multiple task scopes were active.'
);
expect(findLogFileRefsForTask).not.toHaveBeenCalled();
expect(upsertEntry).toHaveBeenCalledWith(
TEAM_NAME,
expect.objectContaining({
projectFingerprint: 'fixture-project-fingerprint',
logSourceGeneration: 'fixture-log-generation',
}),
expect.objectContaining({
taskId: fixture.manifest.taskId,
presence: 'needs_attention',
})
);
});
});

View file

@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import {
normalizePersistedTaskChangePresenceIndex,
toPersistedTaskChangePresenceIndex,
} from '../../../../src/main/services/team/cache/taskChangePresenceCacheSchema';
import {
LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
} from '../../../../src/main/services/team/cache/taskChangePresenceCacheTypes';
describe('taskChangePresenceCacheSchema', () => {
it('dual-reads legacy v1 payloads and normalizes them to the current schema version', () => {
const normalized = normalizePersistedTaskChangePresenceIndex({
version: LEGACY_TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: 'sig-1',
presence: 'has_changes',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
});
expect(normalized).toEqual({
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: 'sig-1',
presence: 'has_changes',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
});
});
it('preserves needs_attention when normalizing the current schema payload', () => {
const normalized = normalizePersistedTaskChangePresenceIndex({
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: 'sig-1',
presence: 'needs_attention',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
});
expect(normalized?.entries['task-1']?.presence).toBe('needs_attention');
});
it('serializes all new writes as schema version 2', () => {
const serialized = toPersistedTaskChangePresenceIndex({
version: TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION,
teamName: 'my-team',
projectFingerprint: 'project-fingerprint',
logSourceGeneration: 'log-generation',
writtenAt: '2026-03-01T12:00:00.000Z',
entries: {
'task-1': {
taskId: 'task-1',
taskSignature: 'sig-1',
presence: 'needs_attention',
writtenAt: '2026-03-01T12:00:00.000Z',
logSourceGeneration: 'log-generation',
},
},
});
expect(serialized.version).toBe(TASK_CHANGE_PRESENCE_CACHE_SCHEMA_VERSION);
expect(serialized.entries['task-1']?.presence).toBe('needs_attention');
});
});

View file

@ -11,6 +11,9 @@ const hoisted = vi.hoisted(() => ({
getFileContent: vi.fn(),
applyDecisions: vi.fn(),
saveEditedFile: vi.fn(),
loadDecisions: vi.fn(),
saveDecisions: vi.fn(),
clearDecisions: vi.fn(),
checkConflict: vi.fn(),
rejectHunks: vi.fn(),
rejectFile: vi.fn(),
@ -26,6 +29,9 @@ vi.mock('@renderer/api', () => ({
getFileContent: hoisted.getFileContent,
applyDecisions: hoisted.applyDecisions,
saveEditedFile: hoisted.saveEditedFile,
loadDecisions: hoisted.loadDecisions,
saveDecisions: hoisted.saveDecisions,
clearDecisions: hoisted.clearDecisions,
checkConflict: hoisted.checkConflict,
rejectHunks: hoisted.rejectHunks,
rejectFile: hoisted.rejectFile,
@ -181,7 +187,7 @@ describe('changeReviewSlice task changes', () => {
totalLinesRemoved: 0,
teamName: 'team-a',
taskId: '1',
confidence: 'fallback',
confidence: 'high',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId: '1',
@ -192,7 +198,7 @@ describe('changeReviewSlice task changes', () => {
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'No log files found for task' },
confidence: { tier: 1, label: 'high', reason: 'Confirmed empty summary' },
},
warnings: [],
});
@ -202,6 +208,9 @@ describe('changeReviewSlice task changes', () => {
await store.getState().checkTaskHasChanges('team-a', '1', OPTIONS_B);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
expect(
store.getState().taskChangePresenceByKey[buildTaskChangePresenceKey('team-a', '1', OPTIONS_A)]
).toBe('no_changes');
});
it('updates selected team task changePresence after a positive summary check', async () => {
@ -285,6 +294,53 @@ describe('changeReviewSlice task changes', () => {
);
});
it('treats warning-only summaries as needs_attention and rechecks after invalidation', async () => {
const store = createSliceStore();
const teamName = 'team-a';
const taskId = 'presence-warning';
const cacheKey = buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A);
hoisted.getTaskChanges.mockResolvedValue({
files: [],
totalFiles: 0,
totalLinesAdded: 0,
totalLinesRemoved: 0,
teamName,
taskId,
confidence: 'fallback',
computedAt: '2026-03-01T12:00:00.000Z',
scope: {
taskId,
memberName: '',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 4, label: 'fallback', reason: 'Ambiguous scope skipped' },
},
warnings: ['Ledger skipped attribution because multiple task scopes were active.'],
provenance: {
sourceKind: 'ledger',
sourceFingerprint: 'ledger-warning-only',
},
});
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName,
taskId,
'needs_attention'
);
expect(store.getState().taskChangePresenceByKey[cacheKey]).toBe('needs_attention');
store.getState().invalidateTaskChangePresence([cacheKey]);
await store.getState().checkTaskHasChanges(teamName, taskId, OPTIONS_A);
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(2);
});
it('downgrades stale known presence to unknown for fallback empty summaries', async () => {
const store = createSliceStore();
store.setState({
@ -554,8 +610,10 @@ describe('changeReviewSlice task changes', () => {
expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(3);
expect(
store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]
).toBe(true);
store.getState().taskChangePresenceByKey[
buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)
]
).toBe('has_changes');
});
it('warms task summaries with bounded concurrency', async () => {
@ -708,7 +766,16 @@ describe('changeReviewSlice task changes', () => {
summaryOnly: true,
forceFresh: true,
});
expect(store.getState().taskHasChanges[buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)]).toBe(false);
expect(
store.getState().taskChangePresenceByKey[
buildTaskChangePresenceKey(teamName, taskId, OPTIONS_A)
]
).toBeUndefined();
expect(store.getState().setSelectedTeamTaskChangePresence).toHaveBeenCalledWith(
teamName,
taskId,
'unknown'
);
});
it('clears resolved file content state when fetchAgentChanges installs a new change set', async () => {
@ -741,7 +808,7 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentsLoading).toEqual({});
expect(store.getState().fileChunkCounts).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'rejected' });
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().changeSetEpoch).toBe(5);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
@ -777,7 +844,7 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentsLoading).toEqual({});
expect(store.getState().fileChunkCounts).toEqual({});
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().hunkDecisions).toEqual({ '/repo/file.ts:0': 'accepted' });
expect(store.getState().hunkDecisions).toEqual({});
expect(store.getState().changeSetEpoch).toBe(2);
expect(store.getState().fileContentVersionByPath).toEqual({});
});
@ -884,6 +951,93 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('normalizes persisted legacy file-path review decisions onto changeKey entries', async () => {
const store = createSliceStore();
const changeKey = 'rename:/repo/old.ts->/repo/new.ts';
const ledgerFile = {
...makeFile('/repo/new.ts'),
changeKey,
};
store.setState({
activeChangeSet: {
...makeTaskChangeSet('task-ledger', '/repo/new.ts'),
files: [ledgerFile],
totalFiles: 1,
totalLinesAdded: ledgerFile.linesAdded,
totalLinesRemoved: ledgerFile.linesRemoved,
},
});
hoisted.loadDecisions.mockResolvedValueOnce({
hunkDecisions: { '/repo/new.ts:0': 'rejected' },
fileDecisions: { '/repo/new.ts': 'rejected' },
hunkContextHashesByFile: { '/repo/new.ts': { 0: 'ctx-rename' } },
});
await store.getState().loadDecisionsFromDisk('team-a', 'task-task-ledger', 'scope-token');
expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' });
expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' });
expect(store.getState().hunkContextHashesByFile).toEqual({
[changeKey]: { 0: 'ctx-rename' },
});
});
it('stores fresh decisions under changeKey for grouped ledger files', () => {
const store = createSliceStore();
const changeKey = 'rename:/repo/old.ts->/repo/new.ts';
const ledgerFile = {
...makeFile('/repo/new.ts'),
changeKey,
};
store.setState({
activeChangeSet: {
...makeAgentChangeSet('/repo/new.ts'),
files: [ledgerFile],
totalFiles: 1,
totalLinesAdded: ledgerFile.linesAdded,
totalLinesRemoved: ledgerFile.linesRemoved,
},
fileChunkCounts: { '/repo/new.ts': 1 },
});
const originalIndex = store.getState().setHunkDecision('/repo/new.ts', 0, 'rejected');
store.getState().setFileDecision('/repo/new.ts', 'rejected');
expect(originalIndex).toBe(0);
expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' });
expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' });
});
it('stores grouped copy decisions under the copy changeKey', () => {
const store = createSliceStore();
const changeKey = 'copy:/repo/base.ts->/repo/copy.ts';
const ledgerFile = {
...makeFile('/repo/copy.ts'),
changeKey,
};
store.setState({
activeChangeSet: {
...makeAgentChangeSet('/repo/copy.ts'),
files: [ledgerFile],
totalFiles: 1,
totalLinesAdded: ledgerFile.linesAdded,
totalLinesRemoved: ledgerFile.linesRemoved,
},
fileChunkCounts: { '/repo/copy.ts': 1 },
});
const originalIndex = store.getState().setHunkDecision('/repo/copy.ts', 0, 'accepted');
store.getState().setFileDecision('/repo/copy.ts', 'accepted');
expect(originalIndex).toBe(0);
expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'accepted' });
expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'accepted' });
});
it('invalidates resolved file content without clearing draft or review decisions', async () => {
const store = createSliceStore();
@ -922,6 +1076,49 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('invalidates review-key hunk hashes for grouped ledger files without clearing decisions', () => {
const store = createSliceStore();
const changeKey = 'rename:/repo/old.ts->/repo/new.ts';
const ledgerFile = {
...makeFile('/repo/new.ts'),
changeKey,
};
store.setState({
activeChangeSet: {
...makeAgentChangeSet('/repo/new.ts'),
files: [ledgerFile],
totalFiles: 1,
totalLinesAdded: ledgerFile.linesAdded,
totalLinesRemoved: ledgerFile.linesRemoved,
},
hunkDecisions: { [`${changeKey}:0`]: 'rejected' },
fileDecisions: { [changeKey]: 'rejected' },
fileChunkCounts: { '/repo/new.ts': 2 },
hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } },
fileContents: {
'/repo/new.ts': {
...ledgerFile,
originalFullContent: 'before',
modifiedFullContent: 'after',
contentSource: 'ledger-exact',
},
},
fileContentsLoading: { '/repo/new.ts': true },
editedContents: { '/repo/new.ts': 'draft' },
reviewExternalChangesByFile: { '/repo/new.ts': { type: 'change' } },
fileContentVersionByPath: {},
});
store.getState().invalidateResolvedFileContent('/repo/new.ts');
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().hunkDecisions).toEqual({ [`${changeKey}:0`]: 'rejected' });
expect(store.getState().fileDecisions).toEqual({ [changeKey]: 'rejected' });
expect(store.getState().editedContents).toEqual({ '/repo/new.ts': 'draft' });
expect(store.getState().fileContentVersionByPath['/repo/new.ts']).toBe(1);
});
it('reloadReviewFileFromDisk clears the draft but preserves review decisions', async () => {
const store = createSliceStore();
@ -1074,6 +1271,45 @@ describe('changeReviewSlice task changes', () => {
expect(store.getState().fileContentVersionByPath['/repo/file.ts']).toBe(1);
});
it('clears review-key hunk hashes after saveEditedFile for grouped ledger files', async () => {
const store = createSliceStore();
const changeKey = 'rename:/repo/old.ts->/repo/new.ts';
const ledgerFile = {
...makeFile('/repo/new.ts'),
changeKey,
};
hoisted.saveEditedFile.mockResolvedValueOnce(undefined);
store.setState({
activeChangeSet: {
...makeAgentChangeSet('/repo/new.ts'),
files: [ledgerFile],
totalFiles: 1,
totalLinesAdded: ledgerFile.linesAdded,
totalLinesRemoved: ledgerFile.linesRemoved,
},
fileContents: {
'/repo/new.ts': {
...ledgerFile,
originalFullContent: 'before',
modifiedFullContent: 'draft-before-save',
contentSource: 'ledger-exact',
},
},
fileChunkCounts: { '/repo/new.ts': 2 },
hunkContextHashesByFile: { [changeKey]: { 0: 'ctx-rename' } },
editedContents: { '/repo/new.ts': 'saved-content' },
fileContentVersionByPath: {},
});
await store.getState().saveEditedFile('/repo/new.ts');
expect(hoisted.saveEditedFile).toHaveBeenCalledWith('/repo/new.ts', 'saved-content', undefined);
expect(store.getState().hunkContextHashesByFile).toEqual({});
expect(store.getState().fileChunkCounts).toEqual({});
expect(store.getState().fileContents['/repo/new.ts']?.modifiedFullContent).toBe('saved-content');
});
it('forces re-review when snippets change even if file paths stay the same', async () => {
const store = createSliceStore();
const current = makeAgentChangeSet('/repo/file.ts', { newString: 'after' });

View file

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';
import { buildReviewDecisionScopeToken } from '../../../src/renderer/utils/reviewDecisionScope';
describe('buildReviewDecisionScopeToken', () => {
it('includes task request signature so filtered task variants do not collide', () => {
const baseChangeSet = {
teamName: 'demo',
taskId: 'task-1',
files: [],
totalLinesAdded: 0,
totalLinesRemoved: 0,
totalFiles: 0,
confidence: 'high' as const,
computedAt: '2026-04-21T10:00:00.000Z',
scope: {
taskId: 'task-1',
memberName: 'alice',
startLine: 0,
endLine: 0,
startTimestamp: '',
endTimestamp: '',
toolUseIds: [],
filePaths: [],
confidence: { tier: 1 as const, label: 'high' as const, reason: 'ok' },
},
warnings: [],
provenance: {
sourceKind: 'ledger' as const,
sourceFingerprint: 'fp-1',
},
};
const tokenA = buildReviewDecisionScopeToken({
mode: 'task',
taskId: 'task-1',
requestSignature: '{"status":"in_progress"}',
changeSet: baseChangeSet,
});
const tokenB = buildReviewDecisionScopeToken({
mode: 'task',
taskId: 'task-1',
requestSignature: '{"status":"completed"}',
changeSet: baseChangeSet,
});
expect(tokenA).not.toBe(tokenB);
});
});

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest';
import {
getReviewKeyForFilePath,
normalizePersistedReviewState,
} from '../../../src/renderer/utils/reviewKey';
describe('reviewKey path normalization', () => {
it('maps slash variants of Windows file paths to the same review key', () => {
const files = [{ filePath: 'C:\\Repo\\src\\file.ts', changeKey: 'path:c:/repo/src/file.ts' }];
expect(getReviewKeyForFilePath(files, 'c:/repo/src/file.ts')).toBe('path:c:/repo/src/file.ts');
});
it('normalizes persisted legacy Windows path decisions onto changeKey entries', () => {
const files = [{ filePath: 'C:/Repo/src/file.ts', changeKey: 'path:c:/repo/src/file.ts' }];
const state = normalizePersistedReviewState(files, {
fileDecisions: { 'c:\\repo\\src\\file.ts': 'rejected' },
hunkDecisions: { 'c:\\repo\\src\\file.ts:2': 'accepted' },
hunkContextHashesByFile: { 'c:\\repo\\src\\file.ts': { 2: 'ctx' } },
});
expect(state.fileDecisions).toEqual({ 'path:c:/repo/src/file.ts': 'rejected' });
expect(state.hunkDecisions).toEqual({ 'path:c:/repo/src/file.ts:2': 'accepted' });
expect(state.hunkContextHashesByFile).toEqual({
'path:c:/repo/src/file.ts': { 2: 'ctx' },
});
});
});