1691 lines
56 KiB
TypeScript
1691 lines
56 KiB
TypeScript
import { createLogger } from '@shared/utils/logger';
|
|
import { isWindowsishPath, normalizePathForComparison } from '@shared/utils/platformPath';
|
|
import { createHash } from 'crypto';
|
|
import { diffLines } from 'diff';
|
|
import { open, readFile } from 'fs/promises';
|
|
import * as path from 'path';
|
|
|
|
import type {
|
|
FileChangeSummary,
|
|
FileEditEvent,
|
|
FileEditTimeline,
|
|
LedgerChangeRelation,
|
|
LedgerContentState,
|
|
SnippetDiff,
|
|
TaskChangeJournalStamp,
|
|
TaskChangeProvenance,
|
|
TaskChangeScope,
|
|
TaskChangeSetV2,
|
|
} from '@shared/types';
|
|
|
|
const logger = createLogger('Service:TaskChangeLedgerReader');
|
|
|
|
const TASK_CHANGE_JOURNAL_SCHEMA_VERSION = 1;
|
|
const TASK_CHANGE_SUMMARY_SCHEMA_VERSION = 2;
|
|
const TASK_CHANGE_FRESHNESS_SCHEMA_VERSION = 2;
|
|
const TASK_CHANGE_LEDGER_DIRNAME = '.board-task-changes';
|
|
const TASK_CHANGE_FRESHNESS_DIRNAME = '.board-task-change-freshness';
|
|
const MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH = 120;
|
|
|
|
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 {
|
|
const encoded = encodeURIComponent(taskId);
|
|
return isWindowsReservedArtifactSegment(encoded) ||
|
|
encoded.length > MAX_TASK_ID_ARTIFACT_SEGMENT_LENGTH
|
|
? `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 taskArtifactPathCandidates(
|
|
projectDir: string,
|
|
taskId: string,
|
|
dirName: string,
|
|
fileSuffix: string
|
|
): string[] {
|
|
return taskIdArtifactSegments(taskId).map((segment) =>
|
|
path.join(projectDir, dirName, `${segment}${fileSuffix}`)
|
|
);
|
|
}
|
|
|
|
function decodeLedgerTextBlob(buffer: Buffer): string | null {
|
|
for (const byte of buffer) {
|
|
if (byte === 0 || byte < 9 || (byte > 13 && byte < 32)) {
|
|
return null;
|
|
}
|
|
}
|
|
const text = buffer.toString('utf8');
|
|
return Buffer.from(text, 'utf8').equals(buffer) ? text : null;
|
|
}
|
|
|
|
type LedgerConfidence = 'exact' | 'high' | 'medium' | 'low' | 'ambiguous';
|
|
|
|
interface LedgerContentRef {
|
|
sha256: string;
|
|
sizeBytes: number;
|
|
blobRef?: string;
|
|
unavailableReason?: string;
|
|
}
|
|
|
|
interface LedgerEvent {
|
|
schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION;
|
|
eventId: string;
|
|
taskId: string;
|
|
taskRef: string;
|
|
taskRefKind: 'canonical' | 'display' | 'unknown';
|
|
phase: 'work' | 'review';
|
|
executionSeq: number;
|
|
sessionId: string;
|
|
agentId?: string;
|
|
memberName?: string;
|
|
toolUseId: string;
|
|
source:
|
|
| 'file_edit'
|
|
| 'file_write'
|
|
| 'notebook_edit'
|
|
| 'bash_simulated_sed'
|
|
| 'shell_snapshot'
|
|
| 'powershell_snapshot'
|
|
| 'post_tool_hook_snapshot'
|
|
| 'opencode_toolpart_write'
|
|
| 'opencode_toolpart_edit'
|
|
| 'opencode_toolpart_apply_patch';
|
|
operation: 'create' | 'modify' | 'delete';
|
|
confidence: LedgerConfidence;
|
|
workspaceRoot: string;
|
|
worktreePath?: string;
|
|
worktreeBranch?: string;
|
|
baseWorkspaceRoot?: string;
|
|
dirtyLeaderWarning?: 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[];
|
|
sourceRuntime?: 'opencode';
|
|
sourceProvider?: 'opencode';
|
|
sourceImportKey?: string;
|
|
evidenceProof?: string;
|
|
supersedesEventId?: string;
|
|
snapshotId?: string;
|
|
snapshotSource?: string;
|
|
}
|
|
|
|
interface LedgerNotice {
|
|
schemaVersion: typeof TASK_CHANGE_JOURNAL_SCHEMA_VERSION;
|
|
noticeId: string;
|
|
taskId: string;
|
|
taskRef: string;
|
|
taskRefKind: 'canonical' | 'display' | 'unknown';
|
|
phase: 'work' | 'review';
|
|
executionSeq: number;
|
|
sessionId: string;
|
|
agentId?: string;
|
|
memberName?: string;
|
|
toolUseId: string;
|
|
timestamp: string;
|
|
severity: 'warning';
|
|
message: string;
|
|
code?: 'multi-scope-skipped' | 'journal-recovered' | 'writer-lock-stolen';
|
|
}
|
|
|
|
interface LedgerBundleFileV1 {
|
|
filePath: string;
|
|
relativePath: string;
|
|
eventIds: string[];
|
|
linesAdded: number;
|
|
linesRemoved: number;
|
|
isNewFile: boolean;
|
|
latestAfterHash: string | null;
|
|
}
|
|
|
|
interface LedgerBundleV1 {
|
|
schemaVersion: 1;
|
|
source: 'task-change-ledger';
|
|
taskId: string;
|
|
generatedAt: string;
|
|
eventCount: number;
|
|
files: LedgerBundleFileV1[];
|
|
totalLinesAdded: number;
|
|
totalLinesRemoved: number;
|
|
totalFiles: number;
|
|
confidence: 'high' | 'medium' | 'low';
|
|
warnings: string[];
|
|
events: LedgerEvent[];
|
|
notices?: LedgerNotice[];
|
|
}
|
|
|
|
interface LedgerSummaryContributorV2 {
|
|
actorKey: string;
|
|
agentId?: string;
|
|
memberName?: string;
|
|
eventCount: number;
|
|
noticeCount: number;
|
|
touchedFileCount: number;
|
|
visibleFileCount: number;
|
|
toolUseCount: number;
|
|
cumulativeLinesAdded: number;
|
|
cumulativeLinesRemoved: number;
|
|
firstTimestamp: string;
|
|
lastTimestamp: string;
|
|
}
|
|
|
|
interface LedgerSummaryScopeV2 {
|
|
confidence: TaskChangeScope['confidence'];
|
|
primaryActorKey?: string;
|
|
primaryAgentId?: string;
|
|
primaryMemberName?: string;
|
|
memberName: string;
|
|
agentIds?: string[];
|
|
memberNames?: string[];
|
|
startTimestamp: string;
|
|
endTimestamp: string;
|
|
toolUseIds: string[];
|
|
toolUseCount: number;
|
|
toolUseIdsTruncated?: boolean;
|
|
phaseSet: ('work' | 'review')[];
|
|
executionSeqRange?: { start: number; end: number };
|
|
confidenceBreakdown?: TaskChangeScope['confidenceBreakdown'];
|
|
visibleFileCount: number;
|
|
contributors: LedgerSummaryContributorV2[];
|
|
worktreePaths?: string[];
|
|
worktreeBranches?: string[];
|
|
baseWorkspaceRoots?: string[];
|
|
dirtyLeaderWarnings?: string[];
|
|
}
|
|
|
|
interface LedgerSummaryFileV2 {
|
|
changeKey: string;
|
|
filePath: string;
|
|
relativePath: string;
|
|
displayPath?: string;
|
|
linesAdded: number;
|
|
linesRemoved: number;
|
|
diffStatKnown: boolean;
|
|
eventCount: number;
|
|
firstTimestamp: string;
|
|
lastTimestamp: string;
|
|
latestOperation: 'create' | 'modify' | 'delete';
|
|
createdInTask: boolean;
|
|
deletedInTask: boolean;
|
|
baselineExists?: boolean;
|
|
finalExists?: boolean;
|
|
latestBeforeHash: string | null;
|
|
latestAfterHash: string | null;
|
|
latestBeforeState?: LedgerContentState;
|
|
latestAfterState?: LedgerContentState;
|
|
contentAvailability: 'full-text' | 'hash-only' | 'metadata-only';
|
|
reviewability: 'full-text' | 'partial-text' | 'metadata-only';
|
|
relation?: LedgerChangeRelation;
|
|
worktreePath?: string;
|
|
worktreeBranch?: string;
|
|
baseWorkspaceRoot?: string;
|
|
dirtyLeaderWarning?: string;
|
|
primaryActorKey?: string;
|
|
agentIds: string[];
|
|
memberNames?: string[];
|
|
executionSeqRange?: { start: number; end: number };
|
|
warnings?: string[];
|
|
}
|
|
|
|
interface LedgerSummaryBundleV2 {
|
|
schemaVersion: typeof TASK_CHANGE_SUMMARY_SCHEMA_VERSION;
|
|
source: 'task-change-ledger';
|
|
bundleKind: 'summary';
|
|
taskId: string;
|
|
generatedAt: string;
|
|
journalStamp: TaskChangeJournalStamp;
|
|
integrity: 'ok' | 'recovered' | 'partial';
|
|
eventCount: number;
|
|
noticeCount: number;
|
|
scope: LedgerSummaryScopeV2;
|
|
files: LedgerSummaryFileV2[];
|
|
totalLinesAdded: number;
|
|
totalLinesRemoved: number;
|
|
diffStatCompleteness: 'complete' | 'partial';
|
|
totalFiles: number;
|
|
confidence: 'high' | 'medium' | 'low';
|
|
warningCount: number;
|
|
warnings: string[];
|
|
}
|
|
|
|
interface LedgerFreshnessV2 {
|
|
schemaVersion: typeof TASK_CHANGE_FRESHNESS_SCHEMA_VERSION;
|
|
source: 'task-change-ledger';
|
|
taskId: string;
|
|
updatedAt: string;
|
|
journalStamp: TaskChangeJournalStamp;
|
|
eventCount: number;
|
|
noticeCount: number;
|
|
integrity: 'ok' | 'recovered' | 'partial';
|
|
bundleSchemaVersion: 2;
|
|
bundleKind: 'summary';
|
|
}
|
|
|
|
interface JournalReadResult<T> {
|
|
entries: T[];
|
|
recovered: boolean;
|
|
}
|
|
|
|
interface JournalData {
|
|
events: LedgerEvent[];
|
|
notices: LedgerNotice[];
|
|
recovered: boolean;
|
|
}
|
|
|
|
interface SummaryBundleRead {
|
|
bundle: LedgerSummaryBundleV2;
|
|
provenance: TaskChangeProvenance;
|
|
mode: 'validated' | 'degraded';
|
|
degradedWarning?: string;
|
|
}
|
|
|
|
export class TaskChangeLedgerReader {
|
|
async readTaskChanges(params: {
|
|
teamName: string;
|
|
taskId: string;
|
|
projectDir: string;
|
|
projectPath?: string;
|
|
includeDetails: boolean;
|
|
}): Promise<TaskChangeSetV2 | null> {
|
|
const bundleRead = await this.tryReadSummaryBundleV2(
|
|
params.projectDir,
|
|
params.taskId,
|
|
params.projectPath
|
|
);
|
|
|
|
if (params.includeDetails) {
|
|
const journal = await this.readJournalData(params.projectDir, params.taskId);
|
|
if (journal) {
|
|
return this.buildDetailedResult({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectDir: params.projectDir,
|
|
projectPath: params.projectPath,
|
|
journal,
|
|
bundle: bundleRead?.bundle,
|
|
provenance:
|
|
bundleRead?.provenance ??
|
|
this.buildLedgerProvenanceFromJournal(
|
|
(await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {},
|
|
undefined,
|
|
journal.recovered ? 'recovered' : 'ok'
|
|
),
|
|
});
|
|
}
|
|
|
|
const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId);
|
|
if (legacy) {
|
|
return this.buildLegacyResult({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectDir: params.projectDir,
|
|
projectPath: params.projectPath,
|
|
bundle: legacy,
|
|
includeDetails: true,
|
|
});
|
|
}
|
|
|
|
if (bundleRead) {
|
|
const result = this.buildSummaryResultFromBundle({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectPath: params.projectPath,
|
|
bundle: bundleRead.bundle,
|
|
provenance: bundleRead.provenance,
|
|
extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined,
|
|
});
|
|
return {
|
|
...result,
|
|
warnings: [
|
|
...result.warnings,
|
|
'Ledger journal was unavailable; detailed snippets could not be loaded.',
|
|
],
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
if (bundleRead?.mode === 'validated') {
|
|
return this.buildSummaryResultFromBundle({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectPath: params.projectPath,
|
|
bundle: bundleRead.bundle,
|
|
provenance: bundleRead.provenance,
|
|
});
|
|
}
|
|
|
|
const journal = await this.readJournalData(params.projectDir, params.taskId);
|
|
if (journal) {
|
|
return this.buildJournalFallbackSummary({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectDir: params.projectDir,
|
|
projectPath: params.projectPath,
|
|
journal,
|
|
});
|
|
}
|
|
|
|
if (bundleRead) {
|
|
return this.buildSummaryResultFromBundle({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectPath: params.projectPath,
|
|
bundle: bundleRead.bundle,
|
|
provenance: bundleRead.provenance,
|
|
extraWarnings: bundleRead.degradedWarning ? [bundleRead.degradedWarning] : undefined,
|
|
});
|
|
}
|
|
|
|
const legacy = await this.readLegacyBundleV1(params.projectDir, params.taskId);
|
|
if (legacy) {
|
|
return this.buildLegacyResult({
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
projectDir: params.projectDir,
|
|
projectPath: params.projectPath,
|
|
bundle: legacy,
|
|
includeDetails: false,
|
|
});
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private async tryReadSummaryBundleV2(
|
|
projectDir: string,
|
|
taskId: string,
|
|
_projectPath?: string
|
|
): Promise<SummaryBundleRead | null> {
|
|
const [bundle, freshness, journalStamp] = await Promise.all([
|
|
this.readSummaryBundleV2(projectDir, taskId),
|
|
this.readFreshnessV2(projectDir, taskId),
|
|
this.readJournalStampFromDisk(projectDir, taskId),
|
|
]);
|
|
if (!bundle) {
|
|
return null;
|
|
}
|
|
|
|
const provenance = this.buildLedgerProvenanceFromSummaryBundle(bundle);
|
|
|
|
if (
|
|
freshness &&
|
|
this.bundleMatchesFreshness(bundle, freshness) &&
|
|
freshness.integrity !== 'partial'
|
|
) {
|
|
return { bundle, provenance, mode: 'validated' };
|
|
}
|
|
|
|
if (
|
|
!freshness &&
|
|
journalStamp &&
|
|
JSON.stringify(journalStamp) === JSON.stringify(bundle.journalStamp) &&
|
|
bundle.integrity !== 'partial'
|
|
) {
|
|
return {
|
|
bundle,
|
|
provenance: this.buildLedgerProvenanceFromSummaryBundle(bundle, journalStamp),
|
|
mode: 'validated',
|
|
};
|
|
}
|
|
|
|
if (!freshness && !journalStamp) {
|
|
return {
|
|
bundle,
|
|
provenance,
|
|
mode: 'degraded',
|
|
degradedWarning:
|
|
'Task change summary used bundle v2 without live validation because freshness and journal files were unavailable.',
|
|
};
|
|
}
|
|
|
|
return {
|
|
bundle,
|
|
provenance,
|
|
mode: 'degraded',
|
|
degradedWarning:
|
|
'Task change summary bypassed bundle v2 fast-path because bundle freshness did not match the current ledger generation.',
|
|
};
|
|
}
|
|
|
|
private async readSummaryBundleV2(
|
|
projectDir: string,
|
|
taskId: string
|
|
): Promise<LedgerSummaryBundleV2 | null> {
|
|
const bundlePaths = taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'),
|
|
'.json'
|
|
);
|
|
for (const bundlePath of bundlePaths) {
|
|
try {
|
|
const raw = await readFile(bundlePath, 'utf8');
|
|
const parsed = JSON.parse(raw) as Partial<LedgerSummaryBundleV2>;
|
|
if (
|
|
parsed?.schemaVersion !== TASK_CHANGE_SUMMARY_SCHEMA_VERSION ||
|
|
parsed.source !== 'task-change-ledger' ||
|
|
parsed.bundleKind !== 'summary' ||
|
|
parsed.taskId !== taskId ||
|
|
!Array.isArray(parsed.files)
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed as LedgerSummaryBundleV2;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
logger.debug(`No v2 task-change bundle for ${taskId}.`);
|
|
return null;
|
|
}
|
|
|
|
private async readFreshnessV2(
|
|
projectDir: string,
|
|
taskId: string
|
|
): Promise<LedgerFreshnessV2 | null> {
|
|
const freshnessPaths = taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
TASK_CHANGE_FRESHNESS_DIRNAME,
|
|
'.json'
|
|
);
|
|
for (const freshnessPath of freshnessPaths) {
|
|
try {
|
|
const raw = await readFile(freshnessPath, 'utf8');
|
|
const parsed = JSON.parse(raw) as Partial<LedgerFreshnessV2>;
|
|
if (
|
|
parsed?.schemaVersion !== TASK_CHANGE_FRESHNESS_SCHEMA_VERSION ||
|
|
parsed.source !== 'task-change-ledger' ||
|
|
parsed.taskId !== taskId ||
|
|
parsed.bundleKind !== 'summary'
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed as LedgerFreshnessV2;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async readLegacyBundleV1(
|
|
projectDir: string,
|
|
taskId: string
|
|
): Promise<LedgerBundleV1 | null> {
|
|
const bundlePaths = taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'bundles'),
|
|
'.json'
|
|
);
|
|
for (const bundlePath of bundlePaths) {
|
|
try {
|
|
const raw = await readFile(bundlePath, 'utf8');
|
|
const parsed = JSON.parse(raw) as Partial<LedgerBundleV1>;
|
|
if (
|
|
parsed?.schemaVersion !== 1 ||
|
|
parsed.source !== 'task-change-ledger' ||
|
|
parsed.taskId !== taskId ||
|
|
!Array.isArray(parsed.events)
|
|
) {
|
|
return null;
|
|
}
|
|
return parsed as LedgerBundleV1;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private async readJournalData(projectDir: string, taskId: string): Promise<JournalData | null> {
|
|
const [events, notices] = await Promise.all([
|
|
this.readJournalEntries<LedgerEvent>({
|
|
filePath: taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'),
|
|
'.jsonl'
|
|
),
|
|
taskId,
|
|
schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION,
|
|
idField: 'eventId',
|
|
}),
|
|
this.readJournalEntries<LedgerNotice>({
|
|
filePath: taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'),
|
|
'.jsonl'
|
|
),
|
|
taskId,
|
|
schemaVersion: TASK_CHANGE_JOURNAL_SCHEMA_VERSION,
|
|
idField: 'noticeId',
|
|
}),
|
|
]);
|
|
|
|
if (events.entries.length === 0 && notices.entries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
events: events.entries,
|
|
notices: notices.entries,
|
|
recovered: events.recovered || notices.recovered,
|
|
};
|
|
}
|
|
|
|
private async readJournalEntries<T extends { taskId: string; schemaVersion: number }>(params: {
|
|
filePath: string | string[];
|
|
taskId: string;
|
|
schemaVersion: number;
|
|
idField: 'eventId' | 'noticeId';
|
|
}): Promise<JournalReadResult<T>> {
|
|
let raw: string | null = null;
|
|
for (const filePath of Array.isArray(params.filePath) ? params.filePath : [params.filePath]) {
|
|
try {
|
|
raw = await readFile(filePath, 'utf8');
|
|
break;
|
|
} catch {
|
|
continue;
|
|
}
|
|
}
|
|
if (raw === null) {
|
|
return { entries: [], recovered: false };
|
|
}
|
|
|
|
const entries: T[] = [];
|
|
const seenIds = new Set<string>();
|
|
let recovered = false;
|
|
for (const line of raw.split('\n')) {
|
|
if (!line.trim()) continue;
|
|
try {
|
|
const parsed = JSON.parse(line) as T & Record<string, unknown>;
|
|
const id = parsed?.[params.idField];
|
|
if (
|
|
parsed?.schemaVersion !== params.schemaVersion ||
|
|
parsed.taskId !== params.taskId ||
|
|
typeof id !== 'string'
|
|
) {
|
|
recovered = true;
|
|
continue;
|
|
}
|
|
if (seenIds.has(id)) {
|
|
recovered = true;
|
|
continue;
|
|
}
|
|
seenIds.add(id);
|
|
entries.push(parsed);
|
|
} catch {
|
|
recovered = true;
|
|
}
|
|
}
|
|
return { entries, recovered };
|
|
}
|
|
|
|
private bundleMatchesFreshness(
|
|
bundle: LedgerSummaryBundleV2,
|
|
freshness: LedgerFreshnessV2
|
|
): boolean {
|
|
return (
|
|
JSON.stringify(bundle.journalStamp) === JSON.stringify(freshness.journalStamp) &&
|
|
bundle.eventCount === freshness.eventCount &&
|
|
bundle.noticeCount === freshness.noticeCount &&
|
|
freshness.bundleSchemaVersion === bundle.schemaVersion &&
|
|
freshness.bundleKind === bundle.bundleKind
|
|
);
|
|
}
|
|
|
|
private buildLedgerProvenance(
|
|
journalStamp: TaskChangeJournalStamp,
|
|
integrity: 'ok' | 'recovered' | 'partial',
|
|
bundleSchemaVersion?: number
|
|
): TaskChangeProvenance {
|
|
return {
|
|
sourceKind: 'ledger',
|
|
sourceFingerprint: this.hashFingerprintPayload({
|
|
journalStamp,
|
|
integrity,
|
|
...(bundleSchemaVersion ? { bundleSchemaVersion } : {}),
|
|
}),
|
|
journalStamp,
|
|
...(bundleSchemaVersion ? { bundleSchemaVersion } : {}),
|
|
integrity,
|
|
};
|
|
}
|
|
|
|
private buildLedgerProvenanceFromJournal(
|
|
journalStamp: TaskChangeJournalStamp,
|
|
bundleSchemaVersion?: number,
|
|
integrity: 'ok' | 'recovered' | 'partial' = 'ok'
|
|
): TaskChangeProvenance {
|
|
return this.buildLedgerProvenance(journalStamp, integrity, bundleSchemaVersion);
|
|
}
|
|
|
|
private buildLedgerProvenanceFromSummaryBundle(
|
|
bundle: LedgerSummaryBundleV2,
|
|
journalStamp: TaskChangeJournalStamp = bundle.journalStamp
|
|
): TaskChangeProvenance {
|
|
return {
|
|
sourceKind: 'ledger',
|
|
sourceFingerprint: this.hashFingerprintPayload(this.buildProjectedSummaryIdentity(bundle)),
|
|
journalStamp,
|
|
bundleSchemaVersion: bundle.schemaVersion,
|
|
integrity: bundle.integrity,
|
|
};
|
|
}
|
|
|
|
private buildProjectedSummaryIdentity(bundle: LedgerSummaryBundleV2): unknown {
|
|
return {
|
|
kind: 'ledger-summary-v2-projected-identity',
|
|
schemaVersion: bundle.schemaVersion,
|
|
bundleKind: bundle.bundleKind,
|
|
taskId: bundle.taskId,
|
|
integrity: bundle.integrity,
|
|
totalFiles: bundle.totalFiles,
|
|
totalLinesAdded: bundle.totalLinesAdded,
|
|
totalLinesRemoved: bundle.totalLinesRemoved,
|
|
diffStatCompleteness: bundle.diffStatCompleteness,
|
|
confidence: bundle.confidence,
|
|
files: [...bundle.files]
|
|
.map((file) => ({
|
|
changeKey: this.normalizeSummaryChangeKey(file),
|
|
filePath: normalizePathForComparison(file.filePath),
|
|
relativePath: normalizePathForComparison(file.relativePath),
|
|
displayPath: file.displayPath ? normalizePathForComparison(file.displayPath) : undefined,
|
|
linesAdded: file.linesAdded,
|
|
linesRemoved: file.linesRemoved,
|
|
diffStatKnown: file.diffStatKnown,
|
|
latestOperation: file.latestOperation,
|
|
createdInTask: file.createdInTask,
|
|
deletedInTask: file.deletedInTask,
|
|
baselineExists: file.baselineExists,
|
|
finalExists: file.finalExists,
|
|
latestBeforeHash: file.latestBeforeHash,
|
|
latestAfterHash: file.latestAfterHash,
|
|
latestBeforeState: this.contentStateFingerprint(file.latestBeforeState),
|
|
latestAfterState: this.contentStateFingerprint(file.latestAfterState),
|
|
contentAvailability: file.contentAvailability,
|
|
reviewability: file.reviewability,
|
|
relation: file.relation
|
|
? {
|
|
kind: file.relation.kind,
|
|
oldPath: normalizePathForComparison(file.relation.oldPath),
|
|
newPath: normalizePathForComparison(file.relation.newPath),
|
|
}
|
|
: undefined,
|
|
worktreePath: file.worktreePath
|
|
? normalizePathForComparison(file.worktreePath)
|
|
: undefined,
|
|
worktreeBranch: file.worktreeBranch,
|
|
baseWorkspaceRoot: file.baseWorkspaceRoot
|
|
? normalizePathForComparison(file.baseWorkspaceRoot)
|
|
: undefined,
|
|
}))
|
|
.sort(
|
|
(left, right) =>
|
|
left.changeKey.localeCompare(right.changeKey) ||
|
|
left.filePath.localeCompare(right.filePath)
|
|
),
|
|
};
|
|
}
|
|
|
|
private contentStateFingerprint(state: LedgerContentState | undefined): unknown {
|
|
if (!state) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
exists: state.exists,
|
|
sha256: state.sha256,
|
|
sizeBytes: state.sizeBytes,
|
|
unavailableReason: state.unavailableReason,
|
|
};
|
|
}
|
|
|
|
private hashFingerprintPayload(payload: unknown): string {
|
|
return createHash('sha256').update(JSON.stringify(payload)).digest('hex');
|
|
}
|
|
|
|
private async readJournalStampFromDisk(
|
|
projectDir: string,
|
|
taskId: string
|
|
): Promise<TaskChangeJournalStamp | null> {
|
|
const readFileStamp = async (filePaths: string[]) => {
|
|
let handle: Awaited<ReturnType<typeof open>> | null = null;
|
|
for (const filePath of filePaths) {
|
|
try {
|
|
handle = await open(filePath, 'r');
|
|
const fileStat = await handle.stat();
|
|
if (!fileStat.isFile()) {
|
|
continue;
|
|
}
|
|
const tailLength = Math.min(fileStat.size, 4096);
|
|
const tail = Buffer.alloc(tailLength);
|
|
if (tailLength > 0) {
|
|
await handle.read(tail, 0, tailLength, fileStat.size - tailLength);
|
|
}
|
|
return {
|
|
bytes: fileStat.size,
|
|
mtimeMs: fileStat.mtimeMs,
|
|
tailSha256: tailLength > 0 ? createHash('sha256').update(tail).digest('hex') : null,
|
|
};
|
|
} catch {
|
|
continue;
|
|
} finally {
|
|
await handle?.close().catch(() => undefined);
|
|
handle = null;
|
|
}
|
|
}
|
|
return undefined;
|
|
};
|
|
|
|
const [events, notices] = await Promise.all([
|
|
readFileStamp(
|
|
taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'events'),
|
|
'.jsonl'
|
|
)
|
|
),
|
|
readFileStamp(
|
|
taskArtifactPathCandidates(
|
|
projectDir,
|
|
taskId,
|
|
path.join(TASK_CHANGE_LEDGER_DIRNAME, 'notices'),
|
|
'.jsonl'
|
|
)
|
|
),
|
|
]);
|
|
|
|
if (!events && !notices) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...(events ? { events } : {}),
|
|
...(notices ? { notices } : {}),
|
|
};
|
|
}
|
|
|
|
private buildSummaryResultFromBundle(params: {
|
|
teamName: string;
|
|
taskId: string;
|
|
projectPath?: string;
|
|
bundle: LedgerSummaryBundleV2;
|
|
provenance: TaskChangeProvenance;
|
|
extraWarnings?: string[];
|
|
}): TaskChangeSetV2 {
|
|
return {
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
files: params.bundle.files.map((file) => this.mapV2SummaryFile(file, params.projectPath)),
|
|
totalLinesAdded: params.bundle.totalLinesAdded,
|
|
totalLinesRemoved: params.bundle.totalLinesRemoved,
|
|
totalFiles: params.bundle.totalFiles,
|
|
confidence: params.bundle.confidence,
|
|
computedAt: params.bundle.generatedAt,
|
|
scope: this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files),
|
|
warnings: [...params.bundle.warnings, ...(params.extraWarnings ?? [])],
|
|
diffStatCompleteness: params.bundle.diffStatCompleteness,
|
|
provenance: params.provenance,
|
|
};
|
|
}
|
|
|
|
private async buildDetailedResult(params: {
|
|
teamName: string;
|
|
taskId: string;
|
|
projectDir: string;
|
|
projectPath?: string;
|
|
journal: JournalData;
|
|
bundle?: LedgerSummaryBundleV2;
|
|
provenance: TaskChangeProvenance;
|
|
}): Promise<TaskChangeSetV2> {
|
|
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
|
|
const snippets = await this.buildSnippets(params.projectDir, projectedEvents);
|
|
const groupedSnippets = this.groupSnippets(snippets);
|
|
const warnings = this.collectWarnings(projectedEvents, params.journal.notices, {
|
|
recovered: params.journal.recovered,
|
|
});
|
|
|
|
let files: FileChangeSummary[];
|
|
let totalLinesAdded: number;
|
|
let totalLinesRemoved: number;
|
|
let totalFiles: number;
|
|
let confidence: TaskChangeSetV2['confidence'];
|
|
let scope: TaskChangeScope;
|
|
let diffStatCompleteness: 'complete' | 'partial' | undefined;
|
|
|
|
if (params.bundle) {
|
|
files = params.bundle.files.map((file) => {
|
|
const groupKey = this.groupKeyForFileSummary(
|
|
file.filePath,
|
|
file.relation,
|
|
file.worktreePath
|
|
);
|
|
const entry = groupedSnippets.get(groupKey);
|
|
return {
|
|
...this.mapV2SummaryFile(file, params.projectPath),
|
|
snippets: entry?.snippets ?? [],
|
|
timeline: entry ? this.buildTimeline(file.filePath, entry.snippets) : undefined,
|
|
};
|
|
});
|
|
totalLinesAdded = params.bundle.totalLinesAdded;
|
|
totalLinesRemoved = params.bundle.totalLinesRemoved;
|
|
totalFiles = params.bundle.totalFiles;
|
|
confidence = params.bundle.confidence;
|
|
scope = this.mapV2Scope(params.taskId, params.bundle.scope, params.bundle.files);
|
|
diffStatCompleteness = params.bundle.diffStatCompleteness;
|
|
} else {
|
|
const fallback = this.buildFallbackFilesFromGroupedSnippets(
|
|
groupedSnippets,
|
|
params.projectPath
|
|
);
|
|
files = fallback.files;
|
|
totalLinesAdded = fallback.totalLinesAdded;
|
|
totalLinesRemoved = fallback.totalLinesRemoved;
|
|
totalFiles = fallback.files.length;
|
|
confidence = projectedEvents.some((event) => event.confidence === 'low')
|
|
? 'low'
|
|
: projectedEvents.some((event) => event.confidence === 'medium')
|
|
? 'medium'
|
|
: 'high';
|
|
scope = this.buildFallbackScope(
|
|
params.taskId,
|
|
files,
|
|
projectedEvents,
|
|
params.journal.notices
|
|
);
|
|
diffStatCompleteness = fallback.files.every((file) => file.diffStatKnown !== false)
|
|
? 'complete'
|
|
: 'partial';
|
|
warnings.push(
|
|
'Ledger detail view fell back to journal reconstruction because summary bundle v2 was unavailable.'
|
|
);
|
|
}
|
|
|
|
return {
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
files,
|
|
totalLinesAdded,
|
|
totalLinesRemoved,
|
|
totalFiles,
|
|
confidence,
|
|
computedAt: params.bundle?.generatedAt ?? new Date().toISOString(),
|
|
scope,
|
|
warnings,
|
|
...(diffStatCompleteness ? { diffStatCompleteness } : {}),
|
|
provenance: params.provenance,
|
|
};
|
|
}
|
|
|
|
private async buildJournalFallbackSummary(params: {
|
|
teamName: string;
|
|
taskId: string;
|
|
projectDir: string;
|
|
projectPath?: string;
|
|
journal: JournalData;
|
|
}): Promise<TaskChangeSetV2> {
|
|
const provenance = this.buildLedgerProvenanceFromJournal(
|
|
(await this.readJournalStampFromDisk(params.projectDir, params.taskId)) ?? {},
|
|
undefined,
|
|
params.journal.recovered ? 'recovered' : 'ok'
|
|
);
|
|
const projectedEvents = this.projectJournalEventsForUi(params.journal.events);
|
|
const snippets = projectedEvents.map((event) => this.eventToSnippet(event, null, null));
|
|
const grouped = this.groupSnippets(snippets);
|
|
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
|
return {
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
files: fallback.files.map((file) => ({ ...file, snippets: [] })),
|
|
totalLinesAdded: fallback.totalLinesAdded,
|
|
totalLinesRemoved: fallback.totalLinesRemoved,
|
|
totalFiles: fallback.files.length,
|
|
confidence: projectedEvents.some((event) => event.confidence === 'low')
|
|
? 'low'
|
|
: projectedEvents.some((event) => event.confidence === 'medium')
|
|
? 'medium'
|
|
: 'high',
|
|
computedAt: new Date().toISOString(),
|
|
scope: this.buildFallbackScope(
|
|
params.taskId,
|
|
fallback.files,
|
|
projectedEvents,
|
|
params.journal.notices
|
|
),
|
|
warnings: [
|
|
...this.collectWarnings(projectedEvents, params.journal.notices, {
|
|
recovered: params.journal.recovered,
|
|
}),
|
|
'Task change summary fell back to journal reconstruction.',
|
|
],
|
|
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
|
? 'complete'
|
|
: 'partial',
|
|
provenance,
|
|
};
|
|
}
|
|
|
|
private async buildLegacyResult(params: {
|
|
teamName: string;
|
|
taskId: string;
|
|
projectDir: string;
|
|
projectPath?: string;
|
|
bundle: LedgerBundleV1;
|
|
includeDetails: boolean;
|
|
}): Promise<TaskChangeSetV2> {
|
|
const snippets = params.includeDetails
|
|
? await this.buildSnippets(params.projectDir, params.bundle.events)
|
|
: params.bundle.events.map((event) => this.eventToSnippet(event, null, null));
|
|
const grouped = this.groupSnippets(snippets);
|
|
const fallback = this.buildFallbackFilesFromGroupedSnippets(grouped, params.projectPath);
|
|
const warnings = new Set<string>(params.bundle.warnings ?? []);
|
|
warnings.add(
|
|
'Task change ledger used legacy bundle v1 compatibility mode; summary was derived from legacy events.'
|
|
);
|
|
for (const notice of params.bundle.notices ?? []) warnings.add(notice.message);
|
|
|
|
return {
|
|
teamName: params.teamName,
|
|
taskId: params.taskId,
|
|
files: params.includeDetails
|
|
? fallback.files
|
|
: fallback.files.map((file) => ({ ...file, snippets: [], timeline: undefined })),
|
|
totalLinesAdded: fallback.totalLinesAdded,
|
|
totalLinesRemoved: fallback.totalLinesRemoved,
|
|
totalFiles: fallback.files.length,
|
|
confidence: params.bundle.confidence,
|
|
computedAt: params.bundle.generatedAt,
|
|
scope: this.buildFallbackScope(
|
|
params.taskId,
|
|
fallback.files,
|
|
params.bundle.events,
|
|
params.bundle.notices ?? []
|
|
),
|
|
warnings: [...warnings],
|
|
diffStatCompleteness: fallback.files.every((file) => file.diffStatKnown !== false)
|
|
? 'complete'
|
|
: 'partial',
|
|
provenance: {
|
|
sourceKind: 'ledger',
|
|
sourceFingerprint: this.hashFingerprintPayload({
|
|
legacyTaskId: params.taskId,
|
|
generatedAt: params.bundle.generatedAt,
|
|
eventCount: params.bundle.eventCount,
|
|
}),
|
|
},
|
|
};
|
|
}
|
|
|
|
private mapV2SummaryFile(file: LedgerSummaryFileV2, projectPath?: string): FileChangeSummary {
|
|
const displayPath = file.displayPath ?? file.filePath;
|
|
const filePath = this.normalizeLedgerFilePath(file.filePath);
|
|
const agentIds = Array.isArray(file.agentIds) ? file.agentIds : [];
|
|
return {
|
|
filePath,
|
|
relativePath: this.relativePath(displayPath, projectPath, file.relativePath),
|
|
snippets: [],
|
|
linesAdded: file.linesAdded,
|
|
linesRemoved: file.linesRemoved,
|
|
isNewFile: Boolean(
|
|
file.createdInTask && file.latestOperation !== 'delete' && file.relation?.kind !== 'rename'
|
|
),
|
|
changeKey: this.normalizeSummaryChangeKey(file),
|
|
diffStatKnown: file.diffStatKnown,
|
|
ledgerSummary: {
|
|
latestOperation: file.latestOperation,
|
|
createdInTask: file.createdInTask,
|
|
deletedInTask: file.deletedInTask,
|
|
contentAvailability: file.contentAvailability,
|
|
reviewability: file.reviewability,
|
|
...(file.relation ? { relation: file.relation } : {}),
|
|
...(file.latestBeforeState ? { beforeState: file.latestBeforeState } : {}),
|
|
...(file.latestAfterState ? { afterState: file.latestAfterState } : {}),
|
|
...(file.primaryActorKey ? { primaryActorKey: file.primaryActorKey } : {}),
|
|
...(agentIds.length > 0 ? { agentIds } : {}),
|
|
...(file.memberNames ? { memberNames: file.memberNames } : {}),
|
|
...(file.executionSeqRange ? { executionSeqRange: file.executionSeqRange } : {}),
|
|
...(file.worktreePath ? { worktreePath: file.worktreePath } : {}),
|
|
...(file.worktreeBranch ? { worktreeBranch: file.worktreeBranch } : {}),
|
|
...(file.baseWorkspaceRoot ? { baseWorkspaceRoot: file.baseWorkspaceRoot } : {}),
|
|
...(file.dirtyLeaderWarning ? { dirtyLeaderWarning: file.dirtyLeaderWarning } : {}),
|
|
},
|
|
};
|
|
}
|
|
|
|
private normalizeSummaryChangeKey(file: LedgerSummaryFileV2): string {
|
|
if (file.relation) {
|
|
return this.relationChangeKey(file.relation, file.worktreePath);
|
|
}
|
|
const slashNormalized = file.changeKey.replace(/\\/g, '/');
|
|
const pathKeyMatch = /^(path|create|delete):(.+)$/.exec(slashNormalized);
|
|
if (pathKeyMatch) {
|
|
return `${pathKeyMatch[1]}:${normalizePathForComparison(pathKeyMatch[2] ?? '')}`;
|
|
}
|
|
return slashNormalized;
|
|
}
|
|
|
|
private mapV2Scope(
|
|
taskId: string,
|
|
scope: LedgerSummaryScopeV2,
|
|
files: LedgerSummaryFileV2[]
|
|
): TaskChangeScope {
|
|
const agentIds = Array.isArray(scope.agentIds) ? scope.agentIds : [];
|
|
return {
|
|
taskId,
|
|
memberName:
|
|
scope.memberName ||
|
|
scope.primaryMemberName ||
|
|
scope.primaryAgentId ||
|
|
scope.primaryActorKey ||
|
|
'',
|
|
startLine: 0,
|
|
endLine: 0,
|
|
startTimestamp: scope.startTimestamp,
|
|
endTimestamp: scope.endTimestamp,
|
|
toolUseIds: scope.toolUseIds,
|
|
filePaths: files.map((file) => this.normalizeLedgerFilePath(file.filePath)),
|
|
confidence: scope.confidence,
|
|
...(scope.primaryActorKey ? { primaryActorKey: scope.primaryActorKey } : {}),
|
|
...(scope.primaryAgentId ? { primaryAgentId: scope.primaryAgentId } : {}),
|
|
...(scope.primaryMemberName ? { primaryMemberName: scope.primaryMemberName } : {}),
|
|
...(agentIds.length > 0 ? { agentIds } : {}),
|
|
...(scope.memberNames ? { memberNames: scope.memberNames } : {}),
|
|
...(scope.toolUseCount !== undefined ? { toolUseCount: scope.toolUseCount } : {}),
|
|
...(scope.toolUseIdsTruncated ? { toolUseIdsTruncated: true } : {}),
|
|
...(scope.phaseSet ? { phaseSet: scope.phaseSet } : {}),
|
|
...(scope.executionSeqRange ? { executionSeqRange: scope.executionSeqRange } : {}),
|
|
...(scope.confidenceBreakdown ? { confidenceBreakdown: scope.confidenceBreakdown } : {}),
|
|
...(scope.contributors ? { contributors: scope.contributors } : {}),
|
|
...(scope.worktreePaths ? { worktreePaths: scope.worktreePaths } : {}),
|
|
...(scope.worktreeBranches ? { worktreeBranches: scope.worktreeBranches } : {}),
|
|
...(scope.baseWorkspaceRoots ? { baseWorkspaceRoots: scope.baseWorkspaceRoots } : {}),
|
|
...(scope.dirtyLeaderWarnings ? { dirtyLeaderWarnings: scope.dirtyLeaderWarnings } : {}),
|
|
};
|
|
}
|
|
|
|
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 projectJournalEventsForUi(events: LedgerEvent[]): LedgerEvent[] {
|
|
const selectedBySourceImportKey = new Map<
|
|
string,
|
|
{ event: LedgerEvent; index: number; rank: number }
|
|
>();
|
|
const passthrough: Array<{ event: LedgerEvent; index: number }> = [];
|
|
|
|
events.forEach((event, index) => {
|
|
const sourceImportKey = this.sourceImportKeyForEvent(event);
|
|
if (!sourceImportKey) {
|
|
passthrough.push({ event, index });
|
|
return;
|
|
}
|
|
const rank = this.evidenceRankForEvent(event);
|
|
const existing = selectedBySourceImportKey.get(sourceImportKey);
|
|
if (!existing || rank >= existing.rank) {
|
|
selectedBySourceImportKey.set(sourceImportKey, { event, index, rank });
|
|
}
|
|
});
|
|
|
|
return [
|
|
...passthrough,
|
|
...[...selectedBySourceImportKey.values()].map(({ event, index }) => ({ event, index })),
|
|
]
|
|
.sort((left, right) => left.index - right.index)
|
|
.map(({ event }) => event);
|
|
}
|
|
|
|
private sourceImportKeyForEvent(event: LedgerEvent): string | null {
|
|
if (
|
|
event.sourceImportKey &&
|
|
(event.sourceRuntime === 'opencode' ||
|
|
event.sourceProvider === 'opencode' ||
|
|
event.source === 'opencode_toolpart_write' ||
|
|
event.source === 'opencode_toolpart_edit' ||
|
|
event.source === 'opencode_toolpart_apply_patch')
|
|
) {
|
|
return event.sourceImportKey;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private evidenceRankForEvent(event: LedgerEvent): number {
|
|
const hasFullText = this.hasFullTextEvidence(event);
|
|
|
|
switch (event.evidenceProof) {
|
|
case 'opencode-snapshot':
|
|
return hasFullText ? 50 : 35;
|
|
case 'inverse-apply-patch-chain':
|
|
case 'inverse-edit-chain':
|
|
case 'toolpart-chain':
|
|
return hasFullText ? 40 : 25;
|
|
case 'metadata-only-fallback':
|
|
return 10;
|
|
default:
|
|
return hasFullText ? 30 : 5;
|
|
}
|
|
}
|
|
|
|
private hasFullTextEvidence(event: Pick<LedgerEvent, 'before' | 'after' | 'operation'>): boolean {
|
|
if (event.operation === 'create') {
|
|
return event.after !== null;
|
|
}
|
|
if (event.operation === 'delete') {
|
|
return event.before !== null;
|
|
}
|
|
return event.before !== null && event.after !== null;
|
|
}
|
|
|
|
private async readContentRef(
|
|
projectDir: string,
|
|
ref: LedgerContentRef | null
|
|
): Promise<string | null> {
|
|
if (!ref?.blobRef) {
|
|
return null;
|
|
}
|
|
try {
|
|
const buffer = await readFile(
|
|
path.join(projectDir, TASK_CHANGE_LEDGER_DIRNAME, 'blobs', ref.blobRef)
|
|
);
|
|
return decodeLedgerTextBlob(buffer);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private eventToSnippet(
|
|
event: LedgerEvent,
|
|
beforeContent: string | null,
|
|
afterContent: string | null
|
|
): SnippetDiff {
|
|
const filePath = this.normalizeLedgerFilePath(event.filePath);
|
|
return {
|
|
toolUseId: event.toolUseId,
|
|
filePath,
|
|
toolName: this.mapToolName(event.source),
|
|
type: this.mapSnippetType(event),
|
|
oldString: event.oldString ?? beforeContent ?? '',
|
|
newString: event.newString ?? afterContent ?? '',
|
|
replaceAll: event.replaceAll ?? false,
|
|
timestamp: event.timestamp,
|
|
isError: false,
|
|
ledger: {
|
|
eventId: event.eventId,
|
|
source: event.confidence === 'exact' ? 'ledger-exact' : 'ledger-snapshot',
|
|
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,
|
|
linesAdded: event.linesAdded,
|
|
linesRemoved: event.linesRemoved,
|
|
worktreePath: event.worktreePath,
|
|
worktreeBranch: event.worktreeBranch,
|
|
baseWorkspaceRoot: event.baseWorkspaceRoot,
|
|
dirtyLeaderWarning: event.dirtyLeaderWarning,
|
|
textAvailability:
|
|
beforeContent !== null && afterContent !== null
|
|
? 'full-text'
|
|
: event.oldString !== undefined || event.newString !== undefined
|
|
? 'patch-text'
|
|
: 'unavailable',
|
|
},
|
|
};
|
|
}
|
|
|
|
private mapToolName(eventSource: LedgerEvent['source']): SnippetDiff['toolName'] {
|
|
switch (eventSource) {
|
|
case 'file_edit':
|
|
return 'Edit';
|
|
case 'file_write':
|
|
case 'opencode_toolpart_write':
|
|
return 'Write';
|
|
case 'notebook_edit':
|
|
return 'NotebookEdit';
|
|
case 'opencode_toolpart_edit':
|
|
case 'opencode_toolpart_apply_patch':
|
|
return 'Edit';
|
|
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' || event.source === 'opencode_toolpart_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 groupSnippets(
|
|
snippets: SnippetDiff[]
|
|
): Map<string, { filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }> {
|
|
const grouped = new Map<
|
|
string,
|
|
{ filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }
|
|
>();
|
|
for (const snippet of snippets) {
|
|
const groupKey = this.groupKeyForSnippet(snippet);
|
|
const existing = grouped.get(groupKey);
|
|
if (existing) {
|
|
existing.snippets.push(snippet);
|
|
} else {
|
|
grouped.set(groupKey, {
|
|
filePath: snippet.filePath,
|
|
...(snippet.ledger?.relation ? { relation: snippet.ledger.relation } : {}),
|
|
snippets: [snippet],
|
|
});
|
|
}
|
|
}
|
|
return grouped;
|
|
}
|
|
|
|
private buildFallbackFilesFromGroupedSnippets(
|
|
grouped: Map<
|
|
string,
|
|
{ filePath: string; relation?: LedgerChangeRelation; snippets: SnippetDiff[] }
|
|
>,
|
|
projectPath?: string
|
|
): { files: FileChangeSummary[]; totalLinesAdded: number; totalLinesRemoved: number } {
|
|
const files: FileChangeSummary[] = [];
|
|
for (const entry of grouped.values()) {
|
|
const relation = entry.relation ?? this.relationForSnippets(entry.snippets);
|
|
let linesAdded = 0;
|
|
let linesRemoved = 0;
|
|
for (const snippet of entry.snippets) {
|
|
if (
|
|
typeof snippet.ledger?.linesAdded === 'number' ||
|
|
typeof snippet.ledger?.linesRemoved === 'number'
|
|
) {
|
|
linesAdded += snippet.ledger?.linesAdded ?? 0;
|
|
linesRemoved += snippet.ledger?.linesRemoved ?? 0;
|
|
continue;
|
|
}
|
|
const { added, removed } = this.countLineChanges(snippet.oldString, snippet.newString);
|
|
linesAdded += added;
|
|
linesRemoved += removed;
|
|
}
|
|
const displayPath = this.resolveGroupedDisplayPath(entry.filePath, relation, entry.snippets);
|
|
const worktreeLedger = entry.snippets.find((snippet) => snippet.ledger?.worktreePath)?.ledger;
|
|
const firstLedger = entry.snippets.find((snippet) => snippet.ledger)?.ledger;
|
|
const lastLedger = [...entry.snippets].reverse().find((snippet) => snippet.ledger)?.ledger;
|
|
const baselineExists = firstLedger?.beforeState?.exists;
|
|
const finalExists = lastLedger?.afterState?.exists;
|
|
const isCreatedLifecycle = baselineExists === false && finalExists === true;
|
|
const fallbackIsCreated = entry.snippets.some(
|
|
(snippet) => snippet.type === 'write-new' || snippet.ledger?.operation === 'create'
|
|
);
|
|
files.push({
|
|
filePath: displayPath,
|
|
relativePath: this.relativePath(displayPath, projectPath),
|
|
snippets: entry.snippets,
|
|
linesAdded,
|
|
linesRemoved,
|
|
isNewFile:
|
|
relation?.kind !== 'rename' &&
|
|
(baselineExists === undefined || finalExists === undefined
|
|
? fallbackIsCreated
|
|
: isCreatedLifecycle),
|
|
changeKey: relation
|
|
? this.relationChangeKey(relation, worktreeLedger?.worktreePath)
|
|
: `path:${normalizePathForComparison(displayPath)}`,
|
|
diffStatKnown: true,
|
|
ledgerSummary: {
|
|
...(relation ? { relation } : {}),
|
|
latestOperation:
|
|
entry.snippets[entry.snippets.length - 1]?.ledger?.operation ??
|
|
(entry.snippets[entry.snippets.length - 1]?.type === 'write-new' ? 'create' : 'modify'),
|
|
...(worktreeLedger?.worktreePath ? { worktreePath: worktreeLedger.worktreePath } : {}),
|
|
...(worktreeLedger?.worktreeBranch
|
|
? { worktreeBranch: worktreeLedger.worktreeBranch }
|
|
: {}),
|
|
...(worktreeLedger?.baseWorkspaceRoot
|
|
? { baseWorkspaceRoot: worktreeLedger.baseWorkspaceRoot }
|
|
: {}),
|
|
...(worktreeLedger?.dirtyLeaderWarning
|
|
? { dirtyLeaderWarning: worktreeLedger.dirtyLeaderWarning }
|
|
: {}),
|
|
},
|
|
timeline: this.buildTimeline(displayPath, entry.snippets),
|
|
});
|
|
}
|
|
const totalLinesAdded = files.reduce((sum, file) => sum + file.linesAdded, 0);
|
|
const totalLinesRemoved = files.reduce((sum, file) => sum + file.linesRemoved, 0);
|
|
return { files, totalLinesAdded, totalLinesRemoved };
|
|
}
|
|
|
|
private buildFallbackScope(
|
|
taskId: string,
|
|
files: FileChangeSummary[],
|
|
events: LedgerEvent[],
|
|
notices: LedgerNotice[]
|
|
): TaskChangeScope {
|
|
const primaryMemberName = events.find((event) => event.memberName)?.memberName;
|
|
const primaryAgentId = events.find((event) => event.agentId)?.agentId;
|
|
const worktreePaths = [
|
|
...new Set(events.flatMap((event) => (event.worktreePath ? [event.worktreePath] : []))),
|
|
].sort();
|
|
const worktreeBranches = [
|
|
...new Set(events.flatMap((event) => (event.worktreeBranch ? [event.worktreeBranch] : []))),
|
|
].sort();
|
|
const baseWorkspaceRoots = [
|
|
...new Set(
|
|
events.flatMap((event) => (event.baseWorkspaceRoot ? [event.baseWorkspaceRoot] : []))
|
|
),
|
|
].sort();
|
|
const dirtyLeaderWarnings = [
|
|
...new Set(
|
|
events.flatMap((event) => (event.dirtyLeaderWarning ? [event.dirtyLeaderWarning] : []))
|
|
),
|
|
].sort();
|
|
return {
|
|
taskId,
|
|
memberName: primaryMemberName ?? primaryAgentId ?? '',
|
|
startLine: 0,
|
|
endLine: 0,
|
|
startTimestamp: events[0]?.timestamp ?? notices[0]?.timestamp ?? '',
|
|
endTimestamp:
|
|
events[events.length - 1]?.timestamp ?? notices[notices.length - 1]?.timestamp ?? '',
|
|
toolUseIds: [
|
|
...new Set([...events.map((event) => event.toolUseId), ...notices.map((n) => n.toolUseId)]),
|
|
],
|
|
filePaths: files.map((file) => file.filePath),
|
|
confidence: {
|
|
tier: events.some((event) => event.confidence !== 'exact') ? 2 : 1,
|
|
label: events.some((event) => event.confidence !== 'exact') ? 'medium' : 'high',
|
|
reason: 'Scoped by orchestrator task-change ledger',
|
|
},
|
|
...(primaryMemberName ? { primaryMemberName } : {}),
|
|
...(primaryAgentId ? { primaryAgentId } : {}),
|
|
...(events.some((event) => !!event.memberName)
|
|
? {
|
|
memberNames: [
|
|
...new Set(events.flatMap((event) => (event.memberName ? [event.memberName] : []))),
|
|
].sort(),
|
|
}
|
|
: {}),
|
|
...(events.length > 0
|
|
? {
|
|
executionSeqRange: {
|
|
start: Math.min(...events.map((event) => event.executionSeq)),
|
|
end: Math.max(...events.map((event) => event.executionSeq)),
|
|
},
|
|
}
|
|
: {}),
|
|
...(worktreePaths.length > 0 ? { worktreePaths } : {}),
|
|
...(worktreeBranches.length > 0 ? { worktreeBranches } : {}),
|
|
...(baseWorkspaceRoots.length > 0 ? { baseWorkspaceRoots } : {}),
|
|
...(dirtyLeaderWarnings.length > 0 ? { dirtyLeaderWarnings } : {}),
|
|
};
|
|
}
|
|
|
|
private collectWarnings(
|
|
events: LedgerEvent[],
|
|
notices: LedgerNotice[],
|
|
options: { recovered: boolean }
|
|
): string[] {
|
|
const warnings = new Set<string>();
|
|
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.`);
|
|
}
|
|
}
|
|
if (options.recovered) {
|
|
warnings.add('Task change ledger recovered from malformed journal lines.');
|
|
}
|
|
return [...warnings];
|
|
}
|
|
|
|
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 groupKeyForSnippet(snippet: SnippetDiff): string {
|
|
return this.groupKeyForFileSummary(
|
|
snippet.filePath,
|
|
snippet.ledger?.relation,
|
|
snippet.ledger?.worktreePath
|
|
);
|
|
}
|
|
|
|
private groupKeyForFileSummary(
|
|
filePath: string,
|
|
relation?: LedgerChangeRelation,
|
|
worktreePath?: string
|
|
): string {
|
|
if (relation) {
|
|
return this.relationChangeKey(relation, worktreePath);
|
|
}
|
|
return `path:${normalizePathForComparison(filePath)}`;
|
|
}
|
|
|
|
private relationChangeKey(relation: LedgerChangeRelation, worktreePath?: string): string {
|
|
const pathPart = `${normalizePathForComparison(relation.oldPath)}->${normalizePathForComparison(relation.newPath)}`;
|
|
return worktreePath
|
|
? `${relation.kind}:${normalizePathForComparison(worktreePath)}:${pathPart}`
|
|
: `${relation.kind}:${pathPart}`;
|
|
}
|
|
|
|
private relationForSnippets(snippets: SnippetDiff[]): LedgerChangeRelation | undefined {
|
|
return snippets.find((snippet) => snippet.ledger?.relation)?.ledger?.relation;
|
|
}
|
|
|
|
private resolveGroupedDisplayPath(
|
|
fallbackPath: string,
|
|
relation: LedgerChangeRelation | undefined,
|
|
snippets: SnippetDiff[]
|
|
): string {
|
|
if (!relation) {
|
|
return fallbackPath;
|
|
}
|
|
|
|
const newPathSnippet = snippets.find((snippet) =>
|
|
this.pathMatchesRelationPath(snippet.filePath, relation.newPath)
|
|
);
|
|
if (newPathSnippet) {
|
|
return newPathSnippet.filePath;
|
|
}
|
|
|
|
const createdSnippet = snippets.find(
|
|
(snippet) => snippet.ledger?.operation === 'create' || snippet.type === 'write-new'
|
|
);
|
|
if (createdSnippet) {
|
|
return createdSnippet.filePath;
|
|
}
|
|
|
|
return (
|
|
this.resolveRelatedPathFromRelation(fallbackPath, relation.oldPath, relation.newPath) ??
|
|
fallbackPath
|
|
);
|
|
}
|
|
|
|
private pathMatchesRelationPath(filePath: string, relationPath: string): boolean {
|
|
const caseInsensitive =
|
|
this.isWindowsReviewPath(filePath) || this.isWindowsReviewPath(relationPath);
|
|
const normalizedFilePath = this.normalizeRelationComparisonPath(filePath, caseInsensitive);
|
|
const normalizedRelationPath = this.normalizeRelationComparisonPath(
|
|
relationPath,
|
|
caseInsensitive
|
|
);
|
|
return (
|
|
normalizedFilePath === normalizedRelationPath ||
|
|
normalizedFilePath.endsWith(`/${normalizedRelationPath}`)
|
|
);
|
|
}
|
|
|
|
private resolveRelatedPathFromRelation(
|
|
anchorPath: string,
|
|
anchorRelationPath: string,
|
|
targetRelationPath: string
|
|
): string | null {
|
|
const slashAnchor = anchorPath.replace(/\\/g, '/');
|
|
const slashAnchorRelation = anchorRelationPath.replace(/\\/g, '/');
|
|
const caseInsensitive =
|
|
this.isWindowsReviewPath(anchorPath) || this.isWindowsReviewPath(anchorRelationPath);
|
|
const normalizedAnchor = this.normalizeRelationComparisonPath(anchorPath, caseInsensitive);
|
|
const normalizedAnchorRelation = this.normalizeRelationComparisonPath(
|
|
anchorRelationPath,
|
|
caseInsensitive
|
|
);
|
|
if (!this.matchesRelationSuffix(normalizedAnchor, normalizedAnchorRelation)) {
|
|
return null;
|
|
}
|
|
|
|
return this.normalizeLedgerFilePath(
|
|
`${slashAnchor.slice(0, slashAnchor.length - slashAnchorRelation.length)}${targetRelationPath.replace(/\\/g, '/')}`
|
|
);
|
|
}
|
|
|
|
private normalizeRelationComparisonPath(filePath: string, caseInsensitive: boolean): string {
|
|
const normalized = normalizePathForComparison(filePath);
|
|
return caseInsensitive ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
private isWindowsReviewPath(filePath: string): boolean {
|
|
return isWindowsishPath(filePath) || filePath.includes('\\');
|
|
}
|
|
|
|
private matchesRelationSuffix(normalizedPath: string, normalizedRelationPath: string): boolean {
|
|
return (
|
|
normalizedPath === normalizedRelationPath ||
|
|
normalizedPath.endsWith(`/${normalizedRelationPath}`)
|
|
);
|
|
}
|
|
|
|
private normalizeLedgerFilePath(filePath: string): string {
|
|
const slashPath = filePath.replace(/\\/g, '/');
|
|
const isWindowsAbsolute = /^[A-Za-z]:\//.test(slashPath) || slashPath.startsWith('//');
|
|
if (isWindowsAbsolute || (process.platform !== 'win32' && path.isAbsolute(filePath))) {
|
|
return path.normalize(filePath);
|
|
}
|
|
return slashPath;
|
|
}
|
|
|
|
private relativePath(
|
|
filePath: string,
|
|
projectPath?: string,
|
|
explicitRelativePath?: string
|
|
): string {
|
|
if (explicitRelativePath) {
|
|
return explicitRelativePath.replace(/\\/g, '/');
|
|
}
|
|
const normalizedFilePath = filePath.replace(/\\/g, '/');
|
|
const normalizedProjectPath = projectPath?.replace(/\\/g, '/');
|
|
const comparableFilePath = normalizePathForComparison(normalizedFilePath);
|
|
const comparableProjectPath = normalizedProjectPath
|
|
? normalizePathForComparison(normalizedProjectPath)
|
|
: undefined;
|
|
if (
|
|
normalizedProjectPath &&
|
|
comparableProjectPath &&
|
|
comparableFilePath.startsWith(`${comparableProjectPath}/`)
|
|
) {
|
|
return normalizedFilePath.slice(normalizedProjectPath.length + 1);
|
|
}
|
|
return normalizedFilePath.split('/').slice(-3).join('/');
|
|
}
|
|
}
|