feat(task-change-ledger): merge review hardening
This commit is contained in:
commit
7b486b7fea
92 changed files with 3890 additions and 577 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' };
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ||
|
||||
|
|
|
|||
|
|
@ -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'>;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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" />}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 } : {};
|
||||
});
|
||||
},
|
||||
|
||||
|
|
|
|||
72
src/renderer/utils/reviewDecisionScope.ts
Normal file
72
src/renderer/utils/reviewDecisionScope.ts
Normal 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}`;
|
||||
}
|
||||
104
src/renderer/utils/reviewKey.ts
Normal file
104
src/renderer/utils/reviewKey.ts
Normal 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 };
|
||||
}
|
||||
19
src/renderer/utils/taskChangePresence.ts
Normal file
19
src/renderer/utils/taskChangePresence.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
15
src/shared/utils/taskChangePresence.ts
Normal file
15
src/shared/utils/taskChangePresence.ts
Normal 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;
|
||||
}
|
||||
17
test/fixtures/team/task-change-ledger/binary/manifest.json
vendored
Normal file
17
test/fixtures/team/task-change-ledger/binary/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
|
||||
|
|
@ -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."]}
|
||||
|
|
@ -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"}
|
||||
1
test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin
vendored
Normal file
1
test/fixtures/team/task-change-ledger/binary/project/fixtures/blob.bin
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
17
test/fixtures/team/task-change-ledger/copy/manifest.json
vendored
Normal file
17
test/fixtures/team/task-change-ledger/copy/manifest.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const copied = true;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
1
test/fixtures/team/task-change-ledger/copy/project/src/base.ts
vendored
Normal file
1
test/fixtures/team/task-change-ledger/copy/project/src/base.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const copied = true;
|
||||
1
test/fixtures/team/task-change-ledger/copy/project/src/copy.ts
vendored
Normal file
1
test/fixtures/team/task-change-ledger/copy/project/src/copy.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const copied = true;
|
||||
15
test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/generation-mismatch/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const mismatch = 1;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
15
test/fixtures/team/task-change-ledger/missing-blob/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/missing-blob/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const missing = 2;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
1
test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts
vendored
Normal file
1
test/fixtures/team/task-change-ledger/missing-blob/project/src/missing.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const missing = 2;
|
||||
15
test/fixtures/team/task-change-ledger/notices-only/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/notices-only/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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."]}
|
||||
|
|
@ -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."]}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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"}
|
||||
17
test/fixtures/team/task-change-ledger/recovered-journal/manifest.json
vendored
Normal file
17
test/fixtures/team/task-change-ledger/recovered-journal/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const ok = true;
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const recovered = true;
|
||||
|
|
@ -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."]}
|
||||
|
|
@ -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"}
|
||||
17
test/fixtures/team/task-change-ledger/rename/manifest.json
vendored
Normal file
17
test/fixtures/team/task-change-ledger/rename/manifest.json
vendored
Normal 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"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const renamed = true;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
1
test/fixtures/team/task-change-ledger/rename/project/src/new.ts
vendored
Normal file
1
test/fixtures/team/task-change-ledger/rename/project/src/new.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const renamed = true;
|
||||
15
test/fixtures/team/task-change-ledger/v2-summary/manifest.json
vendored
Normal file
15
test/fixtures/team/task-change-ledger/v2-summary/manifest.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export const summary = 1;
|
||||
|
|
@ -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":[]}
|
||||
|
|
@ -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"}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
135
test/main/services/team/ReviewDecisionStore.test.ts
Normal file
135
test/main/services/team/ReviewDecisionStore.test.ts
Normal 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 [];
|
||||
}
|
||||
}
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
88
test/main/services/team/taskChangeLedgerFixtureUtils.ts
Normal file
88
test/main/services/team/taskChangeLedgerFixtureUtils.ts
Normal 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 });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -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',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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' });
|
||||
|
|
|
|||
49
test/renderer/utils/reviewDecisionScope.test.ts
Normal file
49
test/renderer/utils/reviewDecisionScope.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
29
test/renderer/utils/reviewKey.test.ts
Normal file
29
test/renderer/utils/reviewKey.test.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue