agent-ecosystem/src/main/services/team/TaskChangeLedgerReader.ts

538 lines
17 KiB
TypeScript

import { createLogger } from '@shared/utils/logger';
import { diffLines } from 'diff';
import { readFile } from 'fs/promises';
import * as path from 'path';
import type {
FileChangeSummary,
FileEditEvent,
FileEditTimeline,
SnippetDiff,
TaskChangeScope,
TaskChangeSetV2,
} from '@shared/types';
const logger = createLogger('Service:TaskChangeLedgerReader');
const TASK_CHANGE_LEDGER_SCHEMA_VERSION = 1;
const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes';
type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous';
interface LedgerContentRef {
sha256: string;
sizeBytes: number;
blobRef?: string;
unavailableReason?: string;
}
interface LedgerContentState {
exists?: boolean;
sha256?: string;
sizeBytes?: number;
unavailableReason?: string;
}
interface LedgerChangeRelation {
kind: 'rename' | 'copy';
oldPath: string;
newPath: string;
}
interface LedgerEvent {
schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION;
eventId: string;
taskId: string;
taskRef: string;
taskRefKind: 'canonical' | 'display' | 'unknown';
phase: 'work' | 'review';
executionSeq: number;
sessionId: string;
agentId?: string;
toolUseId: string;
source:
| 'file_edit'
| 'file_write'
| 'notebook_edit'
| 'bash_simulated_sed'
| 'shell_snapshot'
| 'powershell_snapshot'
| 'post_tool_hook_snapshot';
operation: 'create' | 'modify' | 'delete';
confidence: LedgerConfidence;
workspaceRoot: string;
filePath: string;
relativePath: string;
timestamp: string;
toolStatus: 'succeeded' | 'failed' | 'killed' | 'backgrounded';
before: LedgerContentRef | null;
after: LedgerContentRef | null;
beforeState?: LedgerContentState;
afterState?: LedgerContentState;
relation?: LedgerChangeRelation;
oldString?: string;
newString?: string;
linesAdded?: number;
linesRemoved?: number;
replaceAll?: boolean;
warnings?: string[];
}
interface LedgerNotice {
schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION;
noticeId: string;
taskId: string;
taskRef: string;
taskRefKind: 'canonical' | 'display' | 'unknown';
phase: 'work' | 'review';
executionSeq: number;
sessionId: string;
agentId?: string;
toolUseId: string;
timestamp: string;
severity: 'warning';
message: string;
}
interface LedgerBundle {
schemaVersion: typeof TASK_CHANGE_LEDGER_SCHEMA_VERSION;
source: 'task-change-ledger';
taskId: string;
generatedAt: string;
eventCount: number;
files: {
filePath: string;
relativePath: string;
eventIds: string[];
linesAdded: number;
linesRemoved: number;
isNewFile: boolean;
latestAfterHash: string | null;
}[];
totalLinesAdded: number;
totalLinesRemoved: number;
totalFiles: number;
confidence: 'high' | 'medium' | 'low';
warnings: string[];
events: LedgerEvent[];
notices?: LedgerNotice[];
}
export class TaskChangeLedgerReader {
async readTaskChanges(params: {
teamName: string;
taskId: string;
projectDir: string;
projectPath?: string;
includeDetails: boolean;
}): Promise<TaskChangeSetV2 | null> {
const bundle = await this.readBundle(params.projectDir, params.taskId);
if (!bundle) {
return null;
}
const events = bundle.events
.filter((event) => event.taskId === params.taskId)
.sort((a, b) => {
const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp);
return timeDiff === 0 ? a.eventId.localeCompare(b.eventId) : timeDiff;
});
const notices = (bundle.notices ?? [])
.filter((notice) => notice.taskId === params.taskId)
.sort((a, b) => {
const timeDiff = Date.parse(a.timestamp) - Date.parse(b.timestamp);
return timeDiff === 0 ? a.noticeId.localeCompare(b.noticeId) : timeDiff;
});
if (events.length === 0 && notices.length === 0) {
return null;
}
const snippets = params.includeDetails
? await this.buildSnippets(params.projectDir, events)
: [];
const files = params.includeDetails
? this.aggregateByFile(snippets, params.projectPath, true)
: this.buildSummaryFiles(bundle, params.projectPath);
const scope = this.buildScope(params.taskId, events, files, notices);
const warnings = new Set(bundle.warnings ?? []);
for (const notice of notices) warnings.add(notice.message);
for (const event of events) {
for (const warning of event.warnings ?? []) warnings.add(warning);
if (event.toolStatus === 'failed') {
warnings.add(`Tool ${event.toolUseId} failed after changing files.`);
}
if (event.toolStatus === 'killed') {
warnings.add(`Background tool ${event.toolUseId} was killed after changing files.`);
}
}
return {
teamName: params.teamName,
taskId: params.taskId,
files,
totalLinesAdded: files.reduce((sum, file) => sum + file.linesAdded, 0),
totalLinesRemoved: files.reduce((sum, file) => sum + file.linesRemoved, 0),
totalFiles: files.length,
confidence: bundle.confidence,
computedAt: bundle.generatedAt,
scope,
warnings: [...warnings],
};
}
private async readBundle(projectDir: string, taskId: string): Promise<LedgerBundle | null> {
const bundlePath = path.join(
projectDir,
TASK_CHANGE_LEDGER_DIRNAME,
'bundles',
`${encodeURIComponent(taskId)}.json`
);
try {
const raw = await readFile(bundlePath, 'utf8');
const parsed = JSON.parse(raw) as LedgerBundle;
if (
parsed?.schemaVersion !== TASK_CHANGE_LEDGER_SCHEMA_VERSION ||
parsed.source !== 'task-change-ledger' ||
parsed.taskId !== taskId ||
!Array.isArray(parsed.events)
) {
return null;
}
return parsed;
} catch (error) {
logger.debug(`No task-change ledger bundle for ${taskId}: ${String(error)}`);
return null;
}
}
private async buildSnippets(projectDir: string, events: LedgerEvent[]): Promise<SnippetDiff[]> {
return Promise.all(
events.map(async (event) => {
const beforeContent = await this.readContentRef(projectDir, event.before);
const afterContent = await this.readContentRef(projectDir, event.after);
return this.eventToSnippet(event, beforeContent, afterContent);
})
);
}
private async readContentRef(
projectDir: string,
ref: LedgerContentRef | null
): Promise<string | null> {
if (!ref?.blobRef) {
return null;
}
try {
return await readFile(
path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef),
'utf8'
);
} catch {
return null;
}
}
private eventToSnippet(
event: LedgerEvent,
beforeContent: string | null,
afterContent: string | null
): SnippetDiff {
const toolName = this.mapToolName(event.source);
const type = this.mapSnippetType(event);
const source = event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot';
return {
toolUseId: event.toolUseId,
filePath: event.filePath,
toolName,
type,
oldString: event.oldString ?? beforeContent ?? '',
newString: event.newString ?? afterContent ?? '',
replaceAll: event.replaceAll ?? false,
timestamp: event.timestamp,
isError: false,
ledger: {
eventId: event.eventId,
source,
confidence: event.confidence,
originalFullContent: beforeContent,
modifiedFullContent: afterContent,
beforeHash: event.before?.sha256 ?? null,
afterHash: event.after?.sha256 ?? null,
operation: event.operation,
beforeState: event.beforeState,
afterState: event.afterState,
relation: event.relation,
executionSeq: event.executionSeq,
},
};
}
private mapToolName(eventSource: LedgerEvent['source']): SnippetDiff['toolName'] {
switch (eventSource) {
case 'file_edit':
return 'Edit';
case 'file_write':
return 'Write';
case 'notebook_edit':
return 'NotebookEdit';
case 'bash_simulated_sed':
case 'shell_snapshot':
return 'Bash';
case 'powershell_snapshot':
return 'PowerShell';
case 'post_tool_hook_snapshot':
return 'PostToolUse';
}
}
private mapSnippetType(event: LedgerEvent): SnippetDiff['type'] {
if (event.source === 'file_write') {
return event.operation === 'create' ? 'write-new' : 'write-update';
}
if (event.source === 'notebook_edit') {
return 'notebook-edit';
}
if (event.source === 'shell_snapshot' || event.source === 'powershell_snapshot') {
return 'shell-snapshot';
}
if (event.source === 'post_tool_hook_snapshot') {
return 'hook-snapshot';
}
return 'edit';
}
private aggregateByFile(
snippets: SnippetDiff[],
projectPath: string | undefined,
includeDetails: boolean
): FileChangeSummary[] {
const fileMap = new Map<
string,
{ filePath: string; snippets: SnippetDiff[]; isNewFile: boolean }
>();
for (const snippet of snippets) {
const key = this.fileGroupKey(snippet);
const existing = fileMap.get(key);
if (existing) {
existing.snippets.push(snippet);
existing.isNewFile ||=
snippet.type === 'write-new' || snippet.ledger?.operation === 'create';
} else {
fileMap.set(key, {
filePath: snippet.filePath,
snippets: [snippet],
isNewFile: snippet.type === 'write-new' || snippet.ledger?.operation === 'create',
});
}
}
return [...fileMap.values()].map((entry) => {
let linesAdded = 0;
let linesRemoved = 0;
for (const snippet of entry.snippets) {
const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString);
linesAdded += added;
linesRemoved += removed;
}
const displayFilePath = this.displayFilePathForGroup(entry);
const relation = this.relationForSnippets(entry.snippets);
return {
filePath: displayFilePath,
relativePath: this.relativePath(displayFilePath, projectPath),
snippets: includeDetails ? entry.snippets : [],
linesAdded,
linesRemoved,
isNewFile: relation?.kind === 'rename' ? false : entry.isNewFile,
timeline: includeDetails ? this.buildTimeline(displayFilePath, entry.snippets) : undefined,
};
});
}
private buildSummaryFiles(
bundle: LedgerBundle,
projectPath: string | undefined
): FileChangeSummary[] {
const eventById = new Map(bundle.events.map((event) => [event.eventId, event]));
const fileMap = new Map<
string,
{
filePath: string;
filePaths: string[];
linesAdded: number;
linesRemoved: number;
isNewFile: boolean;
relation?: LedgerChangeRelation;
}
>();
for (const file of bundle.files) {
const relation = file.eventIds
.map((eventId) => eventById.get(eventId)?.relation)
.find((value): value is LedgerChangeRelation => Boolean(value));
const key = relation
? `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}`
: this.normalizePathKey(file.filePath);
const displayFilePath = relation?.newPath ?? file.filePath;
const existing = fileMap.get(key);
if (existing) {
existing.filePaths.push(file.filePath);
existing.filePath = relation
? this.displayFilePathForRelation(relation, existing.filePaths)
: existing.filePath;
existing.linesAdded += file.linesAdded;
existing.linesRemoved += file.linesRemoved;
existing.isNewFile ||= file.isNewFile;
existing.relation ??= relation;
} else {
fileMap.set(key, {
filePath: relation
? this.displayFilePathForRelation(relation, [file.filePath])
: displayFilePath,
filePaths: [file.filePath],
linesAdded: file.linesAdded,
linesRemoved: file.linesRemoved,
isNewFile: file.isNewFile,
relation,
});
}
}
return [...fileMap.values()].map((file) => ({
filePath: file.filePath,
relativePath: this.relativePath(file.filePath, projectPath),
snippets: [],
linesAdded: file.linesAdded,
linesRemoved: file.linesRemoved,
isNewFile: file.relation?.kind === 'rename' ? false : file.isNewFile,
}));
}
private buildScope(
taskId: string,
events: LedgerEvent[],
files: FileChangeSummary[],
notices: LedgerNotice[] = []
): TaskChangeScope {
const first = events[0];
const last = events[events.length - 1];
const firstNotice = notices[0];
const lastNotice = notices[notices.length - 1];
const worstConfidence = events.some((event) => event.confidence !== 'exact') ? 2 : 1;
return {
taskId,
memberName: first?.agentId ?? firstNotice?.agentId ?? '',
startLine: 0,
endLine: 0,
startTimestamp: first?.timestamp ?? firstNotice?.timestamp ?? new Date().toISOString(),
endTimestamp:
last?.timestamp ??
first?.timestamp ??
lastNotice?.timestamp ??
firstNotice?.timestamp ??
new Date().toISOString(),
toolUseIds: [
...new Set([
...events.map((event) => event.toolUseId),
...notices.map((notice) => notice.toolUseId),
]),
],
filePaths: files.map((file) => file.filePath),
confidence: {
tier: worstConfidence,
label: worstConfidence === 1 ? 'high' : 'medium',
reason: 'Scoped by orchestrator task-change ledger',
},
};
}
private buildTimeline(filePath: string, snippets: SnippetDiff[]): FileEditTimeline {
const events: FileEditEvent[] = snippets.map((snippet, index) => {
const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString);
return {
toolUseId: snippet.toolUseId,
toolName: snippet.toolName,
timestamp: snippet.timestamp,
summary: this.summaryForSnippet(snippet, added, removed),
linesAdded: added,
linesRemoved: removed,
snippetIndex: index,
};
});
const firstMs = Date.parse(events[0]?.timestamp ?? '');
const lastMs = Date.parse(events[events.length - 1]?.timestamp ?? '');
return {
filePath,
events,
durationMs:
Number.isFinite(firstMs) && Number.isFinite(lastMs) ? Math.max(0, lastMs - firstMs) : 0,
};
}
private summaryForSnippet(snippet: SnippetDiff, added: number, removed: number): string {
if (snippet.type === 'write-new') return `Created file (${added} lines)`;
if (snippet.type === 'write-update') return `Rewrote file (+${added}/-${removed})`;
if (snippet.type === 'shell-snapshot') {
return `${snippet.toolName === 'PowerShell' ? 'PowerShell' : 'Shell'} changed file (+${added}/-${removed})`;
}
if (snippet.type === 'hook-snapshot') return `Hook changed file (+${added}/-${removed})`;
if (snippet.type === 'notebook-edit') return `Edited notebook (+${added}/-${removed})`;
return `Edited file (+${added}/-${removed})`;
}
private countLineChanges(before: string, after: string): { added: number; removed: number } {
let added = 0;
let removed = 0;
for (const change of diffLines(before, after)) {
if (change.added) added += change.count ?? 0;
if (change.removed) removed += change.count ?? 0;
}
return { added, removed };
}
private normalizePathKey(filePath: string): string {
return path.normalize(filePath).toLowerCase();
}
private fileGroupKey(snippet: SnippetDiff): string {
const relation = snippet.ledger?.relation;
if (relation) {
return `relation:${relation.kind}:${this.normalizePathKey(relation.oldPath)}:${this.normalizePathKey(relation.newPath)}`;
}
return this.normalizePathKey(snippet.filePath);
}
private displayFilePathForGroup(entry: { filePath: string; snippets: SnippetDiff[] }): string {
const relation = this.relationForSnippets(entry.snippets);
if (!relation) {
return entry.filePath;
}
return this.displayFilePathForRelation(
relation,
entry.snippets.map((snippet) => snippet.filePath)
);
}
private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
}
private displayFilePathForRelation(relation: LedgerChangeRelation, filePaths: string[]): string {
const expected = relation.newPath.replace(/\\/g, '/');
const match = filePaths.find((filePath) => {
const normalized = filePath.replace(/\\/g, '/');
return normalized === expected || normalized.endsWith(`/${expected}`);
});
return match ?? relation.newPath;
}
private relativePath(filePath: string, projectPath?: string): string {
const normalizedFilePath = filePath.replace(/\\/g, '/');
const normalizedProjectPath = projectPath?.replace(/\\/g, '/');
if (normalizedProjectPath && normalizedFilePath.startsWith(normalizedProjectPath + '/')) {
return normalizedFilePath.slice(normalizedProjectPath.length + 1);
}
return normalizedFilePath.split('/').slice(-3).join('/');
}
}