perf(team): cache board task activity records
This commit is contained in:
parent
6707cc3b60
commit
ea25e6ba58
8 changed files with 340 additions and 82 deletions
|
|
@ -5,16 +5,62 @@ import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder
|
||||||
import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader';
|
import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader';
|
||||||
|
|
||||||
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
|
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
|
||||||
|
import type { TeamTask } from '@shared/types';
|
||||||
|
|
||||||
|
const TASK_ACTIVITY_INDEX_CACHE_TTL_MS = 1_000;
|
||||||
|
|
||||||
|
interface TaskActivityIndex {
|
||||||
|
expiresAt: number;
|
||||||
|
tasksById: Map<string, TeamTask>;
|
||||||
|
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||||
|
}
|
||||||
|
|
||||||
export class BoardTaskActivityRecordSource {
|
export class BoardTaskActivityRecordSource {
|
||||||
|
private readonly indexCache = new Map<string, TaskActivityIndex>();
|
||||||
|
private readonly indexInFlight = new Map<string, Promise<TaskActivityIndex>>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
|
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
|
||||||
private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder()
|
private readonly recordBuilder: Pick<
|
||||||
|
BoardTaskActivityRecordBuilder,
|
||||||
|
'buildForTasks'
|
||||||
|
> = new BoardTaskActivityRecordBuilder()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getTaskRecords(teamName: string, taskId: string): Promise<BoardTaskActivityRecord[]> {
|
async getTaskRecords(teamName: string, taskId: string): Promise<BoardTaskActivityRecord[]> {
|
||||||
|
const index = await this.getTaskActivityIndex(teamName);
|
||||||
|
if (!index.tasksById.has(taskId)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return [...(index.recordsByTaskId.get(taskId) ?? [])];
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||||
|
const cached = this.indexCache.get(teamName);
|
||||||
|
if (cached && cached.expiresAt > Date.now()) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPromise = this.indexInFlight.get(teamName);
|
||||||
|
if (existingPromise) {
|
||||||
|
return await existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promise = this.buildTaskActivityIndex(teamName)
|
||||||
|
.then((index) => {
|
||||||
|
this.indexCache.set(teamName, index);
|
||||||
|
return index;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
this.indexInFlight.delete(teamName);
|
||||||
|
});
|
||||||
|
this.indexInFlight.set(teamName, promise);
|
||||||
|
return await promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async buildTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||||
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
||||||
this.taskReader.getTasks(teamName),
|
this.taskReader.getTasks(teamName),
|
||||||
this.taskReader.getDeletedTasks(teamName),
|
this.taskReader.getDeletedTasks(teamName),
|
||||||
|
|
@ -22,17 +68,25 @@ export class BoardTaskActivityRecordSource {
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const tasks = [...activeTasks, ...deletedTasks];
|
const tasks = [...activeTasks, ...deletedTasks];
|
||||||
const targetTask = tasks.find((task) => task.id === taskId);
|
const tasksById = new Map(tasks.map((task) => [task.id, task] as const));
|
||||||
if (!targetTask || transcriptFiles.length === 0) {
|
if (tasks.length === 0 || transcriptFiles.length === 0) {
|
||||||
return [];
|
return {
|
||||||
|
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||||
|
tasksById,
|
||||||
|
recordsByTaskId: new Map(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const messages = await this.transcriptReader.readFiles(transcriptFiles);
|
const messages = await this.transcriptReader.readFiles(transcriptFiles);
|
||||||
return this.recordBuilder.buildForTask({
|
const recordsByTaskId = this.recordBuilder.buildForTasks({
|
||||||
teamName,
|
teamName,
|
||||||
targetTask,
|
|
||||||
tasks,
|
tasks,
|
||||||
messages,
|
messages,
|
||||||
});
|
});
|
||||||
|
return {
|
||||||
|
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||||
|
tasksById,
|
||||||
|
recordsByTaskId,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -123,6 +123,12 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
lineCount += 1;
|
lineCount += 1;
|
||||||
|
if (!line.includes('"boardTaskLinks"')) {
|
||||||
|
if (lineCount % 500 === 0) {
|
||||||
|
await yieldToEventLoop();
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(line) as unknown;
|
const parsed = JSON.parse(line) as unknown;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import type { TeamConfig } from '@shared/types';
|
||||||
const logger = createLogger('Service:TeamTranscriptSourceLocator');
|
const logger = createLogger('Service:TeamTranscriptSourceLocator');
|
||||||
const TRANSCRIPT_DISCOVERY_WARN_MS = 3_000;
|
const TRANSCRIPT_DISCOVERY_WARN_MS = 3_000;
|
||||||
const TRANSCRIPT_DISCOVERY_FILE_COUNT_WARN = 500;
|
const TRANSCRIPT_DISCOVERY_FILE_COUNT_WARN = 500;
|
||||||
|
const TRANSCRIPT_DISCOVERY_SESSION_CONCURRENCY = process.platform === 'win32' ? 4 : 8;
|
||||||
|
|
||||||
export interface TeamTranscriptSourceContext {
|
export interface TeamTranscriptSourceContext {
|
||||||
projectDir: string;
|
projectDir: string;
|
||||||
|
|
@ -19,6 +20,28 @@ export interface TeamTranscriptSourceContext {
|
||||||
transcriptFiles: string[];
|
transcriptFiles: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function mapLimit<T, R>(
|
||||||
|
items: readonly T[],
|
||||||
|
limit: number,
|
||||||
|
fn: (item: T) => Promise<R>
|
||||||
|
): Promise<R[]> {
|
||||||
|
const results = new Array<R>(items.length);
|
||||||
|
let index = 0;
|
||||||
|
const workerCount = Math.max(1, Math.min(limit, items.length));
|
||||||
|
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||||
|
while (true) {
|
||||||
|
const currentIndex = index;
|
||||||
|
index += 1;
|
||||||
|
if (currentIndex >= items.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
results[currentIndex] = await fn(items[currentIndex]!);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
await Promise.all(workers);
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamTranscriptSourceLocator {
|
export class TeamTranscriptSourceLocator {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver()
|
private readonly projectResolver: TeamTranscriptProjectResolver = new TeamTranscriptProjectResolver()
|
||||||
|
|
@ -55,29 +78,41 @@ export class TeamTranscriptSourceLocator {
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const transcriptFiles = new Set<string>();
|
const transcriptFiles = new Set<string>();
|
||||||
|
|
||||||
for (const sessionId of sessionIds) {
|
const filesBySession = await mapLimit(
|
||||||
const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`);
|
sessionIds,
|
||||||
try {
|
TRANSCRIPT_DISCOVERY_SESSION_CONCURRENCY,
|
||||||
const stat = await fs.stat(mainTranscript);
|
async (sessionId) => {
|
||||||
if (stat.isFile()) {
|
const sessionFiles: string[] = [];
|
||||||
transcriptFiles.add(mainTranscript);
|
const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`);
|
||||||
|
try {
|
||||||
|
const stat = await fs.stat(mainTranscript);
|
||||||
|
if (stat.isFile()) {
|
||||||
|
sessionFiles.push(mainTranscript);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore missing root transcript
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore missing root transcript
|
|
||||||
}
|
|
||||||
|
|
||||||
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
|
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
|
||||||
try {
|
try {
|
||||||
const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true });
|
const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true });
|
||||||
for (const entry of dirEntries) {
|
for (const entry of dirEntries) {
|
||||||
if (!entry.isFile()) continue;
|
if (!entry.isFile()) continue;
|
||||||
if (!entry.name.endsWith('.jsonl')) continue;
|
if (!entry.name.endsWith('.jsonl')) continue;
|
||||||
if (!entry.name.startsWith('agent-')) continue;
|
if (!entry.name.startsWith('agent-')) continue;
|
||||||
if (entry.name.startsWith('agent-acompact')) continue;
|
if (entry.name.startsWith('agent-acompact')) continue;
|
||||||
transcriptFiles.add(path.join(subagentsDir, entry.name));
|
sessionFiles.push(path.join(subagentsDir, entry.name));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore missing subagent dir
|
||||||
}
|
}
|
||||||
} catch {
|
return sessionFiles;
|
||||||
// ignore missing subagent dir
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const sessionFiles of filesBySession) {
|
||||||
|
for (const filePath of sessionFiles) {
|
||||||
|
transcriptFiles.add(filePath);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
import { yieldToEventLoop } from '@main/utils/asyncYield';
|
||||||
import { parseJsonlLine } from '@main/utils/jsonl';
|
import { parseJsonlEntry } from '@main/utils/jsonl';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
|
|
@ -8,6 +8,7 @@ import * as readline from 'readline';
|
||||||
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
|
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
|
||||||
|
|
||||||
import type { ParsedMessage } from '@main/types';
|
import type { ParsedMessage } from '@main/types';
|
||||||
|
import type { ChatHistoryEntry } from '@main/types';
|
||||||
|
|
||||||
const logger = createLogger('Service:BoardTaskExactLogStrictParser');
|
const logger = createLogger('Service:BoardTaskExactLogStrictParser');
|
||||||
const EXACT_LOG_PARSE_CONCURRENCY = process.platform === 'win32' ? 4 : 8;
|
const EXACT_LOG_PARSE_CONCURRENCY = process.platform === 'win32' ? 4 : 8;
|
||||||
|
|
@ -123,7 +124,7 @@ export class BoardTaskExactLogStrictParser {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsed = parseJsonlLine(line);
|
const parsed = parseJsonlEntry(record as unknown as ChatHistoryEntry);
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
results.push(parsed);
|
results.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,10 @@ export function parseJsonlLine(line: string): ParsedMessage | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
const entry = JSON.parse(normalized) as ChatHistoryEntry;
|
const entry = JSON.parse(normalized) as ChatHistoryEntry;
|
||||||
|
return parseJsonlEntry(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseJsonlEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
return parseChatHistoryEntry(entry);
|
return parseChatHistoryEntry(entry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ describe('BoardTaskActivityRecordSource', () => {
|
||||||
readFiles: vi.fn(async () => rawMessages),
|
readFiles: vi.fn(async () => rawMessages),
|
||||||
};
|
};
|
||||||
const recordBuilder = {
|
const recordBuilder = {
|
||||||
buildForTask: vi.fn(() => builtRecords),
|
buildForTasks: vi.fn(() => new Map([['task-a', builtRecords]])),
|
||||||
};
|
};
|
||||||
|
|
||||||
const source = new BoardTaskActivityRecordSource(
|
const source = new BoardTaskActivityRecordSource(
|
||||||
|
|
@ -43,12 +43,12 @@ describe('BoardTaskActivityRecordSource', () => {
|
||||||
|
|
||||||
const result = await source.getTaskRecords('demo', 'task-a');
|
const result = await source.getTaskRecords('demo', 'task-a');
|
||||||
|
|
||||||
expect(result).toBe(builtRecords);
|
expect(result).toEqual(builtRecords);
|
||||||
|
expect(result).not.toBe(builtRecords);
|
||||||
expect(locator.listTranscriptFiles).toHaveBeenCalledWith('demo');
|
expect(locator.listTranscriptFiles).toHaveBeenCalledWith('demo');
|
||||||
expect(transcriptReader.readFiles).toHaveBeenCalledWith(transcriptFiles);
|
expect(transcriptReader.readFiles).toHaveBeenCalledWith(transcriptFiles);
|
||||||
expect(recordBuilder.buildForTask).toHaveBeenCalledWith({
|
expect(recordBuilder.buildForTasks).toHaveBeenCalledWith({
|
||||||
teamName: 'demo',
|
teamName: 'demo',
|
||||||
targetTask,
|
|
||||||
tasks: [targetTask, deletedTask],
|
tasks: [targetTask, deletedTask],
|
||||||
messages: rawMessages,
|
messages: rawMessages,
|
||||||
});
|
});
|
||||||
|
|
@ -66,7 +66,7 @@ describe('BoardTaskActivityRecordSource', () => {
|
||||||
readFiles: vi.fn(async () => [{ uuid: 'm1' }]),
|
readFiles: vi.fn(async () => [{ uuid: 'm1' }]),
|
||||||
};
|
};
|
||||||
const recordBuilder = {
|
const recordBuilder = {
|
||||||
buildForTask: vi.fn(() => [{ id: 'r1' }]),
|
buildForTasks: vi.fn(() => new Map([['task-known', [{ id: 'r1' }]]])),
|
||||||
};
|
};
|
||||||
|
|
||||||
const source = new BoardTaskActivityRecordSource(
|
const source = new BoardTaskActivityRecordSource(
|
||||||
|
|
@ -77,6 +77,63 @@ describe('BoardTaskActivityRecordSource', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
await expect(source.getTaskRecords('demo', 'task-missing')).resolves.toEqual([]);
|
await expect(source.getTaskRecords('demo', 'task-missing')).resolves.toEqual([]);
|
||||||
expect(recordBuilder.buildForTask).not.toHaveBeenCalled();
|
expect(recordBuilder.buildForTasks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one in-flight team index across concurrent task lookups', async () => {
|
||||||
|
const taskA = {
|
||||||
|
id: 'task-a',
|
||||||
|
displayId: 'aaaa1111',
|
||||||
|
subject: 'A',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
const taskB = {
|
||||||
|
id: 'task-b',
|
||||||
|
displayId: 'bbbb2222',
|
||||||
|
subject: 'B',
|
||||||
|
status: 'pending',
|
||||||
|
};
|
||||||
|
const transcriptFiles = ['/tmp/a.jsonl'];
|
||||||
|
const rawMessages = [{ uuid: 'm1' }];
|
||||||
|
const recordsA = [{ id: 'record-a' }];
|
||||||
|
const recordsB = [{ id: 'record-b' }];
|
||||||
|
|
||||||
|
let resolveReadFiles: (messages: typeof rawMessages) => void = () => undefined;
|
||||||
|
const readFilesPromise = new Promise<typeof rawMessages>((resolve) => {
|
||||||
|
resolveReadFiles = resolve;
|
||||||
|
});
|
||||||
|
const locator = {
|
||||||
|
listTranscriptFiles: vi.fn(async () => transcriptFiles),
|
||||||
|
};
|
||||||
|
const taskReader = {
|
||||||
|
getTasks: vi.fn(async () => [taskA, taskB]),
|
||||||
|
getDeletedTasks: vi.fn(async () => []),
|
||||||
|
};
|
||||||
|
const transcriptReader = {
|
||||||
|
readFiles: vi.fn(() => readFilesPromise),
|
||||||
|
};
|
||||||
|
const recordBuilder = {
|
||||||
|
buildForTasks: vi.fn(() => new Map([
|
||||||
|
['task-a', recordsA],
|
||||||
|
['task-b', recordsB],
|
||||||
|
])),
|
||||||
|
};
|
||||||
|
|
||||||
|
const source = new BoardTaskActivityRecordSource(
|
||||||
|
locator as never,
|
||||||
|
taskReader as never,
|
||||||
|
transcriptReader as never,
|
||||||
|
recordBuilder as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskAResult = source.getTaskRecords('demo', 'task-a');
|
||||||
|
const taskBResult = source.getTaskRecords('demo', 'task-b');
|
||||||
|
resolveReadFiles(rawMessages);
|
||||||
|
|
||||||
|
await expect(taskAResult).resolves.toEqual(recordsA);
|
||||||
|
await expect(taskBResult).resolves.toEqual(recordsB);
|
||||||
|
expect(locator.listTranscriptFiles).toHaveBeenCalledTimes(1);
|
||||||
|
expect(transcriptReader.readFiles).toHaveBeenCalledTimes(1);
|
||||||
|
expect(recordBuilder.buildForTasks).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,67 +1,97 @@
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
|
||||||
|
|
||||||
import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
|
import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
|
||||||
|
|
||||||
const tempPaths: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
|
|
||||||
async function createTempTranscript(lines: unknown[]): Promise<string> {
|
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'board-task-activity-'));
|
|
||||||
const filePath = path.join(dir, 'transcript.jsonl');
|
|
||||||
tempPaths.push(dir);
|
|
||||||
await fs.writeFile(
|
|
||||||
filePath,
|
|
||||||
lines.map(line => JSON.stringify(line)).join('\n'),
|
|
||||||
'utf8',
|
|
||||||
);
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
tempPaths.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })),
|
tempDirs.splice(0, tempDirs.length).map(async (dirPath) => {
|
||||||
|
await fs.rm(dirPath, { recursive: true, force: true });
|
||||||
|
})
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('BoardTaskActivityTranscriptReader', () => {
|
describe('BoardTaskActivityTranscriptReader', () => {
|
||||||
it('skips transcript rows without a stable timestamp', async () => {
|
it('skips non-board and malformed rows while preserving task-linked activity rows', async () => {
|
||||||
const filePath = await createTempTranscript([
|
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-transcript-reader-'));
|
||||||
{
|
tempDirs.push(tempDir);
|
||||||
uuid: 'missing-timestamp',
|
|
||||||
sessionId: 'session-1',
|
const filePath = path.join(tempDir, 'session.jsonl');
|
||||||
boardTaskLinks: [
|
await fs.writeFile(
|
||||||
{
|
filePath,
|
||||||
schemaVersion: 1,
|
[
|
||||||
task: { ref: 'abcd1234', refKind: 'display' },
|
'{not-json',
|
||||||
targetRole: 'subject',
|
JSON.stringify({
|
||||||
linkKind: 'execution',
|
uuid: 'ordinary-message',
|
||||||
actorContext: { relation: 'same_task' },
|
sessionId: 'session-a',
|
||||||
},
|
timestamp: '2026-04-20T12:00:00.000Z',
|
||||||
],
|
message: { role: 'assistant', content: 'No board task links here' },
|
||||||
},
|
}),
|
||||||
{
|
'{"boardTaskLinks":',
|
||||||
uuid: 'valid-row',
|
JSON.stringify({
|
||||||
timestamp: '2026-04-12T10:00:00.000Z',
|
uuid: 'linked-message',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-a',
|
||||||
boardTaskLinks: [
|
timestamp: '2026-04-20T12:01:00.000Z',
|
||||||
{
|
agentId: 'agent-a',
|
||||||
schemaVersion: 1,
|
agentName: 'alice',
|
||||||
task: { ref: 'abcd1234', refKind: 'display' },
|
isSidechain: true,
|
||||||
targetRole: 'subject',
|
boardTaskLinks: [
|
||||||
linkKind: 'execution',
|
{
|
||||||
actorContext: { relation: 'same_task' },
|
schemaVersion: 1,
|
||||||
},
|
task: { ref: '12345678', refKind: 'display', canonicalId: 'task-a' },
|
||||||
],
|
targetRole: 'subject',
|
||||||
},
|
linkKind: 'execution',
|
||||||
]);
|
actorContext: { relation: 'same_task' },
|
||||||
|
toolUseId: 'toolu_1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
boardTaskToolActions: [
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
toolUseId: 'toolu_1',
|
||||||
|
canonicalToolName: 'task_set_status',
|
||||||
|
input: { status: 'in_progress' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
].join('\n'),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]);
|
const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]);
|
||||||
|
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0]?.uuid).toBe('valid-row');
|
expect(rows[0]).toMatchObject({
|
||||||
expect(rows[0]?.timestamp).toBe('2026-04-12T10:00:00.000Z');
|
filePath,
|
||||||
|
uuid: 'linked-message',
|
||||||
|
sessionId: 'session-a',
|
||||||
|
timestamp: '2026-04-20T12:01:00.000Z',
|
||||||
|
agentId: 'agent-a',
|
||||||
|
agentName: 'alice',
|
||||||
|
isSidechain: true,
|
||||||
|
sourceOrder: 1,
|
||||||
|
boardTaskLinks: [
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
toolUseId: 'toolu_1',
|
||||||
|
task: { ref: '12345678', refKind: 'display', canonicalId: 'task-a' },
|
||||||
|
targetRole: 'subject',
|
||||||
|
linkKind: 'execution',
|
||||||
|
actorContext: { relation: 'same_task' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
boardTaskToolActions: [
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
toolUseId: 'toolu_1',
|
||||||
|
canonicalToolName: 'task_set_status',
|
||||||
|
input: { status: 'in_progress' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -111,4 +111,75 @@ describe('TeamTranscriptSourceLocator', () => {
|
||||||
);
|
);
|
||||||
expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl'));
|
expect(transcriptFiles).not.toContain(path.join(projectRoot, 'unrelated-session.jsonl'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns the same sorted transcript set across multiple session directories', async () => {
|
||||||
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-transcripts-'));
|
||||||
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
||||||
|
const teamName = 'bounded-discovery-test';
|
||||||
|
const projectPath = '/Users/test/bounded-discovery';
|
||||||
|
const projectId = '-Users-test-bounded-discovery';
|
||||||
|
const sessionIds = Array.from({ length: 12 }, (_, index) => `member-${index + 1}`);
|
||||||
|
|
||||||
|
await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(tmpDir, 'teams', teamName, 'config.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: teamName,
|
||||||
|
projectPath,
|
||||||
|
members: sessionIds.map((sessionId, index) => ({
|
||||||
|
name: `member-${index + 1}`,
|
||||||
|
agentType: 'general-purpose',
|
||||||
|
sessionId,
|
||||||
|
cwd: projectPath,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
const projectRoot = path.join(tmpDir, 'projects', projectId);
|
||||||
|
const expectedFiles: string[] = [];
|
||||||
|
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
const rootTranscript = path.join(projectRoot, `${sessionId}.jsonl`);
|
||||||
|
const subagentsDir = path.join(projectRoot, sessionId, 'subagents');
|
||||||
|
const subagentTranscript = path.join(subagentsDir, 'agent-worker.jsonl');
|
||||||
|
|
||||||
|
await fs.mkdir(subagentsDir, { recursive: true });
|
||||||
|
await fs.writeFile(
|
||||||
|
rootTranscript,
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: '2026-04-15T14:02:00.000Z',
|
||||||
|
type: 'user',
|
||||||
|
teamName,
|
||||||
|
message: { role: 'user', content: `Bootstrap ${sessionId} for ${teamName}` },
|
||||||
|
}) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
subagentTranscript,
|
||||||
|
JSON.stringify({
|
||||||
|
timestamp: '2026-04-15T14:02:01.000Z',
|
||||||
|
type: 'user',
|
||||||
|
message: { role: 'user', content: `Subagent for ${sessionId}` },
|
||||||
|
}) + '\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
await fs.writeFile(
|
||||||
|
path.join(subagentsDir, 'agent-acompact-ignore.jsonl'),
|
||||||
|
'{}\n',
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
|
||||||
|
expectedFiles.push(rootTranscript, subagentTranscript);
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcriptFiles = await new TeamTranscriptSourceLocator().listTranscriptFiles(teamName);
|
||||||
|
|
||||||
|
expect(transcriptFiles).toEqual([...expectedFiles].sort((a, b) => a.localeCompare(b)));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue