Merge remote-tracking branch 'origin/dev' into spike/team-snapshot-split-plan
This commit is contained in:
commit
dac7b4f875
14 changed files with 510 additions and 36 deletions
|
|
@ -36,6 +36,7 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -156,6 +157,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
|
|
@ -562,6 +564,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
||||||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
|
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
|
||||||
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
|
ipcMain.handle(TEAM_GET_TASK_ACTIVITY_DETAIL, handleGetTaskActivityDetail);
|
||||||
|
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM_SUMMARY, handleGetTaskLogStreamSummary);
|
||||||
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
|
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
|
||||||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
|
||||||
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
|
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
|
||||||
|
|
@ -638,6 +641,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
|
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY_DETAIL);
|
||||||
|
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM_SUMMARY);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
|
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
|
||||||
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
|
||||||
|
|
@ -2752,6 +2756,24 @@ async function handleGetTaskLogStream(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleGetTaskLogStreamSummary(
|
||||||
|
_event: IpcMainInvokeEvent,
|
||||||
|
teamName: unknown,
|
||||||
|
taskId: unknown
|
||||||
|
): Promise<IpcResult<BoardTaskLogStreamSummary>> {
|
||||||
|
const vTeam = validateTeamName(teamName);
|
||||||
|
if (!vTeam.valid) {
|
||||||
|
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
|
||||||
|
}
|
||||||
|
const vTask = validateTaskId(taskId);
|
||||||
|
if (!vTask.valid) {
|
||||||
|
return { success: false, error: vTask.error ?? 'Invalid taskId' };
|
||||||
|
}
|
||||||
|
return wrapTeamHandler('getTaskLogStreamSummary', () =>
|
||||||
|
getBoardTaskLogStreamService().getTaskLogStreamSummary(vTeam.value!, vTask.value!)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGetTaskExactLogSummaries(
|
async function handleGetTaskExactLogSummaries(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown,
|
teamName: unknown,
|
||||||
|
|
|
||||||
|
|
@ -98,6 +98,7 @@ import {
|
||||||
} from './idleNotificationMainProcessSemantics';
|
} from './idleNotificationMainProcessSemantics';
|
||||||
import { withInboxLock } from './inboxLock';
|
import { withInboxLock } from './inboxLock';
|
||||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||||
|
import { buildProgressAssistantOutput, buildProgressLogsTail } from './progressPayload';
|
||||||
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
|
import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode';
|
||||||
import {
|
import {
|
||||||
choosePreferredLaunchSnapshot,
|
choosePreferredLaunchSnapshot,
|
||||||
|
|
@ -206,7 +207,13 @@ const VERIFY_TIMEOUT_MS = 15_000;
|
||||||
const VERIFY_POLL_MS = 500;
|
const VERIFY_POLL_MS = 500;
|
||||||
const STDERR_RING_LIMIT = 64 * 1024;
|
const STDERR_RING_LIMIT = 64 * 1024;
|
||||||
const STDOUT_RING_LIMIT = 64 * 1024;
|
const STDOUT_RING_LIMIT = 64 * 1024;
|
||||||
const LOG_PROGRESS_THROTTLE_MS = 300;
|
// Progress emissions fan out the latest CLI tail + assistant output to the
|
||||||
|
// renderer over IPC. Under load the previous 300ms cadence combined with an
|
||||||
|
// unbounded payload (see `emitLogsProgress`) caused renderer OOM crashes
|
||||||
|
// (≈3 full-history serializations per second, each holding thousands of
|
||||||
|
// lines). The tail cap in `emitLogsProgress` bounds each payload; we also
|
||||||
|
// slow the cadence to ~1s so Zustand can keep up on large teams.
|
||||||
|
const LOG_PROGRESS_THROTTLE_MS = 1000;
|
||||||
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
const UI_LOGS_TAIL_LIMIT = 128 * 1024;
|
||||||
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000;
|
||||||
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
|
|
@ -2223,10 +2230,12 @@ function updateProgress(
|
||||||
'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity'
|
'pid' | 'error' | 'warnings' | 'cliLogsTail' | 'configReady' | 'messageSeverity'
|
||||||
>
|
>
|
||||||
): TeamProvisioningProgress {
|
): TeamProvisioningProgress {
|
||||||
|
// Cap assistant output on every progress tick. `updateProgress` is invoked
|
||||||
|
// from ~20 event-driven sites (auth retries, stall warnings, spawn events),
|
||||||
|
// and an unbounded `provisioningOutputParts.join` was part of the same OOM
|
||||||
|
// class that `emitLogsProgress` already guards against.
|
||||||
const assistantOutput =
|
const assistantOutput =
|
||||||
run.provisioningOutputParts.length > 0
|
buildProgressAssistantOutput(run.provisioningOutputParts) ?? run.progress.assistantOutput;
|
||||||
? run.provisioningOutputParts.join('\n\n')
|
|
||||||
: run.progress.assistantOutput;
|
|
||||||
run.progress = {
|
run.progress = {
|
||||||
...run.progress,
|
...run.progress,
|
||||||
state,
|
state,
|
||||||
|
|
@ -2357,10 +2366,22 @@ function extractCliLogsFromRun(run: ProvisioningRun): string | undefined {
|
||||||
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
|
return extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emit a throttled progress update for the renderer. Payloads are capped to a
|
||||||
|
* tail window so that the hot emission path (called every LOG_PROGRESS_THROTTLE_MS
|
||||||
|
* under streaming output) cannot accumulate into multi-megabyte IPC messages
|
||||||
|
* that would OOM the renderer's Zustand state. The full history stays in
|
||||||
|
* `run.claudeLogLines` / `run.provisioningOutputParts` for diagnostics and
|
||||||
|
* one-shot completion emissions that intentionally use `extractCliLogsFromRun`.
|
||||||
|
*/
|
||||||
function emitLogsProgress(run: ProvisioningRun): void {
|
function emitLogsProgress(run: ProvisioningRun): void {
|
||||||
const logsTail = extractCliLogsFromRun(run);
|
// Prefer the line-buffered history (already chronological with [stdout]/[stderr]
|
||||||
const assistantOutput =
|
// markers) and fall back to the legacy ring-buffer tail only when no lines
|
||||||
run.provisioningOutputParts.length > 0 ? run.provisioningOutputParts.join('\n\n') : undefined;
|
// have been captured yet (early in provisioning).
|
||||||
|
const logsTail =
|
||||||
|
buildProgressLogsTail(run.claudeLogLines) ??
|
||||||
|
extractLogsTail(run.stdoutBuffer, run.stderrBuffer);
|
||||||
|
const assistantOutput = buildProgressAssistantOutput(run.provisioningOutputParts);
|
||||||
|
|
||||||
if (!logsTail && !assistantOutput) {
|
if (!logsTail && !assistantOutput) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -5113,7 +5134,9 @@ export class TeamProvisioningService {
|
||||||
message: this.buildStallProgressMessage(silenceSec, elapsed),
|
message: this.buildStallProgressMessage(silenceSec, elapsed),
|
||||||
messageSeverity: 'warning' as const,
|
messageSeverity: 'warning' as const,
|
||||||
}),
|
}),
|
||||||
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
assistantOutput:
|
||||||
|
buildProgressAssistantOutput(run.provisioningOutputParts) ??
|
||||||
|
run.progress.assistantOutput,
|
||||||
};
|
};
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -9649,7 +9672,9 @@ export class TeamProvisioningService {
|
||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
message: retryText,
|
message: retryText,
|
||||||
messageSeverity: 'error' as const,
|
messageSeverity: 'error' as const,
|
||||||
assistantOutput: run.provisioningOutputParts.join('\n\n'),
|
assistantOutput:
|
||||||
|
buildProgressAssistantOutput(run.provisioningOutputParts) ??
|
||||||
|
run.progress.assistantOutput,
|
||||||
};
|
};
|
||||||
run.onProgress(run.progress);
|
run.onProgress(run.progress);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
src/main/services/team/progressPayload.ts
Normal file
52
src/main/services/team/progressPayload.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Helpers that shape provisioning progress payloads before they are emitted
|
||||||
|
* to the renderer over IPC.
|
||||||
|
*
|
||||||
|
* Rationale: the renderer only renders a small "tail" preview of CLI logs
|
||||||
|
* and assistant output in ProvisioningProgressBlock / CliLogsRichView. Sending
|
||||||
|
* the full accumulated history on every throttled progress tick (≈ every
|
||||||
|
* second under load) serialized a multi-megabyte string over IPC and forced
|
||||||
|
* Zustand to produce a new immutable state object — which triggered renderer
|
||||||
|
* V8 OOM crashes for users with long-running teams. These helpers keep the
|
||||||
|
* hot emission path bounded while leaving the full history in-process for
|
||||||
|
* diagnostics and completion-time reports.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||||
|
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
||||||
|
* and trimmed. Returns `undefined` when the tail is empty so callers can
|
||||||
|
* skip emitting a noop update.
|
||||||
|
*/
|
||||||
|
export function buildProgressLogsTail(
|
||||||
|
lines: readonly string[],
|
||||||
|
maxLines: number = PROGRESS_LOG_TAIL_LINES
|
||||||
|
): string | undefined {
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const effectiveMax = Math.max(1, maxLines);
|
||||||
|
const tail = lines.length > effectiveMax ? lines.slice(-effectiveMax) : lines;
|
||||||
|
const joined = tail.join('\n').trim();
|
||||||
|
return joined.length === 0 ? undefined : joined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the trailing `maxParts` of assistant output parts joined with a
|
||||||
|
* blank line, matching the renderer's rendering contract. Returns `undefined`
|
||||||
|
* when no parts are available.
|
||||||
|
*/
|
||||||
|
export function buildProgressAssistantOutput(
|
||||||
|
parts: readonly string[],
|
||||||
|
maxParts: number = PROGRESS_OUTPUT_TAIL_PARTS
|
||||||
|
): string | undefined {
|
||||||
|
if (parts.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const effectiveMax = Math.max(1, maxParts);
|
||||||
|
const tail = parts.length > effectiveMax ? parts.slice(-effectiveMax) : parts;
|
||||||
|
const joined = tail.join('\n\n');
|
||||||
|
return joined.trim().length === 0 ? undefined : joined;
|
||||||
|
}
|
||||||
|
|
@ -19,6 +19,7 @@ import type {
|
||||||
BoardTaskLogParticipant,
|
BoardTaskLogParticipant,
|
||||||
BoardTaskLogSegment,
|
BoardTaskLogSegment,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
TeamTask,
|
TeamTask,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -47,6 +48,11 @@ interface TimeWindow {
|
||||||
endMs: number | null;
|
endMs: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface StreamLayout {
|
||||||
|
participants: BoardTaskLogParticipant[];
|
||||||
|
visibleSlices: StreamSlice[];
|
||||||
|
}
|
||||||
|
|
||||||
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
|
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
|
||||||
const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000;
|
const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000;
|
||||||
const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000;
|
const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000;
|
||||||
|
|
@ -61,6 +67,12 @@ function emptyResponse(): BoardTaskLogStreamResponse {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emptySummary(): BoardTaskLogStreamSummary {
|
||||||
|
return {
|
||||||
|
segmentCount: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeMemberName(value: string): string {
|
function normalizeMemberName(value: string): string {
|
||||||
return value.trim().toLowerCase();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
@ -1018,6 +1030,46 @@ function compareSlices(left: StreamSlice, right: StreamSlice): number {
|
||||||
return left.id.localeCompare(right.id);
|
return left.id.localeCompare(right.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildOrderedParticipants(visibleSlices: StreamSlice[]): BoardTaskLogParticipant[] {
|
||||||
|
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
|
||||||
|
const participantOrder: string[] = [];
|
||||||
|
|
||||||
|
for (const slice of visibleSlices) {
|
||||||
|
if (participantsByKey.has(slice.participantKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
participantsByKey.set(
|
||||||
|
slice.participantKey,
|
||||||
|
buildParticipant(slice.actor, slice.participantKey)
|
||||||
|
);
|
||||||
|
participantOrder.push(slice.participantKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return participantOrder
|
||||||
|
.map((key) => participantsByKey.get(key))
|
||||||
|
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (left.isLead && !right.isLead) return 1;
|
||||||
|
if (!left.isLead && right.isLead) return -1;
|
||||||
|
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number {
|
||||||
|
if (visibleSlices.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
let segmentCount = 1;
|
||||||
|
for (let index = 1; index < visibleSlices.length; index += 1) {
|
||||||
|
if (visibleSlices[index]?.participantKey !== visibleSlices[index - 1]?.participantKey) {
|
||||||
|
segmentCount += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentCount;
|
||||||
|
}
|
||||||
|
|
||||||
export class BoardTaskLogStreamService {
|
export class BoardTaskLogStreamService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
||||||
|
|
@ -1131,14 +1183,20 @@ export class BoardTaskLogStreamService {
|
||||||
return inferredSlices.sort(compareSlices);
|
return inferredSlices.sort(compareSlices);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
|
private async buildStreamLayout(teamName: string, taskId: string): Promise<StreamLayout> {
|
||||||
if (!isBoardTaskExactLogsReadEnabled()) {
|
if (!isBoardTaskExactLogsReadEnabled()) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
const records = await this.recordSource.getTaskRecords(teamName, taskId);
|
||||||
if (records.length === 0) {
|
if (records.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
|
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
|
||||||
|
|
@ -1154,7 +1212,10 @@ export class BoardTaskLogStreamService {
|
||||||
.sort(compareCandidates);
|
.sort(compareCandidates);
|
||||||
|
|
||||||
if (candidates.length === 0) {
|
if (candidates.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedMessagesByFile = await this.strictParser.parseFiles(
|
const parsedMessagesByFile = await this.strictParser.parseFiles(
|
||||||
|
|
@ -1202,7 +1263,10 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (slices.length === 0) {
|
if (slices.length === 0) {
|
||||||
return emptyResponse();
|
return {
|
||||||
|
participants: [],
|
||||||
|
visibleSlices: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const inferredExecutionSlices = await this.buildInferredExecutionSlices(
|
const inferredExecutionSlices = await this.buildInferredExecutionSlices(
|
||||||
|
|
@ -1220,27 +1284,31 @@ export class BoardTaskLogStreamService {
|
||||||
const visibleSlices =
|
const visibleSlices =
|
||||||
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
|
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
|
||||||
|
|
||||||
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
|
return {
|
||||||
const participantOrder: string[] = [];
|
participants: buildOrderedParticipants(visibleSlices),
|
||||||
for (const slice of visibleSlices) {
|
visibleSlices,
|
||||||
if (participantsByKey.has(slice.participantKey)) {
|
};
|
||||||
continue;
|
}
|
||||||
}
|
|
||||||
participantsByKey.set(
|
async getTaskLogStreamSummary(
|
||||||
slice.participantKey,
|
teamName: string,
|
||||||
buildParticipant(slice.actor, slice.participantKey)
|
taskId: string
|
||||||
);
|
): Promise<BoardTaskLogStreamSummary> {
|
||||||
participantOrder.push(slice.participantKey);
|
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||||
|
if (layout.visibleSlices.length === 0) {
|
||||||
|
return emptySummary();
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderedParticipants = participantOrder
|
return {
|
||||||
.map((key) => participantsByKey.get(key))
|
segmentCount: countSegmentsFromSlices(layout.visibleSlices),
|
||||||
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
|
};
|
||||||
.sort((left, right) => {
|
}
|
||||||
if (left.isLead && !right.isLead) return 1;
|
|
||||||
if (!left.isLead && right.isLead) return -1;
|
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
|
||||||
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
|
const layout = await this.buildStreamLayout(teamName, taskId);
|
||||||
});
|
if (layout.visibleSlices.length === 0) {
|
||||||
|
return emptyResponse();
|
||||||
|
}
|
||||||
|
|
||||||
const segments: BoardTaskLogSegment[] = [];
|
const segments: BoardTaskLogSegment[] = [];
|
||||||
let currentSegmentSlices: StreamSlice[] = [];
|
let currentSegmentSlices: StreamSlice[] = [];
|
||||||
|
|
@ -1274,7 +1342,7 @@ export class BoardTaskLogStreamService {
|
||||||
currentSegmentSlices = [];
|
currentSegmentSlices = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
for (const slice of visibleSlices) {
|
for (const slice of layout.visibleSlices) {
|
||||||
if (
|
if (
|
||||||
currentSegmentSlices.length > 0 &&
|
currentSegmentSlices.length > 0 &&
|
||||||
currentSegmentSlices[0].participantKey !== slice.participantKey
|
currentSegmentSlices[0].participantKey !== slice.participantKey
|
||||||
|
|
@ -1285,11 +1353,11 @@ export class BoardTaskLogStreamService {
|
||||||
}
|
}
|
||||||
flushSegment();
|
flushSegment();
|
||||||
|
|
||||||
const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead);
|
const namedParticipants = layout.participants.filter((participant) => !participant.isLead);
|
||||||
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
|
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
participants: orderedParticipants,
|
participants: layout.participants,
|
||||||
defaultFilter,
|
defaultFilter,
|
||||||
segments,
|
segments,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -316,6 +316,9 @@ export const TEAM_GET_TASK_ACTIVITY_DETAIL = 'team:getTaskActivityDetail';
|
||||||
/** Get one task-scoped log stream derived from explicit board-task activity */
|
/** Get one task-scoped log stream derived from explicit board-task activity */
|
||||||
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
|
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
|
||||||
|
|
||||||
|
/** Get lightweight task log stream summary for header badges/live counters */
|
||||||
|
export const TEAM_GET_TASK_LOG_STREAM_SUMMARY = 'team:getTaskLogStreamSummary';
|
||||||
|
|
||||||
/** Get exact task-log summaries derived from explicit board-task activity records */
|
/** Get exact task-log summaries derived from explicit board-task activity records */
|
||||||
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';
|
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,7 @@ import {
|
||||||
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
TEAM_GET_TASK_EXACT_LOG_DETAIL,
|
||||||
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
TEAM_KILL_PROCESS,
|
TEAM_KILL_PROCESS,
|
||||||
TEAM_LAUNCH,
|
TEAM_LAUNCH,
|
||||||
TEAM_LEAD_ACTIVITY,
|
TEAM_LEAD_ACTIVITY,
|
||||||
|
|
@ -244,6 +245,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
ChangeStats,
|
ChangeStats,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -998,6 +1000,13 @@ const electronAPI: ElectronAPI = {
|
||||||
activityId
|
activityId
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
getTaskLogStreamSummary: async (teamName: string, taskId: string) => {
|
||||||
|
return invokeIpcWithResult<BoardTaskLogStreamSummary>(
|
||||||
|
TEAM_GET_TASK_LOG_STREAM_SUMMARY,
|
||||||
|
teamName,
|
||||||
|
taskId
|
||||||
|
);
|
||||||
|
},
|
||||||
getTaskLogStream: async (teamName: string, taskId: string) => {
|
getTaskLogStream: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
|
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
|
||||||
TEAM_GET_TASK_LOG_STREAM,
|
TEAM_GET_TASK_LOG_STREAM,
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
ClaudeMdFileInfo,
|
ClaudeMdFileInfo,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -836,6 +837,10 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
|
console.warn('[HttpAPIClient] getTaskActivityDetail is not available in browser mode');
|
||||||
return { status: 'missing' };
|
return { status: 'missing' };
|
||||||
},
|
},
|
||||||
|
getTaskLogStreamSummary: async (): Promise<BoardTaskLogStreamSummary> => {
|
||||||
|
console.warn('[HttpAPIClient] getTaskLogStreamSummary is not available in browser mode');
|
||||||
|
return { segmentCount: 0 };
|
||||||
|
},
|
||||||
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
|
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
|
||||||
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
|
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,7 @@ export const TaskDetailDialog = ({
|
||||||
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
const [executionPreviewOnline, setExecutionPreviewOnline] = useState(false);
|
||||||
const [logsSectionOpen, setLogsSectionOpen] = useState(false);
|
const [logsSectionOpen, setLogsSectionOpen] = useState(false);
|
||||||
const [taskLogActivityActive, setTaskLogActivityActive] = useState(false);
|
const [taskLogActivityActive, setTaskLogActivityActive] = useState(false);
|
||||||
|
const [taskLogStreamCount, setTaskLogStreamCount] = useState<number | undefined>(undefined);
|
||||||
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
const [changesSectionOpen, setChangesSectionOpen] = useState(false);
|
||||||
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
const [taskChangesFiles, setTaskChangesFiles] = useState<FileChangeSummary[] | null>(null);
|
||||||
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
const [taskChangesLoading, setTaskChangesLoading] = useState(false);
|
||||||
|
|
@ -236,6 +237,7 @@ export const TaskDetailDialog = ({
|
||||||
setExecutionPreviewOnline(false);
|
setExecutionPreviewOnline(false);
|
||||||
setLogsSectionOpen(false);
|
setLogsSectionOpen(false);
|
||||||
setTaskLogActivityActive(false);
|
setTaskLogActivityActive(false);
|
||||||
|
setTaskLogStreamCount(undefined);
|
||||||
}, [open, currentTask?.id]);
|
}, [open, currentTask?.id]);
|
||||||
|
|
||||||
const [replyTo, setReplyTo] = useState<{
|
const [replyTo, setReplyTo] = useState<{
|
||||||
|
|
@ -1263,6 +1265,7 @@ export const TaskDetailDialog = ({
|
||||||
key={`task-logs:${currentTask.id}`}
|
key={`task-logs:${currentTask.id}`}
|
||||||
title="Task Logs"
|
title="Task Logs"
|
||||||
icon={<ScrollText size={14} />}
|
icon={<ScrollText size={14} />}
|
||||||
|
badge={taskLogStreamCount}
|
||||||
headerExtra={
|
headerExtra={
|
||||||
taskLogActivityActive ? (
|
taskLogActivityActive ? (
|
||||||
<OngoingIndicator size="sm" title="New task logs arriving" />
|
<OngoingIndicator size="sm" title="New task logs arriving" />
|
||||||
|
|
@ -1288,6 +1291,7 @@ export const TaskDetailDialog = ({
|
||||||
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
||||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
onPreviewOnlineChange={setExecutionPreviewOnline}
|
||||||
onTaskLogActivityChange={setTaskLogActivityActive}
|
onTaskLogActivityChange={setTaskLogActivityActive}
|
||||||
|
onTaskLogCountChange={setTaskLogStreamCount}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,11 @@ interface TaskLogsPanelProps {
|
||||||
showLeadPreview?: boolean;
|
showLeadPreview?: boolean;
|
||||||
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
onPreviewOnlineChange?: (isOnline: boolean) => void;
|
||||||
onTaskLogActivityChange?: (isActive: boolean) => void;
|
onTaskLogActivityChange?: (isActive: boolean) => void;
|
||||||
|
onTaskLogCountChange?: (count: number | undefined) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
|
const TASK_LOG_ACTIVITY_PULSE_MS = 1800;
|
||||||
|
const TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS = 350;
|
||||||
|
|
||||||
export const TaskLogsPanel = ({
|
export const TaskLogsPanel = ({
|
||||||
teamName,
|
teamName,
|
||||||
|
|
@ -40,6 +42,7 @@ export const TaskLogsPanel = ({
|
||||||
showLeadPreview = false,
|
showLeadPreview = false,
|
||||||
onPreviewOnlineChange,
|
onPreviewOnlineChange,
|
||||||
onTaskLogActivityChange,
|
onTaskLogActivityChange,
|
||||||
|
onTaskLogCountChange,
|
||||||
}: TaskLogsPanelProps): React.JSX.Element => {
|
}: TaskLogsPanelProps): React.JSX.Element => {
|
||||||
const availableTabs = useMemo<TaskLogsTab[]>(() => {
|
const availableTabs = useMemo<TaskLogsTab[]>(() => {
|
||||||
const tabs: TaskLogsTab[] = [];
|
const tabs: TaskLogsTab[] = [];
|
||||||
|
|
@ -56,9 +59,13 @@ export const TaskLogsPanel = ({
|
||||||
const defaultTab = availableTabs[0] ?? 'sessions';
|
const defaultTab = availableTabs[0] ?? 'sessions';
|
||||||
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
|
const [activeTab, setActiveTab] = useState<TaskLogsTab>(defaultTab);
|
||||||
const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false);
|
const [isTaskLogActivityActive, setIsTaskLogActivityActive] = useState(false);
|
||||||
|
const [taskLogSegmentCount, setTaskLogSegmentCount] = useState<number | null>(null);
|
||||||
const [hasOpenedContent, setHasOpenedContent] = useState(isOpen);
|
const [hasOpenedContent, setHasOpenedContent] = useState(isOpen);
|
||||||
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const countReloadTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const countRequestSeqRef = useRef(0);
|
||||||
const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream');
|
const taskLogTrackingEnabled = task.status === 'in_progress' && availableTabs.includes('stream');
|
||||||
|
const taskLogSummaryEnabled = availableTabs.includes('stream');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveTab(defaultTab);
|
setActiveTab(defaultTab);
|
||||||
|
|
@ -80,14 +87,50 @@ export const TaskLogsPanel = ({
|
||||||
onTaskLogActivityChange?.(isTaskLogActivityActive);
|
onTaskLogActivityChange?.(isTaskLogActivityActive);
|
||||||
}, [isTaskLogActivityActive, onTaskLogActivityChange]);
|
}, [isTaskLogActivityActive, onTaskLogActivityChange]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onTaskLogCountChange?.(
|
||||||
|
taskLogSegmentCount != null && taskLogSegmentCount > 0 ? taskLogSegmentCount : undefined
|
||||||
|
);
|
||||||
|
}, [onTaskLogCountChange, taskLogSegmentCount]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (pulseTimerRef.current) {
|
if (pulseTimerRef.current) {
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = null;
|
pulseTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (countReloadTimerRef.current) {
|
||||||
|
clearTimeout(countReloadTimerRef.current);
|
||||||
|
countReloadTimerRef.current = null;
|
||||||
|
}
|
||||||
|
countRequestSeqRef.current += 1;
|
||||||
setIsTaskLogActivityActive(false);
|
setIsTaskLogActivityActive(false);
|
||||||
|
setTaskLogSegmentCount(null);
|
||||||
}, [task.id]);
|
}, [task.id]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!taskLogSummaryEnabled || !api.teams.getTaskLogStreamSummary) {
|
||||||
|
setTaskLogSegmentCount(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestSeq = countRequestSeqRef.current + 1;
|
||||||
|
countRequestSeqRef.current = requestSeq;
|
||||||
|
|
||||||
|
void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id))
|
||||||
|
.then((summary) => {
|
||||||
|
if (countRequestSeqRef.current !== requestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTaskLogSegmentCount(summary.segmentCount);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (countRequestSeqRef.current !== requestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTaskLogSegmentCount((prev) => prev);
|
||||||
|
});
|
||||||
|
}, [task.id, taskLogSummaryEnabled, teamName]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
if (!taskLogTrackingEnabled || !api.teams.setTaskLogStreamTracking) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -107,10 +150,39 @@ export const TaskLogsPanel = ({
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = null;
|
pulseTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (countReloadTimerRef.current) {
|
||||||
|
clearTimeout(countReloadTimerRef.current);
|
||||||
|
countReloadTimerRef.current = null;
|
||||||
|
}
|
||||||
setIsTaskLogActivityActive(false);
|
setIsTaskLogActivityActive(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const scheduleCountReload = (): void => {
|
||||||
|
if (!api.teams.getTaskLogStreamSummary) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (countReloadTimerRef.current) {
|
||||||
|
clearTimeout(countReloadTimerRef.current);
|
||||||
|
}
|
||||||
|
countReloadTimerRef.current = setTimeout(() => {
|
||||||
|
countReloadTimerRef.current = null;
|
||||||
|
const requestSeq = countRequestSeqRef.current + 1;
|
||||||
|
countRequestSeqRef.current = requestSeq;
|
||||||
|
void Promise.resolve(api.teams.getTaskLogStreamSummary(teamName, task.id))
|
||||||
|
.then((summary) => {
|
||||||
|
if (countRequestSeqRef.current !== requestSeq) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setTaskLogSegmentCount(summary.segmentCount);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
}, TASK_LOG_COUNT_RELOAD_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
const unsubscribe = api.teams.onTeamChange?.((_event, event) => {
|
||||||
if (
|
if (
|
||||||
event.teamName !== teamName ||
|
event.teamName !== teamName ||
|
||||||
|
|
@ -128,13 +200,31 @@ export const TaskLogsPanel = ({
|
||||||
pulseTimerRef.current = null;
|
pulseTimerRef.current = null;
|
||||||
setIsTaskLogActivityActive(false);
|
setIsTaskLogActivityActive(false);
|
||||||
}, TASK_LOG_ACTIVITY_PULSE_MS);
|
}, TASK_LOG_ACTIVITY_PULSE_MS);
|
||||||
|
scheduleCountReload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleVisibilityChange = (): void => {
|
||||||
|
if (document.visibilityState === 'visible') {
|
||||||
|
scheduleCountReload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (pulseTimerRef.current) {
|
if (pulseTimerRef.current) {
|
||||||
clearTimeout(pulseTimerRef.current);
|
clearTimeout(pulseTimerRef.current);
|
||||||
pulseTimerRef.current = null;
|
pulseTimerRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (countReloadTimerRef.current) {
|
||||||
|
clearTimeout(countReloadTimerRef.current);
|
||||||
|
countReloadTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
|
}
|
||||||
if (typeof unsubscribe === 'function') {
|
if (typeof unsubscribe === 'function') {
|
||||||
unsubscribe();
|
unsubscribe();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ import type {
|
||||||
BoardTaskExactLogDetailResult,
|
BoardTaskExactLogDetailResult,
|
||||||
BoardTaskExactLogSummariesResponse,
|
BoardTaskExactLogSummariesResponse,
|
||||||
BoardTaskLogStreamResponse,
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskLogStreamSummary,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
CrossTeamMessage,
|
CrossTeamMessage,
|
||||||
CrossTeamSendRequest,
|
CrossTeamSendRequest,
|
||||||
|
|
@ -495,6 +496,7 @@ export interface TeamsAPI {
|
||||||
taskId: string,
|
taskId: string,
|
||||||
activityId: string
|
activityId: string
|
||||||
) => Promise<BoardTaskActivityDetailResult>;
|
) => Promise<BoardTaskActivityDetailResult>;
|
||||||
|
getTaskLogStreamSummary: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamSummary>;
|
||||||
getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>;
|
getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>;
|
||||||
getTaskExactLogSummaries: (
|
getTaskExactLogSummaries: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
|
||||||
|
|
@ -342,6 +342,10 @@ export interface BoardTaskLogStreamResponse {
|
||||||
segments: BoardTaskLogSegment[];
|
segments: BoardTaskLogSegment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BoardTaskLogStreamSummary {
|
||||||
|
segmentCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface TaskComment {
|
export interface TaskComment {
|
||||||
id: string;
|
id: string;
|
||||||
author: string;
|
author: string;
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,63 @@ describe('BoardTaskLogStreamService', () => {
|
||||||
expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
|
expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('returns lightweight segment count without building stream chunks', async () => {
|
||||||
|
const tom = {
|
||||||
|
memberName: 'tom',
|
||||||
|
role: 'member' as const,
|
||||||
|
sessionId: 'session-tom',
|
||||||
|
agentId: 'agent-tom',
|
||||||
|
isSidechain: true,
|
||||||
|
};
|
||||||
|
const alice = {
|
||||||
|
memberName: 'alice',
|
||||||
|
role: 'member' as const,
|
||||||
|
sessionId: 'session-alice',
|
||||||
|
agentId: 'agent-alice',
|
||||||
|
isSidechain: true,
|
||||||
|
};
|
||||||
|
const candidates = [
|
||||||
|
makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
|
||||||
|
makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'),
|
||||||
|
makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'),
|
||||||
|
makeCandidate('c4', '2026-04-12T16:03:00.000Z', tom, 'tool-4'),
|
||||||
|
];
|
||||||
|
|
||||||
|
const recordSource = {
|
||||||
|
getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)),
|
||||||
|
};
|
||||||
|
const summarySelector = {
|
||||||
|
selectSummaries: vi.fn(() => candidates),
|
||||||
|
};
|
||||||
|
const strictParser = {
|
||||||
|
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
|
||||||
|
};
|
||||||
|
const detailSelector = {
|
||||||
|
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({
|
||||||
|
id: candidate.id,
|
||||||
|
timestamp: candidate.timestamp,
|
||||||
|
actor: candidate.actor,
|
||||||
|
source: candidate.source,
|
||||||
|
records: candidate.records,
|
||||||
|
filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)],
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
||||||
|
|
||||||
|
const service = new BoardTaskLogStreamService(
|
||||||
|
recordSource as never,
|
||||||
|
summarySelector as never,
|
||||||
|
strictParser as never,
|
||||||
|
detailSelector as never,
|
||||||
|
{ buildBundleChunks } as never,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(service.getTaskLogStreamSummary('demo', 'task-a')).resolves.toEqual({
|
||||||
|
segmentCount: 3,
|
||||||
|
});
|
||||||
|
expect(buildBundleChunks).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('merges duplicate message uuids inside one participant segment before chunk building', async () => {
|
it('merges duplicate message uuids inside one participant segment before chunk building', async () => {
|
||||||
const tom = {
|
const tom = {
|
||||||
memberName: 'tom',
|
memberName: 'tom',
|
||||||
|
|
|
||||||
77
test/main/services/team/progressPayload.test.ts
Normal file
77
test/main/services/team/progressPayload.test.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import {
|
||||||
|
PROGRESS_LOG_TAIL_LINES,
|
||||||
|
PROGRESS_OUTPUT_TAIL_PARTS,
|
||||||
|
buildProgressAssistantOutput,
|
||||||
|
buildProgressLogsTail,
|
||||||
|
} from '../../../../src/main/services/team/progressPayload';
|
||||||
|
|
||||||
|
describe('buildProgressLogsTail', () => {
|
||||||
|
it('returns undefined for an empty buffer', () => {
|
||||||
|
expect(buildProgressLogsTail([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined when all lines are whitespace', () => {
|
||||||
|
expect(buildProgressLogsTail(['', ' ', '\t'])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the full buffer joined when below the limit', () => {
|
||||||
|
const lines = ['alpha', 'beta', 'gamma'];
|
||||||
|
expect(buildProgressLogsTail(lines, 10)).toBe('alpha\nbeta\ngamma');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps the payload to the last N lines once the limit is exceeded', () => {
|
||||||
|
const lines = Array.from({ length: 1_000 }, (_, i) => `line-${i}`);
|
||||||
|
const result = buildProgressLogsTail(lines, 50);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
const parts = result!.split('\n');
|
||||||
|
expect(parts).toHaveLength(50);
|
||||||
|
expect(parts[0]).toBe('line-950');
|
||||||
|
expect(parts[parts.length - 1]).toBe('line-999');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default tail size when the caller does not override it', () => {
|
||||||
|
const lines = Array.from({ length: PROGRESS_LOG_TAIL_LINES + 250 }, (_, i) => `l${i}`);
|
||||||
|
const result = buildProgressLogsTail(lines);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.split('\n')).toHaveLength(PROGRESS_LOG_TAIL_LINES);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps payload size bounded for pathological inputs (50k lines)', () => {
|
||||||
|
const lines = Array.from({ length: 50_000 }, (_, i) => `line-${i}`);
|
||||||
|
const result = buildProgressLogsTail(lines);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
// Regression guard: a full-buffer join of 50k synthetic lines would exceed
|
||||||
|
// 400k chars. The tail must stay well below that.
|
||||||
|
expect(result!.length).toBeLessThan(50_000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces non-positive limits to at least one line', () => {
|
||||||
|
expect(buildProgressLogsTail(['a', 'b', 'c'], 0)).toBe('c');
|
||||||
|
expect(buildProgressLogsTail(['a', 'b', 'c'], -5)).toBe('c');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildProgressAssistantOutput', () => {
|
||||||
|
it('returns undefined when there are no parts', () => {
|
||||||
|
expect(buildProgressAssistantOutput([])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('joins parts with a blank-line separator when below the limit', () => {
|
||||||
|
expect(buildProgressAssistantOutput(['first', 'second'], 10)).toBe('first\n\nsecond');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('caps to the last N parts once the limit is exceeded', () => {
|
||||||
|
const parts = Array.from({ length: 200 }, (_, i) => `p${i}`);
|
||||||
|
const result = buildProgressAssistantOutput(parts, 5);
|
||||||
|
expect(result).toBe('p195\n\np196\n\np197\n\np198\n\np199');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the default tail size when the caller does not override it', () => {
|
||||||
|
const parts = Array.from({ length: PROGRESS_OUTPUT_TAIL_PARTS + 10 }, (_, i) => `p${i}`);
|
||||||
|
const result = buildProgressAssistantOutput(parts);
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -9,6 +9,9 @@ import type { TeamTaskWithKanban } from '../../../../../src/shared/types';
|
||||||
|
|
||||||
const apiState = {
|
const apiState = {
|
||||||
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
|
onTeamChange: vi.fn<(callback: (event: unknown, data: TeamChangeEvent) => void) => () => void>(),
|
||||||
|
getTaskLogStreamSummary: vi.fn<
|
||||||
|
(teamName: string, taskId: string) => Promise<{ segmentCount: number }>
|
||||||
|
>(),
|
||||||
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
setTaskLogStreamTracking: vi.fn<(teamName: string, enabled: boolean) => Promise<void>>(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -17,6 +20,8 @@ vi.mock('@renderer/api', () => ({
|
||||||
teams: {
|
teams: {
|
||||||
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
onTeamChange: (...args: Parameters<typeof apiState.onTeamChange>) =>
|
||||||
apiState.onTeamChange(...args),
|
apiState.onTeamChange(...args),
|
||||||
|
getTaskLogStreamSummary: (...args: Parameters<typeof apiState.getTaskLogStreamSummary>) =>
|
||||||
|
apiState.getTaskLogStreamSummary(...args),
|
||||||
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
|
setTaskLogStreamTracking: (...args: Parameters<typeof apiState.setTaskLogStreamTracking>) =>
|
||||||
apiState.setTaskLogStreamTracking(...args),
|
apiState.setTaskLogStreamTracking(...args),
|
||||||
},
|
},
|
||||||
|
|
@ -168,7 +173,9 @@ describe('TaskLogsPanel', () => {
|
||||||
taskLogStreamProps.calls = [];
|
taskLogStreamProps.calls = [];
|
||||||
executionSessionsProps.calls = [];
|
executionSessionsProps.calls = [];
|
||||||
apiState.onTeamChange.mockReset();
|
apiState.onTeamChange.mockReset();
|
||||||
|
apiState.getTaskLogStreamSummary.mockReset();
|
||||||
apiState.setTaskLogStreamTracking.mockReset();
|
apiState.setTaskLogStreamTracking.mockReset();
|
||||||
|
apiState.getTaskLogStreamSummary.mockResolvedValue({ segmentCount: 0 });
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
@ -484,4 +491,53 @@ describe('TaskLogsPanel', () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('loads task log count for the header badge and refreshes it on matching live updates', async () => {
|
||||||
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
|
vi.useFakeTimers();
|
||||||
|
|
||||||
|
const counts: Array<number | undefined> = [];
|
||||||
|
let handler: ((event: unknown, data: TeamChangeEvent) => void) | null = null;
|
||||||
|
apiState.onTeamChange.mockImplementation((callback) => {
|
||||||
|
handler = callback;
|
||||||
|
return () => {
|
||||||
|
handler = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
apiState.getTaskLogStreamSummary
|
||||||
|
.mockResolvedValueOnce({ segmentCount: 4 })
|
||||||
|
.mockResolvedValueOnce({ segmentCount: 5 });
|
||||||
|
|
||||||
|
const host = document.createElement('div');
|
||||||
|
document.body.appendChild(host);
|
||||||
|
const root = createRoot(host);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.render(
|
||||||
|
React.createElement(TaskLogsPanel, {
|
||||||
|
teamName: 'demo',
|
||||||
|
task: makeTask(),
|
||||||
|
onTaskLogCountChange: (count) => counts.push(count),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledWith('demo', 'task-1');
|
||||||
|
expect(counts).toEqual([undefined, 4]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
handler?.(null, { teamName: 'demo', type: 'task-log-change', taskId: 'task-1' });
|
||||||
|
vi.advanceTimersByTime(350);
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(apiState.getTaskLogStreamSummary).toHaveBeenCalledTimes(2);
|
||||||
|
expect(counts).toEqual([undefined, 4, 5]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
root.unmount();
|
||||||
|
await flushMicrotasks();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue