fix(ci): restore green workspace checks
This commit is contained in:
parent
4869bb35da
commit
fb21b982c6
15 changed files with 182 additions and 80 deletions
4
.github/workflows/ci.yml
vendored
4
.github/workflows/ci.yml
vendored
|
|
@ -53,7 +53,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -88,7 +88,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
|
||||||
2
.github/workflows/landing.yml
vendored
2
.github/workflows/landing.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@v6
|
||||||
|
|
||||||
- uses: actions/setup-node@v7
|
- uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
|
|
||||||
|
|
|
||||||
8
.github/workflows/release.yml
vendored
8
.github/workflows/release.yml
vendored
|
|
@ -21,7 +21,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -178,7 +178,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -287,7 +287,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
@ -391,7 +391,7 @@ jobs:
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v7
|
uses: actions/setup-node@v6
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,41 @@
|
||||||
import { mkdtemp, rm } from 'node:fs/promises';
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
function parseJsonToolResult(result: unknown) {
|
function parseJsonToolResult(result: unknown) {
|
||||||
const text = (result as { content?: Array<{ text?: string }> }).content?.[0]?.text;
|
const response = result as {
|
||||||
|
content?: Array<{ text?: string }>;
|
||||||
|
isError?: boolean;
|
||||||
|
};
|
||||||
|
const text = response.content?.[0]?.text;
|
||||||
|
if (response.isError) {
|
||||||
|
throw new Error(text ?? 'Tool returned an unspecified error');
|
||||||
|
}
|
||||||
return JSON.parse(text ?? 'null');
|
return JSON.parse(text ?? 'null');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function writeTeamConfig(claudeDir: string, teamName: string) {
|
||||||
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||||
|
await mkdir(teamDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(teamDir, 'config.json'),
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: teamName,
|
||||||
|
members: [
|
||||||
|
{ name: 'team-lead', agentType: 'team-lead' },
|
||||||
|
{ name: 'alice', agentType: 'teammate', role: 'developer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
class McpStdIoClient {
|
class McpStdIoClient {
|
||||||
private readonly child: ChildProcessWithoutNullStreams;
|
private readonly child: ChildProcessWithoutNullStreams;
|
||||||
private stdoutBuffer = '';
|
private stdoutBuffer = '';
|
||||||
|
|
@ -102,6 +129,7 @@ describe('agent-teams-mcp stdio e2e', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
|
it('boots over stdio, lists task tools, and executes task lifecycle calls', async () => {
|
||||||
|
await writeTeamConfig(claudeDir, 'e2e-team');
|
||||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -174,7 +174,8 @@ export class ChangeExtractorService {
|
||||||
teamName,
|
teamName,
|
||||||
taskId,
|
taskId,
|
||||||
effectiveOptions,
|
effectiveOptions,
|
||||||
effectiveStateBucket
|
effectiveStateBucket,
|
||||||
|
version
|
||||||
);
|
);
|
||||||
|
|
||||||
if (options?.forceFresh !== true) {
|
if (options?.forceFresh !== true) {
|
||||||
|
|
@ -407,9 +408,10 @@ export class ChangeExtractorService {
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
options: TaskChangeEffectiveOptions,
|
options: TaskChangeEffectiveOptions,
|
||||||
stateBucket: TaskChangeStateBucket
|
stateBucket: TaskChangeStateBucket,
|
||||||
|
version: number
|
||||||
): string {
|
): string {
|
||||||
return `${teamName}:${taskId}:${this.buildTaskSignature(options, stateBucket)}`;
|
return `${teamName}:${taskId}:v${version}:${this.buildTaskSignature(options, stateBucket)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private normalizeFilePathKey(filePath: string): string {
|
private normalizeFilePathKey(filePath: string): string {
|
||||||
|
|
|
||||||
|
|
@ -9,14 +9,14 @@ import {
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
|
||||||
import {
|
import {
|
||||||
choosePreferredLaunchSnapshot,
|
choosePreferredLaunchSnapshot,
|
||||||
readBootstrapLaunchSnapshot,
|
readBootstrapLaunchSnapshot,
|
||||||
} from './TeamBootstrapStateReader';
|
} from './TeamBootstrapStateReader';
|
||||||
|
import { getTeamFsWorkerClient } from './TeamFsWorkerClient';
|
||||||
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
import { normalizePersistedLaunchSnapshot } from './TeamLaunchStateEvaluator';
|
||||||
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
|
|
||||||
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
import type { TeamConfig, TeamMember, TeamSummary, TeamSummaryMember } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -48,18 +48,19 @@ interface LaunchStateSummary {
|
||||||
async function readLaunchStateSummary(teamDir: string): Promise<LaunchStateSummary | null> {
|
async function readLaunchStateSummary(teamDir: string): Promise<LaunchStateSummary | null> {
|
||||||
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir));
|
const bootstrapSnapshot = await readBootstrapLaunchSnapshot(path.basename(teamDir));
|
||||||
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
|
const launchStatePath = path.join(teamDir, TEAM_LAUNCH_STATE_FILE);
|
||||||
let launchSnapshot = null;
|
const launchSnapshot = await (async () => {
|
||||||
try {
|
try {
|
||||||
const stat = await fs.promises.stat(launchStatePath);
|
const stat = await fs.promises.stat(launchStatePath);
|
||||||
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
if (!stat.isFile() || stat.size > MAX_LAUNCH_STATE_BYTES) {
|
||||||
launchSnapshot = null;
|
return null;
|
||||||
} else {
|
}
|
||||||
|
|
||||||
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
const raw = await readFileUtf8WithTimeout(launchStatePath, PER_TEAM_READ_TIMEOUT_MS);
|
||||||
launchSnapshot = normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw));
|
return normalizePersistedLaunchSnapshot(path.basename(teamDir), JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
} catch {
|
})();
|
||||||
launchSnapshot = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
const snapshot = choosePreferredLaunchSnapshot(bootstrapSnapshot, launchSnapshot);
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,9 @@ import {
|
||||||
wrapAgentBlock,
|
wrapAgentBlock,
|
||||||
} from '@shared/constants/agentBlocks';
|
} from '@shared/constants/agentBlocks';
|
||||||
import { getMemberColorByName } from '@shared/constants/memberColors';
|
import { getMemberColorByName } from '@shared/constants/memberColors';
|
||||||
|
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSemantics';
|
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
|
|
@ -31,13 +31,13 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
import { gitIdentityResolver } from '../parsing/GitIdentityResolver';
|
||||||
|
|
||||||
import { atomicWriteAsync } from './atomicWrite';
|
|
||||||
import {
|
import {
|
||||||
areLeadSessionFileSignaturesEqual,
|
areLeadSessionFileSignaturesEqual,
|
||||||
LeadSessionParseCache,
|
|
||||||
type LeadSessionFileSignature,
|
type LeadSessionFileSignature,
|
||||||
|
LeadSessionParseCache,
|
||||||
type LeadSessionParseCacheKey,
|
type LeadSessionParseCacheKey,
|
||||||
} from './cache/LeadSessionParseCache';
|
} from './cache/LeadSessionParseCache';
|
||||||
|
import { atomicWriteAsync } from './atomicWrite';
|
||||||
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor';
|
||||||
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
|
|
@ -62,9 +62,9 @@ import type {
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
InboxMessage,
|
InboxMessage,
|
||||||
MessagesPage,
|
|
||||||
KanbanColumnId,
|
KanbanColumnId,
|
||||||
KanbanState,
|
KanbanState,
|
||||||
|
MessagesPage,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
|
|
@ -134,11 +134,11 @@ function normalizePassiveUserReplyLinkText(value: string | undefined): string {
|
||||||
|
|
||||||
function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
||||||
const classified = classifyIdleNotificationText(text);
|
const classified = classifyIdleNotificationText(text);
|
||||||
if (!classified || classified.primaryKind !== 'heartbeat' || !classified.peerSummary) {
|
if (classified?.primaryKind !== 'heartbeat' || !classified.peerSummary) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = classified.peerSummary.match(/^\[to\s+user\]\s*(.*)$/i);
|
const match = /^\[to\s+user\]\s*(.*)$/i.exec(classified.peerSummary);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -677,7 +677,7 @@ export class TeamDataService {
|
||||||
const runWithConcurrencyLimit = (() => {
|
const runWithConcurrencyLimit = (() => {
|
||||||
const limit = 2;
|
const limit = 2;
|
||||||
let active = 0;
|
let active = 0;
|
||||||
const queue: Array<() => void> = [];
|
const queue: (() => void)[] = [];
|
||||||
const releaseNext = (): void => {
|
const releaseNext = (): void => {
|
||||||
if (active >= limit) return;
|
if (active >= limit) return;
|
||||||
const next = queue.shift();
|
const next = queue.shift();
|
||||||
|
|
@ -811,8 +811,8 @@ export class TeamDataService {
|
||||||
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
if (metaMembersStepResult.warning) warnings.push(metaMembersStepResult.warning);
|
||||||
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
if (kanbanStateStepResult.warning) warnings.push(kanbanStateStepResult.warning);
|
||||||
|
|
||||||
let tasks: TeamTask[] = tasksStepResult.value;
|
const tasks: TeamTask[] = tasksStepResult.value;
|
||||||
let inboxNames: string[] = inboxNamesStepResult.value;
|
const inboxNames: string[] = inboxNamesStepResult.value;
|
||||||
let messages: InboxMessage[] = messagesStepResult.value;
|
let messages: InboxMessage[] = messagesStepResult.value;
|
||||||
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
const leadTexts: InboxMessage[] = leadTextsStepResult.value;
|
||||||
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
const sentMessages: InboxMessage[] = sentMessagesStepResult.value;
|
||||||
|
|
@ -2064,8 +2064,37 @@ export class TeamDataService {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return /^(принято|принял|приняла|ок|ok|okay|на связи|понял|поняла|roger|ack)(?:[ ,.-]+(на связи|остаюсь на связи|жду(?: [^.!?]+)?|ждём(?: [^.!?]+)?|готов(?:а)?(?: [^.!?]+)?|буду ждать(?: [^.!?]+)?))?$/.test(
|
const startsWithAckPrefix = Array.from(exactMatches).find((prefix) => {
|
||||||
normalized
|
if (!normalized.startsWith(prefix)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const remainder = normalized.slice(prefix.length);
|
||||||
|
return remainder.length > 0 && /^[ ,.-]+/.test(remainder);
|
||||||
|
});
|
||||||
|
if (!startsWithAckPrefix) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qualifier = normalized
|
||||||
|
.slice(startsWithAckPrefix.length)
|
||||||
|
.replace(/^[ ,.-]+/, '')
|
||||||
|
.trim();
|
||||||
|
if (!qualifier) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesQualifierWithOptionalDetail = (phrase: string): boolean =>
|
||||||
|
qualifier === phrase ||
|
||||||
|
(qualifier.startsWith(`${phrase} `) && !/[.!?]/.test(qualifier.slice(phrase.length + 1)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
qualifier === 'на связи' ||
|
||||||
|
qualifier === 'остаюсь на связи' ||
|
||||||
|
matchesQualifierWithOptionalDetail('жду') ||
|
||||||
|
matchesQualifierWithOptionalDetail('ждём') ||
|
||||||
|
matchesQualifierWithOptionalDetail('готов') ||
|
||||||
|
matchesQualifierWithOptionalDetail('готова') ||
|
||||||
|
matchesQualifierWithOptionalDetail('буду ждать')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1450,12 +1450,16 @@ async function writeDeterministicBootstrapSpecFile(spec: RuntimeBootstrapSpec):
|
||||||
return filePath;
|
return filePath;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise<void> {
|
async function removeDeterministicBootstrapTempFile(filePath: string | null): Promise<void> {
|
||||||
if (!filePath) return;
|
if (!filePath) return;
|
||||||
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
||||||
await fs.promises.rmdir(path.dirname(filePath)).catch(() => {});
|
await fs.promises.rmdir(path.dirname(filePath)).catch(() => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeDeterministicBootstrapSpecFile(filePath: string | null): Promise<void> {
|
||||||
|
await removeDeterministicBootstrapTempFile(filePath);
|
||||||
|
}
|
||||||
|
|
||||||
async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise<string> {
|
async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promise<string> {
|
||||||
const tempDir = await fs.promises.mkdtemp(
|
const tempDir = await fs.promises.mkdtemp(
|
||||||
path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-')
|
path.join(os.tmpdir(), 'agent-teams-bootstrap-prompt-')
|
||||||
|
|
@ -1469,9 +1473,7 @@ async function writeDeterministicBootstrapUserPromptFile(prompt: string): Promis
|
||||||
}
|
}
|
||||||
|
|
||||||
async function removeDeterministicBootstrapUserPromptFile(filePath: string | null): Promise<void> {
|
async function removeDeterministicBootstrapUserPromptFile(filePath: string | null): Promise<void> {
|
||||||
if (!filePath) return;
|
await removeDeterministicBootstrapTempFile(filePath);
|
||||||
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
|
||||||
await fs.promises.rmdir(path.dirname(filePath)).catch(() => {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
|
function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string {
|
||||||
|
|
@ -1577,7 +1579,7 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string
|
||||||
function buildLeadRosterContextBlock(
|
function buildLeadRosterContextBlock(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
teammates: Array<{ name: string; role?: string }>
|
teammates: { name: string; role?: string }[]
|
||||||
): string | null {
|
): string | null {
|
||||||
if (teammates.length === 0) return null;
|
if (teammates.length === 0) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
areStringMapsEqual,
|
areStringMapsEqual,
|
||||||
} from '@renderer/utils/messageRenderEquality';
|
} from '@renderer/utils/messageRenderEquality';
|
||||||
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
import { toMessageKey } from '@renderer/utils/teamMessageKey';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { Layers } from 'lucide-react';
|
import { Layers } from 'lucide-react';
|
||||||
|
|
||||||
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
import { ActivityItem, isNoiseMessage } from './ActivityItem';
|
||||||
|
|
@ -81,9 +80,6 @@ const COMPACT_MESSAGES_WIDTH_PX = 400;
|
||||||
const EMPTY_TEAM_NAMES: string[] = [];
|
const EMPTY_TEAM_NAMES: string[] = [];
|
||||||
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
const EMPTY_TEAM_COLOR_MAP = new Map<string, string>();
|
||||||
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
const DEFAULT_COLLAPSE_MODE = 'default' as const;
|
||||||
const logger = createLogger('Component:ActivityTimeline');
|
|
||||||
const ACTIVITY_TIMELINE_SLICE_WARN_MS = 6;
|
|
||||||
const ACTIVITY_TIMELINE_GROUP_WARN_MS = 8;
|
|
||||||
|
|
||||||
interface ItemCollapseProps {
|
interface ItemCollapseProps {
|
||||||
collapseMode: 'default' | 'managed';
|
collapseMode: 'default' | 'managed';
|
||||||
|
|
@ -339,7 +335,6 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
// Pagination counts only significant (non-thought) messages so that lead thoughts
|
// Pagination counts only significant (non-thought) messages so that lead thoughts
|
||||||
// don't consume the page limit — they collapse into a single visual group anyway.
|
// don't consume the page limit — they collapse into a single visual group anyway.
|
||||||
const { visibleMessages, hiddenCount } = useMemo(() => {
|
const { visibleMessages, hiddenCount } = useMemo(() => {
|
||||||
const startedAt = performance.now();
|
|
||||||
const total = messages.length;
|
const total = messages.length;
|
||||||
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
if (total === 0) return { visibleMessages: messages, hiddenCount: 0 };
|
||||||
|
|
||||||
|
|
@ -359,31 +354,14 @@ export const ActivityTimeline = React.memo(function ActivityTimeline({
|
||||||
significantSeen +
|
significantSeen +
|
||||||
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
(cutoff < total ? messages.slice(cutoff).filter((m) => !isLeadThought(m)).length : 0);
|
||||||
const hidden = Math.max(0, significantTotal - visibleCount);
|
const hidden = Math.max(0, significantTotal - visibleCount);
|
||||||
const result = {
|
return {
|
||||||
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
visibleMessages: cutoff < total ? messages.slice(0, cutoff) : messages,
|
||||||
hiddenCount: hidden,
|
hiddenCount: hidden,
|
||||||
};
|
};
|
||||||
const ms = performance.now() - startedAt;
|
|
||||||
if (ms >= ACTIVITY_TIMELINE_SLICE_WARN_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`[perf] paginate team=${teamName} ms=${ms.toFixed(1)} total=${messages.length} visible=${result.visibleMessages.length} hidden=${result.hiddenCount} pageSize=${visibleCount}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [messages, visibleCount]);
|
}, [messages, visibleCount]);
|
||||||
|
|
||||||
// Group consecutive lead thoughts into collapsible blocks.
|
// Group consecutive lead thoughts into collapsible blocks.
|
||||||
const timelineItems = useMemo(() => {
|
const timelineItems = useMemo(() => groupTimelineItems(visibleMessages), [visibleMessages]);
|
||||||
const startedAt = performance.now();
|
|
||||||
const result = groupTimelineItems(visibleMessages);
|
|
||||||
const ms = performance.now() - startedAt;
|
|
||||||
if (ms >= ACTIVITY_TIMELINE_GROUP_WARN_MS) {
|
|
||||||
logger.warn(
|
|
||||||
`[perf] groupTimeline team=${teamName} ms=${ms.toFixed(1)} visibleMessages=${visibleMessages.length} groups=${result.length}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [teamName, visibleMessages]);
|
|
||||||
|
|
||||||
// Zebra striping is anchored from the bottom of the visible list so prepending
|
// Zebra striping is anchored from the bottom of the visible list so prepending
|
||||||
// new live messages at the top does not recolor every existing card.
|
// new live messages at the top does not recolor every existing card.
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,13 @@ const { mockAddTeamNotification } = vi.hoisted(() => ({
|
||||||
const { mockGetMembersMeta } = vi.hoisted(() => ({
|
const { mockGetMembersMeta } = vi.hoisted(() => ({
|
||||||
mockGetMembersMeta: vi.fn(),
|
mockGetMembersMeta: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
const { mockTeamDataWorkerClient } = vi.hoisted(() => ({
|
||||||
|
mockTeamDataWorkerClient: {
|
||||||
|
isAvailable: vi.fn(),
|
||||||
|
getTeamData: vi.fn(),
|
||||||
|
findLogsForTask: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
vi.mock('@main/services/infrastructure/NotificationManager', () => ({
|
||||||
NotificationManager: {
|
NotificationManager: {
|
||||||
getInstance: vi.fn().mockReturnValue({
|
getInstance: vi.fn().mockReturnValue({
|
||||||
|
|
@ -33,6 +40,9 @@ vi.mock('@main/services/team/TeamMembersMetaStore', () => ({
|
||||||
getMembers: mockGetMembersMeta,
|
getMembers: mockGetMembersMeta,
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
|
vi.mock('@main/services/team/TeamDataWorkerClient', () => ({
|
||||||
|
getTeamDataWorkerClient: () => mockTeamDataWorkerClient,
|
||||||
|
}));
|
||||||
|
|
||||||
import {
|
import {
|
||||||
TEAM_ALIVE_LIST,
|
TEAM_ALIVE_LIST,
|
||||||
|
|
@ -182,6 +192,9 @@ describe('ipc teams handlers', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mockGetMembersMeta.mockReset();
|
mockGetMembersMeta.mockReset();
|
||||||
mockGetMembersMeta.mockResolvedValue([]);
|
mockGetMembersMeta.mockResolvedValue([]);
|
||||||
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
|
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||||
|
mockTeamDataWorkerClient.findLogsForTask.mockReset();
|
||||||
initializeTeamHandlers(service as never, provisioningService as never);
|
initializeTeamHandlers(service as never, provisioningService as never);
|
||||||
registerTeamHandlers(ipcMain as never);
|
registerTeamHandlers(ipcMain as never);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -247,17 +247,23 @@ describe('FileWatcher', () => {
|
||||||
|
|
||||||
// Simulate having previously processed the file by directly setting tracking state
|
// Simulate having previously processed the file by directly setting tracking state
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
|
isWatching: boolean;
|
||||||
lastProcessedLineCount: Map<string, number>;
|
lastProcessedLineCount: Map<string, number>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
activeSessionFiles: Map<string, { projectId: string; sessionId: string }>;
|
activeSessionFiles: Map<
|
||||||
|
string,
|
||||||
|
{ projectId: string; sessionId: string; lastObservedAt: number }
|
||||||
|
>;
|
||||||
runCatchUpScan: () => Promise<void>;
|
runCatchUpScan: () => Promise<void>;
|
||||||
};
|
};
|
||||||
const initialSize = fs.statSync(filePath).size;
|
const initialSize = fs.statSync(filePath).size;
|
||||||
|
watcherAny.isWatching = true;
|
||||||
watcherAny.lastProcessedLineCount.set(filePath, 1);
|
watcherAny.lastProcessedLineCount.set(filePath, 1);
|
||||||
watcherAny.lastProcessedSize.set(filePath, initialSize);
|
watcherAny.lastProcessedSize.set(filePath, initialSize);
|
||||||
watcherAny.activeSessionFiles.set(filePath, {
|
watcherAny.activeSessionFiles.set(filePath, {
|
||||||
projectId: 'test-project',
|
projectId: 'test-project',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
|
lastObservedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Append new data WITHOUT triggering fs.watch (simulating a missed event)
|
// Append new data WITHOUT triggering fs.watch (simulating a missed event)
|
||||||
|
|
@ -301,17 +307,23 @@ describe('FileWatcher', () => {
|
||||||
watcher.setNotificationManager(notificationManager);
|
watcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
|
isWatching: boolean;
|
||||||
lastProcessedLineCount: Map<string, number>;
|
lastProcessedLineCount: Map<string, number>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
activeSessionFiles: Map<string, { projectId: string; sessionId: string }>;
|
activeSessionFiles: Map<
|
||||||
|
string,
|
||||||
|
{ projectId: string; sessionId: string; lastObservedAt: number }
|
||||||
|
>;
|
||||||
runCatchUpScan: () => Promise<void>;
|
runCatchUpScan: () => Promise<void>;
|
||||||
};
|
};
|
||||||
const currentSize = fs.statSync(filePath).size;
|
const currentSize = fs.statSync(filePath).size;
|
||||||
|
watcherAny.isWatching = true;
|
||||||
watcherAny.lastProcessedLineCount.set(filePath, 1);
|
watcherAny.lastProcessedLineCount.set(filePath, 1);
|
||||||
watcherAny.lastProcessedSize.set(filePath, currentSize);
|
watcherAny.lastProcessedSize.set(filePath, currentSize);
|
||||||
watcherAny.activeSessionFiles.set(filePath, {
|
watcherAny.activeSessionFiles.set(filePath, {
|
||||||
projectId: 'test-project',
|
projectId: 'test-project',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
|
lastObservedAt: Date.now(),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mocked(errorDetector.detectErrors).mockClear();
|
vi.mocked(errorDetector.detectErrors).mockClear();
|
||||||
|
|
@ -348,13 +360,19 @@ describe('FileWatcher', () => {
|
||||||
watcher.setNotificationManager(notificationManager);
|
watcher.setNotificationManager(notificationManager);
|
||||||
|
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
activeSessionFiles: Map<string, { projectId: string; sessionId: string }>;
|
isWatching: boolean;
|
||||||
|
activeSessionFiles: Map<
|
||||||
|
string,
|
||||||
|
{ projectId: string; sessionId: string; lastObservedAt: number }
|
||||||
|
>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
runCatchUpScan: () => Promise<void>;
|
runCatchUpScan: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
watcherAny.isWatching = true;
|
||||||
watcherAny.activeSessionFiles.set(filePath, {
|
watcherAny.activeSessionFiles.set(filePath, {
|
||||||
projectId: 'test-project',
|
projectId: 'test-project',
|
||||||
sessionId: 'old-session',
|
sessionId: 'old-session',
|
||||||
|
lastObservedAt: Date.now(),
|
||||||
});
|
});
|
||||||
watcherAny.lastProcessedSize.set(filePath, 0);
|
watcherAny.lastProcessedSize.set(filePath, 0);
|
||||||
|
|
||||||
|
|
@ -378,14 +396,20 @@ describe('FileWatcher', () => {
|
||||||
const filePath = '/tmp/projects/test-project/nonexistent.jsonl';
|
const filePath = '/tmp/projects/test-project/nonexistent.jsonl';
|
||||||
|
|
||||||
const watcherAny = watcher as unknown as {
|
const watcherAny = watcher as unknown as {
|
||||||
activeSessionFiles: Map<string, { projectId: string; sessionId: string }>;
|
isWatching: boolean;
|
||||||
|
activeSessionFiles: Map<
|
||||||
|
string,
|
||||||
|
{ projectId: string; sessionId: string; lastObservedAt: number }
|
||||||
|
>;
|
||||||
lastProcessedSize: Map<string, number>;
|
lastProcessedSize: Map<string, number>;
|
||||||
lastProcessedLineCount: Map<string, number>;
|
lastProcessedLineCount: Map<string, number>;
|
||||||
runCatchUpScan: () => Promise<void>;
|
runCatchUpScan: () => Promise<void>;
|
||||||
};
|
};
|
||||||
|
watcherAny.isWatching = true;
|
||||||
watcherAny.activeSessionFiles.set(filePath, {
|
watcherAny.activeSessionFiles.set(filePath, {
|
||||||
projectId: 'test-project',
|
projectId: 'test-project',
|
||||||
sessionId: 'nonexistent',
|
sessionId: 'nonexistent',
|
||||||
|
lastObservedAt: Date.now(),
|
||||||
});
|
});
|
||||||
watcherAny.lastProcessedSize.set(filePath, 100);
|
watcherAny.lastProcessedSize.set(filePath, 100);
|
||||||
watcherAny.lastProcessedLineCount.set(filePath, 5);
|
watcherAny.lastProcessedLineCount.set(filePath, 5);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,13 @@ import { EventEmitter } from 'events';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
paths: {
|
||||||
|
claudeRoot: '',
|
||||||
|
teamsBase: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let tempClaudeRoot = '';
|
let tempClaudeRoot = '';
|
||||||
let tempTeamsBase = '';
|
let tempTeamsBase = '';
|
||||||
|
|
||||||
|
|
@ -12,9 +19,9 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getAutoDetectedClaudeBasePath: () => tempClaudeRoot,
|
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
|
||||||
getClaudeBasePath: () => tempClaudeRoot,
|
getClaudeBasePath: () => hoisted.paths.claudeRoot,
|
||||||
getTeamsBasePath: () => tempTeamsBase,
|
getTeamsBasePath: () => hoisted.paths.teamsBase,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -25,11 +32,15 @@ describe('TeamProvisioningService idempotent launch guards', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-launch-'));
|
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-launch-'));
|
||||||
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
||||||
|
hoisted.paths.claudeRoot = tempClaudeRoot;
|
||||||
|
hoisted.paths.teamsBase = tempTeamsBase;
|
||||||
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
||||||
|
hoisted.paths.claudeRoot = '';
|
||||||
|
hoisted.paths.teamsBase = '';
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses the alive run instead of spawning a duplicate launch', async () => {
|
it('reuses the alive run instead of spawning a duplicate launch', async () => {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import * as path from 'path';
|
||||||
|
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const hoisted = vi.hoisted(() => ({
|
||||||
|
paths: {
|
||||||
|
claudeRoot: '',
|
||||||
|
teamsBase: '',
|
||||||
|
tasksBase: '',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
let tempClaudeRoot = '';
|
let tempClaudeRoot = '';
|
||||||
let tempTeamsBase = '';
|
let tempTeamsBase = '';
|
||||||
let tempTasksBase = '';
|
let tempTasksBase = '';
|
||||||
|
|
@ -22,10 +30,10 @@ vi.mock('@main/utils/pathDecoder', async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
const actual = await importOriginal<typeof import('@main/utils/pathDecoder')>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
getAutoDetectedClaudeBasePath: () => tempClaudeRoot,
|
getAutoDetectedClaudeBasePath: () => hoisted.paths.claudeRoot,
|
||||||
getClaudeBasePath: () => tempClaudeRoot,
|
getClaudeBasePath: () => hoisted.paths.claudeRoot,
|
||||||
getTeamsBasePath: () => tempTeamsBase,
|
getTeamsBasePath: () => hoisted.paths.teamsBase,
|
||||||
getTasksBasePath: () => tempTasksBase,
|
getTasksBasePath: () => hoisted.paths.tasksBase,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -112,6 +120,9 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-compact-'));
|
tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-compact-'));
|
||||||
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
tempTeamsBase = path.join(tempClaudeRoot, 'teams');
|
||||||
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
tempTasksBase = path.join(tempClaudeRoot, 'tasks');
|
||||||
|
hoisted.paths.claudeRoot = tempClaudeRoot;
|
||||||
|
hoisted.paths.teamsBase = tempTeamsBase;
|
||||||
|
hoisted.paths.tasksBase = tempTasksBase;
|
||||||
setAppDataBasePath(tempClaudeRoot);
|
setAppDataBasePath(tempClaudeRoot);
|
||||||
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
fs.mkdirSync(tempTeamsBase, { recursive: true });
|
||||||
fs.mkdirSync(tempTasksBase, { recursive: true });
|
fs.mkdirSync(tempTasksBase, { recursive: true });
|
||||||
|
|
@ -119,6 +130,9 @@ describe('TeamProvisioningService post-compact lifecycle', () => {
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
setAppDataBasePath(null);
|
setAppDataBasePath(null);
|
||||||
|
hoisted.paths.claudeRoot = '';
|
||||||
|
hoisted.paths.teamsBase = '';
|
||||||
|
hoisted.paths.tasksBase = '';
|
||||||
try {
|
try {
|
||||||
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
fs.rmSync(tempClaudeRoot, { recursive: true, force: true });
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -549,7 +549,7 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
'GPT-5.4 Mini · Medium'
|
'GPT-5.4 Mini · Medium'
|
||||||
);
|
);
|
||||||
expect(graph.nodes.find((node) => node.id === 'member:my-team:alice')?.runtimeLabel).toBe(
|
expect(graph.nodes.find((node) => node.id === 'member:my-team:alice')?.runtimeLabel).toBe(
|
||||||
'Anthropic · sonnet · High'
|
'Anthropic · Sonnet 4.6 · High'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -144,9 +144,9 @@ describe('PROTECTED_CLI_FLAGS', () => {
|
||||||
expect(PROTECTED_CLI_FLAGS.has('--verbose')).toBe(true);
|
expect(PROTECTED_CLI_FLAGS.has('--verbose')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not contain user-facing flags', () => {
|
it('contains app-managed launch flags but not unrelated user flags', () => {
|
||||||
expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(false);
|
expect(PROTECTED_CLI_FLAGS.has('--model')).toBe(true);
|
||||||
expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(false);
|
expect(PROTECTED_CLI_FLAGS.has('--effort')).toBe(true);
|
||||||
expect(PROTECTED_CLI_FLAGS.has('--worktree')).toBe(false);
|
expect(PROTECTED_CLI_FLAGS.has('--worktree')).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue