feat: enhance ChangeExtractorService with interval-based scoping
- Implemented deterministic interval scoping in ChangeExtractorService to improve task change retrieval when no specific scope is found. - Added a new method, extractIntervalScopedChanges, to handle changes based on provided intervals, enhancing the accuracy of change tracking. - Updated the logic for deriving intervals from task status history, ensuring better handling of task transitions. - Refactored related code for improved clarity and maintainability, including better type definitions and error handling.
This commit is contained in:
parent
31c4c7a441
commit
bd781aed2f
2 changed files with 227 additions and 44 deletions
|
|
@ -139,8 +139,46 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Если scope не найден — fallback на весь файл
|
// Если scope не найден — try deterministic interval scoping, else fallback to whole file
|
||||||
if (allScopes.length === 0) {
|
if (allScopes.length === 0) {
|
||||||
|
const intervals = options?.intervals ?? taskMeta?.intervals;
|
||||||
|
if (Array.isArray(intervals) && intervals.length > 0) {
|
||||||
|
const { files, toolUseIds, startTimestamp, endTimestamp } =
|
||||||
|
await this.extractIntervalScopedChanges(logRefs, intervals, projectPath);
|
||||||
|
|
||||||
|
const intervalScope: TaskChangeScope = {
|
||||||
|
taskId,
|
||||||
|
memberName: taskMeta?.owner ?? logRefs[0]?.memberName ?? '',
|
||||||
|
startLine: 0,
|
||||||
|
endLine: 0,
|
||||||
|
startTimestamp,
|
||||||
|
endTimestamp,
|
||||||
|
toolUseIds,
|
||||||
|
filePaths: files.map((f) => f.filePath),
|
||||||
|
confidence: {
|
||||||
|
tier: 2,
|
||||||
|
label: 'medium',
|
||||||
|
reason: 'Scoped by persisted task workIntervals (timestamp-based)',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
taskId,
|
||||||
|
files,
|
||||||
|
totalLinesAdded: files.reduce((sum, f) => sum + f.linesAdded, 0),
|
||||||
|
totalLinesRemoved: files.reduce((sum, f) => sum + f.linesRemoved, 0),
|
||||||
|
totalFiles: files.length,
|
||||||
|
confidence: 'medium',
|
||||||
|
computedAt: new Date().toISOString(),
|
||||||
|
scope: intervalScope,
|
||||||
|
warnings:
|
||||||
|
files.length === 0
|
||||||
|
? ['No file edits found within persisted workIntervals.']
|
||||||
|
: ['Task boundaries missing — scoped by workIntervals timestamps.'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
|
return this.fallbackSingleTaskScope(teamName, taskId, logRefs, projectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -203,10 +241,46 @@ export class ChangeExtractorService {
|
||||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
const derivedIntervals = (() => {
|
||||||
|
if (Array.isArray(intervals) && intervals.length > 0) return intervals;
|
||||||
|
const rawHistory = parsed.statusHistory;
|
||||||
|
if (!Array.isArray(rawHistory)) return undefined;
|
||||||
|
|
||||||
|
const transitions = rawHistory
|
||||||
|
.map((h) => (h && typeof h === 'object' ? (h as Record<string, unknown>) : null))
|
||||||
|
.filter((h): h is Record<string, unknown> => h !== null)
|
||||||
|
.map((h) => ({
|
||||||
|
to: typeof h.to === 'string' ? h.to : null,
|
||||||
|
timestamp: typeof h.timestamp === 'string' ? h.timestamp : null,
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(t): t is { to: string; timestamp: string } => t.to !== null && t.timestamp !== null
|
||||||
|
)
|
||||||
|
.sort((a, b) => Date.parse(a.timestamp) - Date.parse(b.timestamp));
|
||||||
|
|
||||||
|
if (transitions.length === 0) return undefined;
|
||||||
|
|
||||||
|
const derived: { startedAt: string; completedAt?: string }[] = [];
|
||||||
|
let currentStart: string | null = null;
|
||||||
|
for (const t of transitions) {
|
||||||
|
if (t.to === 'in_progress') {
|
||||||
|
if (!currentStart) currentStart = t.timestamp;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (currentStart) {
|
||||||
|
derived.push({ startedAt: currentStart, completedAt: t.timestamp });
|
||||||
|
currentStart = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (currentStart) derived.push({ startedAt: currentStart });
|
||||||
|
|
||||||
|
return derived.length > 0 ? derived : undefined;
|
||||||
|
})();
|
||||||
return {
|
return {
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
status: typeof parsed.status === 'string' ? parsed.status : undefined,
|
||||||
intervals,
|
intervals: derivedIntervals,
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -223,6 +297,74 @@ export class ChangeExtractorService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async extractIntervalScopedChanges(
|
||||||
|
logRefs: LogFileRef[],
|
||||||
|
intervals: { startedAt: string; completedAt?: string }[],
|
||||||
|
projectPath?: string
|
||||||
|
): Promise<{
|
||||||
|
files: FileChangeSummary[];
|
||||||
|
toolUseIds: string[];
|
||||||
|
startTimestamp: string;
|
||||||
|
endTimestamp: string;
|
||||||
|
}> {
|
||||||
|
const normalized: {
|
||||||
|
startMs: number;
|
||||||
|
endMs: number | null;
|
||||||
|
startedAt: string;
|
||||||
|
completedAt?: string;
|
||||||
|
}[] = [];
|
||||||
|
|
||||||
|
for (const i of intervals) {
|
||||||
|
const startMs = Date.parse(i.startedAt);
|
||||||
|
if (!Number.isFinite(startMs)) continue;
|
||||||
|
const endMsRaw = typeof i.completedAt === 'string' ? Date.parse(i.completedAt) : Number.NaN;
|
||||||
|
const endMs = Number.isFinite(endMsRaw) ? endMsRaw : null;
|
||||||
|
normalized.push({ startMs, endMs, startedAt: i.startedAt, completedAt: i.completedAt });
|
||||||
|
}
|
||||||
|
|
||||||
|
normalized.sort((a, b) => a.startMs - b.startMs);
|
||||||
|
const startTimestamp = normalized[0]?.startedAt ?? '';
|
||||||
|
|
||||||
|
const maxEnd = normalized.reduce<{ endMs: number; endTimestamp: string } | null>((acc, it) => {
|
||||||
|
if (it.endMs == null || typeof it.completedAt !== 'string') return acc;
|
||||||
|
if (!acc || it.endMs > acc.endMs) return { endMs: it.endMs, endTimestamp: it.completedAt };
|
||||||
|
return acc;
|
||||||
|
}, null);
|
||||||
|
const endTimestamp = maxEnd?.endTimestamp ?? '';
|
||||||
|
|
||||||
|
const inAnyInterval = (ts: string): boolean => {
|
||||||
|
const ms = Date.parse(ts);
|
||||||
|
if (!Number.isFinite(ms)) return false;
|
||||||
|
for (const it of normalized) {
|
||||||
|
if (ms < it.startMs) continue;
|
||||||
|
if (it.endMs == null) return true;
|
||||||
|
if (ms <= it.endMs) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowedSnippets: SnippetDiff[] = [];
|
||||||
|
const toolUseIdsSet = new Set<string>();
|
||||||
|
|
||||||
|
for (const ref of logRefs) {
|
||||||
|
const snippets = await this.parseJSONLFile(ref.filePath);
|
||||||
|
for (const s of snippets) {
|
||||||
|
if (s.isError) continue;
|
||||||
|
if (!inAnyInterval(s.timestamp)) continue;
|
||||||
|
allowedSnippets.push(s);
|
||||||
|
if (s.toolUseId) toolUseIdsSet.add(s.toolUseId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = this.aggregateByFile(allowedSnippets, projectPath);
|
||||||
|
return {
|
||||||
|
files,
|
||||||
|
toolUseIds: [...toolUseIdsSet],
|
||||||
|
startTimestamp,
|
||||||
|
endTimestamp,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compute a context hash from old/newString for reliable hunk↔snippet matching.
|
* Compute a context hash from old/newString for reliable hunk↔snippet matching.
|
||||||
* Uses first+last 3 lines of both strings as a fingerprint.
|
* Uses first+last 3 lines of both strings as a fingerprint.
|
||||||
|
|
|
||||||
|
|
@ -472,52 +472,86 @@ export class TeamMemberLogsFinder {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string
|
taskId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const escaped = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
const teamLower = teamName.trim().toLowerCase();
|
||||||
const numericTaskId = /^\d+$/.test(taskId) ? taskId : null;
|
const taskIdStr = taskId.trim();
|
||||||
const teamEscaped = escapeRegex(teamName);
|
|
||||||
const teamPatterns: RegExp[] = [
|
const extractTaskIdFromUnknown = (raw: unknown): string | null => {
|
||||||
// Team tool inputs often include team_name
|
if (typeof raw === 'string') return raw.trim();
|
||||||
new RegExp(`"team_name"\\s*:\\s*"${teamEscaped}"`, 'i'),
|
if (typeof raw === 'number' && Number.isFinite(raw)) return String(raw);
|
||||||
// Some variants may use teamName or team
|
return null;
|
||||||
new RegExp(`"teamName"\\s*:\\s*"${teamEscaped}"`, 'i'),
|
};
|
||||||
new RegExp(`"team"\\s*:\\s*"${teamEscaped}"`, 'i'),
|
|
||||||
// CLI usage: node ".../teamctl.js" --team team-alpha task start 9
|
const extractTeamFromInput = (input: Record<string, unknown>): string | null => {
|
||||||
new RegExp(`\\b--team\\b\\s*(?:=\\s*)?(?:"${teamEscaped}"|${teamEscaped})\\b`, 'i'),
|
const raw =
|
||||||
];
|
typeof input.team_name === 'string'
|
||||||
const patterns: RegExp[] = [
|
? input.team_name
|
||||||
new RegExp(`"task_id"\\s*:\\s*"${escaped}"`, 'i'),
|
: typeof input.teamName === 'string'
|
||||||
new RegExp(`"taskId"\\s*:\\s*"${escaped}"`, 'i'),
|
? input.teamName
|
||||||
];
|
: typeof input.team === 'string'
|
||||||
if (numericTaskId) {
|
? input.team
|
||||||
patterns.push(
|
: null;
|
||||||
new RegExp(`"task_id"\\s*:\\s*${numericTaskId}\\b`),
|
return typeof raw === 'string' ? raw.trim() : null;
|
||||||
new RegExp(`"taskId"\\s*:\\s*${numericTaskId}\\b`),
|
};
|
||||||
// Support teamctl command lines (may appear in tool output).
|
|
||||||
// Example: node ".../teamctl.js" --team "t" task start 10
|
const matchesTeamctlCommand = (command: string): boolean => {
|
||||||
new RegExp(`\\bteamctl(?:\\.js)?\\b.{0,350}\\b${numericTaskId}\\b`, 'i')
|
if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false;
|
||||||
);
|
|
||||||
}
|
const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command);
|
||||||
|
const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim();
|
||||||
|
if (cmdTeam?.toLowerCase() !== teamLower) return false;
|
||||||
|
|
||||||
|
const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command);
|
||||||
|
const cmdTaskId = taskMatch?.[1];
|
||||||
|
return Boolean(cmdTaskId && cmdTaskId === taskIdStr);
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||||
let foundTask = false;
|
|
||||||
let foundTeam = false;
|
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
// We require BOTH taskId and teamName to avoid cross-team collisions when multiple
|
const trimmed = line.trim();
|
||||||
// teams share the same projectPath (task IDs are only unique per team).
|
if (!trimmed) continue;
|
||||||
//
|
try {
|
||||||
// But they often appear on different lines (e.g. team_name is in Task tool input, while
|
const entry = JSON.parse(trimmed) as Record<string, unknown>;
|
||||||
// taskId appears in a tool result or CLI output). So we track them independently.
|
const content = this.extractEntryContent(entry);
|
||||||
if (!foundTask && patterns.some((re) => re.test(line))) {
|
if (!Array.isArray(content)) continue;
|
||||||
foundTask = true;
|
|
||||||
}
|
for (const block of content) {
|
||||||
if (!foundTeam && teamPatterns.some((re) => re.test(line))) {
|
if (!block || typeof block !== 'object') continue;
|
||||||
foundTeam = true;
|
const b = block as Record<string, unknown>;
|
||||||
}
|
if (b.type !== 'tool_use') continue;
|
||||||
if (foundTask && foundTeam) {
|
|
||||||
rl.close();
|
const rawName = typeof b.name === 'string' ? b.name : '';
|
||||||
stream.destroy();
|
const toolName = rawName.replace(/^proxy_/, '');
|
||||||
return true;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
|
if (!input) continue;
|
||||||
|
|
||||||
|
// Deterministic structured match: any tool whose input references this task+team.
|
||||||
|
const inputTeam = extractTeamFromInput(input);
|
||||||
|
const rawTaskId = input.taskId ?? input.task_id;
|
||||||
|
const inputTaskId = extractTaskIdFromUnknown(rawTaskId);
|
||||||
|
if (
|
||||||
|
inputTeam?.toLowerCase() === teamLower &&
|
||||||
|
inputTaskId &&
|
||||||
|
inputTaskId === taskIdStr
|
||||||
|
) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deterministic CLI match: teamctl command line (Bash tool).
|
||||||
|
if (toolName === 'Bash') {
|
||||||
|
const command = typeof input.command === 'string' ? input.command : '';
|
||||||
|
if (command && matchesTeamctlCommand(command)) {
|
||||||
|
rl.close();
|
||||||
|
stream.destroy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore parse errors
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rl.close();
|
rl.close();
|
||||||
|
|
@ -528,6 +562,13 @@ export class TeamMemberLogsFinder {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private extractEntryContent(entry: Record<string, unknown>): unknown[] | null {
|
||||||
|
const message = entry.message as Record<string, unknown> | undefined;
|
||||||
|
if (message && Array.isArray(message.content)) return message.content as unknown[];
|
||||||
|
if (Array.isArray(entry.content)) return entry.content as unknown[];
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private async listSessionDirs(projectDir: string): Promise<string[]> {
|
private async listSessionDirs(projectDir: string): Promise<string[]> {
|
||||||
try {
|
try {
|
||||||
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue