refactor: streamline task handling and remove legacy support
- Removed legacy overlay review state handling from taskStore, simplifying task normalization processes. - Updated task retrieval methods to directly use normalized task data without fallback to legacy kanban states. - Eliminated outdated tests related to legacy kanban overlay, focusing on modern task management mechanisms. - Refactored TeamDataService and TaskBoundaryParser to enhance clarity and maintainability by removing unnecessary complexity. - Updated related types and interfaces to reflect the removal of legacy support.
This commit is contained in:
parent
52ddbb2916
commit
df457eb9cd
17 changed files with 156 additions and 3299 deletions
|
|
@ -72,33 +72,10 @@ function normalizeTaskReviewState(value) {
|
|||
return REVIEW_STATES.has(String(value || '').trim()) ? String(value).trim() : 'none';
|
||||
}
|
||||
|
||||
function getOverlayReviewState(overlayTasks, taskId) {
|
||||
if (!overlayTasks || typeof overlayTasks !== 'object') {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
const entry = overlayTasks[String(taskId)];
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
return entry.column === 'review' || entry.column === 'approved' ? entry.column : 'none';
|
||||
}
|
||||
|
||||
function withCompatibleReviewState(task, overlayTasks) {
|
||||
const explicit = normalizeTaskReviewState(task.reviewState);
|
||||
return explicit === 'none' ? { ...task, reviewState: getOverlayReviewState(overlayTasks, task.id) } : task;
|
||||
}
|
||||
|
||||
function listRawTasks(paths) {
|
||||
ensureDir(paths.tasksDir);
|
||||
const entries = fs.readdirSync(paths.tasksDir);
|
||||
const out = [];
|
||||
const overlayState = readJson(paths.kanbanPath, null);
|
||||
const overlayTasks =
|
||||
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
||||
? overlayState.tasks
|
||||
: null;
|
||||
|
||||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||
|
|
@ -107,7 +84,7 @@ function listRawTasks(paths) {
|
|||
if (!rawTask) continue;
|
||||
if (rawTask.metadata && rawTask.metadata._internal === true) continue;
|
||||
try {
|
||||
out.push(withCompatibleReviewState(normalizeTask(rawTask, filePath), overlayTasks));
|
||||
out.push(normalizeTask(rawTask, filePath));
|
||||
} catch {
|
||||
// Skip unreadable task rows.
|
||||
}
|
||||
|
|
@ -165,12 +142,7 @@ function readTask(paths, taskRef, options = {}) {
|
|||
if (!rawTask) {
|
||||
throw new Error(`Task not found: ${String(taskRef)}`);
|
||||
}
|
||||
const overlayState = readJson(paths.kanbanPath, null);
|
||||
const overlayTasks =
|
||||
overlayState && typeof overlayState === 'object' && overlayState.tasks && typeof overlayState.tasks === 'object'
|
||||
? overlayState.tasks
|
||||
: null;
|
||||
return withCompatibleReviewState(normalizeTask(rawTask, taskPath), overlayTasks);
|
||||
return normalizeTask(rawTask, taskPath);
|
||||
}
|
||||
|
||||
function createStatusTransition(history, from, to, actor, timestamp) {
|
||||
|
|
|
|||
|
|
@ -194,39 +194,6 @@ describe('agent-teams-controller API', () => {
|
|||
expect(second.linkedCommentsCreated).toBe(0);
|
||||
});
|
||||
|
||||
it('derives reviewState from legacy kanban overlay and tolerates corrupt kanban state', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Legacy review task' });
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
delete rawTask.reviewState;
|
||||
fs.writeFileSync(taskPath, JSON.stringify(rawTask, null, 2));
|
||||
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
teamName: 'my-team',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
[task.id]: { column: 'review', movedAt: '2026-01-01T00:00:00.000Z', reviewer: null },
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('review');
|
||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('review');
|
||||
|
||||
fs.writeFileSync(kanbanPath, '{broken-json');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('none');
|
||||
expect(controller.tasks.listTasks()[0].reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('tracks lifecycle history and intervals without duplicate same-status transitions', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -4,10 +4,8 @@
|
|||
* Responsibilities:
|
||||
* - Check if sessions have subagent files
|
||||
* - List subagent files for a session
|
||||
* - Handle both NEW and OLD subagent directory structures:
|
||||
* - NEW: {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||
* - OLD: {projectId}/agent-{agentId}.jsonl (legacy, still supported)
|
||||
* - Determine subagent ownership for OLD structure
|
||||
* - Handle the canonical subagent directory structure:
|
||||
* - {projectId}/{sessionId}/subagents/agent-{agentId}.jsonl
|
||||
*/
|
||||
|
||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||
|
|
@ -91,110 +89,25 @@ export class SubagentLocator {
|
|||
}
|
||||
|
||||
/**
|
||||
* Lists all subagent files for a session from both NEW and OLD structures.
|
||||
* Returns NEW structure files first, then OLD structure files.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID
|
||||
* @returns Promise resolving to array of file paths
|
||||
* Lists all subagent files for a session from the canonical session-local structure.
|
||||
*/
|
||||
async listSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||
const allFiles: string[] = [];
|
||||
|
||||
try {
|
||||
// Scan NEW structure: {projectId}/{sessionId}/subagents/agent-*.jsonl
|
||||
const newSubagentsPath = this.getSubagentsPath(projectId, sessionId);
|
||||
if (await this.fsProvider.exists(newSubagentsPath)) {
|
||||
const entries = await this.fsProvider.readdir(newSubagentsPath);
|
||||
const newFiles = entries
|
||||
return entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl')
|
||||
)
|
||||
.map((entry) => path.join(newSubagentsPath, entry.name));
|
||||
allFiles.push(...newFiles);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Error scanning NEW subagent structure for session ${sessionId}:`, error);
|
||||
logger.error(`Error scanning subagent structure for session ${sessionId}:`, error);
|
||||
}
|
||||
|
||||
try {
|
||||
// Scan OLD structure: {projectId}/agent-*.jsonl
|
||||
// Must filter by sessionId since all sessions share the same project root
|
||||
const oldFiles = await this.getProjectRootSubagentFiles(projectId, sessionId);
|
||||
allFiles.push(...oldFiles);
|
||||
} catch (error) {
|
||||
logger.error(`Error scanning OLD subagent structure for project ${projectId}:`, error);
|
||||
}
|
||||
|
||||
return allFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets subagent files from project root (OLD structure).
|
||||
* Scans {projectId}/agent-*.jsonl files and filters by sessionId.
|
||||
*
|
||||
* In the OLD structure, all subagent files are in the project root,
|
||||
* so we must read each file's first line to check if it belongs to the session.
|
||||
*
|
||||
* @param projectId - The project ID
|
||||
* @param sessionId - The session ID
|
||||
* @returns Promise resolving to array of file paths
|
||||
*/
|
||||
async getProjectRootSubagentFiles(projectId: string, sessionId: string): Promise<string[]> {
|
||||
try {
|
||||
const projectPath = path.join(this.projectsDir, extractBaseDir(projectId));
|
||||
|
||||
if (!(await this.fsProvider.exists(projectPath))) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = await this.fsProvider.readdir(projectPath);
|
||||
const agentFiles = entries
|
||||
.filter((entry) => entry.name.startsWith('agent-') && entry.name.endsWith('.jsonl'))
|
||||
.map((entry) => path.join(projectPath, entry.name));
|
||||
|
||||
// Filter files by checking if their sessionId matches
|
||||
const matchingFiles: string[] = [];
|
||||
for (const filePath of agentFiles) {
|
||||
if (await this.subagentBelongsToSession(filePath, sessionId)) {
|
||||
matchingFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
|
||||
return matchingFiles;
|
||||
} catch (error) {
|
||||
logger.error(`Error reading project root for subagent files:`, error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a subagent file belongs to a specific session by reading its first line.
|
||||
* Subagent files have a sessionId field that points to the parent session.
|
||||
*
|
||||
* @param filePath - Path to the subagent file
|
||||
* @param sessionId - The session ID to check
|
||||
* @returns Promise resolving to true if the subagent belongs to the session
|
||||
*/
|
||||
async subagentBelongsToSession(filePath: string, sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// Read just the first line to check sessionId
|
||||
const content = await this.fsProvider.readFile(filePath);
|
||||
const firstNewline = content.indexOf('\n');
|
||||
const firstLine = firstNewline > 0 ? content.slice(0, firstNewline) : content;
|
||||
|
||||
if (!firstLine.trim()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const entry = JSON.parse(firstLine) as { sessionId?: string };
|
||||
return entry.sessionId === sessionId;
|
||||
} catch (error) {
|
||||
// If we can't read or parse the file, don't include it - log for debugging
|
||||
logger.debug(`SubagentLocator: Could not parse file ${filePath}:`, error);
|
||||
return false;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -31,14 +31,9 @@ interface ToolUseInfo {
|
|||
filePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical fallback for legacy CLI sessions only.
|
||||
* New runtime sessions are expected to emit structured task updates via MCP/TaskUpdate.
|
||||
*/
|
||||
const TEAMCTL_TASK_REGEX = /task\s+(start|complete|set-status)\s+(\d+)/;
|
||||
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
|
||||
|
||||
type DetectedMechanism = 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
|
||||
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
||||
|
||||
function pickDetectedMechanism(
|
||||
current: DetectedMechanism,
|
||||
|
|
@ -46,9 +41,8 @@ function pickDetectedMechanism(
|
|||
): DetectedMechanism {
|
||||
const priority = {
|
||||
none: 0,
|
||||
teamctl: 1,
|
||||
TaskUpdate: 2,
|
||||
mcp: 3,
|
||||
TaskUpdate: 1,
|
||||
mcp: 2,
|
||||
} as const;
|
||||
return priority[next] > priority[current] ? next : current;
|
||||
}
|
||||
|
|
@ -123,13 +117,6 @@ export class TaskBoundaryParser {
|
|||
boundaries.push(...mcpBounds);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy CLI fallback for historical JSONL rows.
|
||||
const teamctlBounds = this.extractTeamctlBoundaries(content, lineNumber, timestamp);
|
||||
if (teamctlBounds.length > 0) {
|
||||
detectedMechanism = pickDetectedMechanism(detectedMechanism, 'teamctl');
|
||||
boundaries.push(...teamctlBounds);
|
||||
}
|
||||
} catch {
|
||||
// Пропускаем невалидные строки
|
||||
}
|
||||
|
|
@ -290,63 +277,6 @@ export class TaskBoundaryParser {
|
|||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Historical fallback: detect legacy teamctl task commands in Bash tool_use blocks.
|
||||
* Regex: /task\s+(start|complete|set-status)\s+(\d+)/
|
||||
*/
|
||||
private extractTeamctlBoundaries(
|
||||
content: unknown[],
|
||||
lineNumber: number,
|
||||
timestamp: string
|
||||
): TaskBoundary[] {
|
||||
const results: TaskBoundary[] = [];
|
||||
|
||||
for (const block of content) {
|
||||
if (!block || typeof block !== 'object') continue;
|
||||
const b = block as Record<string, unknown>;
|
||||
if (b.type !== 'tool_use') continue;
|
||||
|
||||
const rawName = typeof b.name === 'string' ? b.name : '';
|
||||
const toolName = rawName.replace(/^proxy_/, '');
|
||||
if (toolName !== 'Bash') continue;
|
||||
|
||||
const input = b.input as Record<string, unknown> | undefined;
|
||||
if (!input) continue;
|
||||
|
||||
const command = typeof input.command === 'string' ? input.command : '';
|
||||
if (!command.includes('teamctl')) continue;
|
||||
|
||||
const match = TEAMCTL_TASK_REGEX.exec(command);
|
||||
if (!match) continue;
|
||||
|
||||
const action = match[1]; // start | complete | set-status
|
||||
const taskId = match[2];
|
||||
|
||||
let event: TaskBoundaryEvent = null;
|
||||
if (action === 'start') event = 'start';
|
||||
else if (action === 'complete') event = 'complete';
|
||||
else if (action === 'set-status') {
|
||||
// set-status может быть start или complete — определяем по аргументам
|
||||
if (command.includes('in_progress') || command.includes('in-progress')) event = 'start';
|
||||
else if (command.includes('completed') || command.includes('done')) event = 'complete';
|
||||
}
|
||||
|
||||
if (event) {
|
||||
const toolUseId = typeof b.id === 'string' ? b.id : undefined;
|
||||
results.push({
|
||||
taskId,
|
||||
event,
|
||||
lineNumber,
|
||||
timestamp,
|
||||
mechanism: 'teamctl',
|
||||
toolUseId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Вычислить scopes для каждой задачи на основе границ.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,55 +0,0 @@
|
|||
import { getToolsBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { atomicWriteAsync } from './atomicWrite';
|
||||
|
||||
const TOOL_FILE_NAME = 'teamctl.js';
|
||||
|
||||
function getCandidateLegacyCliPaths(): string[] {
|
||||
const cwd = process.cwd();
|
||||
|
||||
return [
|
||||
path.join(cwd, 'agent-teams-controller', 'src', 'legacy', 'teamctl.cli.js'),
|
||||
path.join(cwd, 'agent-teams-controller', 'dist', 'legacy', 'teamctl.cli.js'),
|
||||
];
|
||||
}
|
||||
|
||||
async function readExtractedTeamctlSource(): Promise<string> {
|
||||
for (const candidatePath of getCandidateLegacyCliPaths()) {
|
||||
try {
|
||||
return await fs.promises.readFile(candidatePath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Extracted teamctl CLI source not found in agent-teams-controller package');
|
||||
}
|
||||
|
||||
export class TeamAgentToolsInstaller {
|
||||
async ensureInstalled(): Promise<string> {
|
||||
const toolsDir = getToolsBasePath();
|
||||
const toolPath = path.join(toolsDir, TOOL_FILE_NAME);
|
||||
await fs.promises.mkdir(toolsDir, { recursive: true });
|
||||
|
||||
const desired = await readExtractedTeamctlSource();
|
||||
let current: string | null = null;
|
||||
try {
|
||||
current = await fs.promises.readFile(toolPath, 'utf8');
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (current === desired) {
|
||||
return toolPath;
|
||||
}
|
||||
|
||||
await atomicWriteAsync(toolPath, desired);
|
||||
return toolPath;
|
||||
}
|
||||
}
|
||||
|
|
@ -107,20 +107,13 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
private resolveTaskReviewState(
|
||||
task: Pick<TeamTask, 'id' | 'reviewState'>,
|
||||
kanbanState?: Pick<KanbanState, 'tasks'>
|
||||
task: Pick<TeamTask, 'reviewState'>
|
||||
): 'none' | 'review' | 'approved' {
|
||||
const explicit = normalizeReviewState(task.reviewState);
|
||||
if (explicit !== 'none') {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
const overlay = kanbanState?.tasks?.[task.id]?.column;
|
||||
return overlay === 'review' || overlay === 'approved' ? overlay : 'none';
|
||||
return normalizeReviewState(task.reviewState);
|
||||
}
|
||||
|
||||
private attachKanbanCompatibility(task: TeamTask, kanbanState?: KanbanState): TeamTaskWithKanban {
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanState);
|
||||
private attachKanbanCompatibility(task: TeamTask): TeamTaskWithKanban {
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
return {
|
||||
...task,
|
||||
reviewState,
|
||||
|
|
@ -172,8 +165,7 @@ export class TeamDataService {
|
|||
continue;
|
||||
}
|
||||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const kanban = kanbanByTeam.get(task.teamName);
|
||||
const reviewState = this.resolveTaskReviewState(task, kanban);
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||
|
||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||
|
|
@ -348,8 +340,6 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
if (includeMessages) {
|
||||
this.ensureStableMessageIds(messages);
|
||||
|
||||
// Enrich inbox messages without leadSessionId by assigning the nearest neighbor's
|
||||
// session ID (by timestamp). This avoids the old forward-only propagation bug.
|
||||
if (config.leadSessionId || messages.some((m) => m.leadSessionId)) {
|
||||
|
|
@ -413,19 +403,17 @@ export class TeamDataService {
|
|||
reviewers: [],
|
||||
tasks: {},
|
||||
};
|
||||
let canRunKanbanGc = true;
|
||||
try {
|
||||
kanbanState = await this.kanbanManager.getState(teamName);
|
||||
} catch {
|
||||
warnings.push('Kanban state failed to load');
|
||||
canRunKanbanGc = false;
|
||||
}
|
||||
mark('kanbanState');
|
||||
|
||||
mark('kanbanGc');
|
||||
|
||||
const tasksWithKanban: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
||||
this.attachKanbanCompatibility(task)
|
||||
);
|
||||
|
||||
const members = this.memberResolver.resolveMembers(
|
||||
|
|
@ -444,7 +432,7 @@ export class TeamDataService {
|
|||
mark('syncComments');
|
||||
|
||||
const tasksToReturn: TeamTaskWithKanban[] = tasks.map((task) =>
|
||||
this.attachKanbanCompatibility(task, canRunKanbanGc ? kanbanState : undefined)
|
||||
this.attachKanbanCompatibility(task)
|
||||
);
|
||||
|
||||
let processes: TeamProcess[] = [];
|
||||
|
|
@ -1114,66 +1102,6 @@ export class TeamDataService {
|
|||
return normalized === leadName.trim().toLowerCase() || normalized === 'team-lead';
|
||||
}
|
||||
|
||||
private normalizeMessageIdPart(value: string | undefined, fallback = 'unknown'): string {
|
||||
const normalized = (value ?? '')
|
||||
.trim()
|
||||
.replace(/\r\n/g, '\n')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\p{L}\p{N}_-]/gu, '')
|
||||
.slice(0, 40);
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Older inbox/sent-message records may not include messageId. Assign deterministic ids
|
||||
* so renderer keys remain stable across refreshes, filtering, and live updates.
|
||||
*/
|
||||
private ensureStableMessageIds(messages: InboxMessage[]): void {
|
||||
const seenAssignedIds = new Set<string>();
|
||||
const missingIdOccurrences = new Map<string, number>();
|
||||
|
||||
for (const message of messages) {
|
||||
const existingId = typeof message.messageId === 'string' ? message.messageId.trim() : '';
|
||||
if (existingId) {
|
||||
seenAssignedIds.add(existingId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const textPrefix = this.normalizeMessageIdPart(message.text?.slice(0, 80), 'empty');
|
||||
const fingerprint = [
|
||||
message.source ?? 'unknown',
|
||||
message.timestamp,
|
||||
message.from,
|
||||
message.to ?? '',
|
||||
message.leadSessionId ?? '',
|
||||
textPrefix,
|
||||
].join('\0');
|
||||
const occurrence = (missingIdOccurrences.get(fingerprint) ?? 0) + 1;
|
||||
missingIdOccurrences.set(fingerprint, occurrence);
|
||||
|
||||
let syntheticId = [
|
||||
'synthetic-msg',
|
||||
this.normalizeMessageIdPart(message.source, 'unknown'),
|
||||
this.normalizeMessageIdPart(message.timestamp, 'unknown'),
|
||||
this.normalizeMessageIdPart(message.from, 'unknown'),
|
||||
this.normalizeMessageIdPart(message.to, 'none'),
|
||||
textPrefix,
|
||||
occurrence,
|
||||
].join('-');
|
||||
|
||||
if (seenAssignedIds.has(syntheticId)) {
|
||||
let collision = 2;
|
||||
while (seenAssignedIds.has(`${syntheticId}-dup${collision}`)) {
|
||||
collision++;
|
||||
}
|
||||
syntheticId = `${syntheticId}-dup${collision}`;
|
||||
}
|
||||
|
||||
message.messageId = syntheticId;
|
||||
seenAssignedIds.add(syntheticId);
|
||||
}
|
||||
}
|
||||
|
||||
async sendDirectToLead(
|
||||
teamName: string,
|
||||
leadName: string,
|
||||
|
|
|
|||
|
|
@ -309,8 +309,7 @@ export class TeamMemberLogsFinder {
|
|||
|
||||
/**
|
||||
* Fast marker probe for task-related logs.
|
||||
* Prefer structured MCP/TaskUpdate markers for modern sessions; keep teamctl text matching
|
||||
* only as historical fallback for old JSONL data.
|
||||
* Prefer structured MCP/TaskUpdate markers for modern sessions.
|
||||
*/
|
||||
async hasTaskUpdateMarker(filePath: string, taskId: string): Promise<boolean> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
|
|
@ -336,11 +335,6 @@ export class TeamMemberLogsFinder {
|
|||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
if (line.includes('teamctl') && line.includes('task') && line.includes(taskId)) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore read errors
|
||||
|
|
@ -508,18 +502,6 @@ export class TeamMemberLogsFinder {
|
|||
return typeof raw === 'string' ? raw.trim() : null;
|
||||
};
|
||||
|
||||
const matchesTeamctlCommand = (command: string): boolean => {
|
||||
if (!/\bteamctl(?:\.js)?\b/i.test(command)) return false;
|
||||
|
||||
const teamMatch = /\s--team(?:\s+|=)(?:"([^"]+)"|'([^']+)'|([^\s]+))/i.exec(command);
|
||||
const cmdTeam = (teamMatch?.[1] ?? teamMatch?.[2] ?? teamMatch?.[3])?.trim();
|
||||
if (cmdTeam?.toLowerCase() !== teamLower) return false;
|
||||
|
||||
const taskMatch = /\btask\s+(?:start|complete|set-status)\s+(\d+)\b/i.exec(command);
|
||||
const cmdTaskId = taskMatch?.[1];
|
||||
return Boolean(cmdTaskId && cmdTaskId === taskIdStr);
|
||||
};
|
||||
|
||||
const matchesTeamMentionText = (text: string): boolean => {
|
||||
const t = text.toLowerCase();
|
||||
if (!t.includes(teamLower)) return false;
|
||||
|
|
@ -631,16 +613,6 @@ export class TeamMemberLogsFinder {
|
|||
taskSeenWithoutTeam = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Deterministic CLI match: teamctl command line (Bash tool).
|
||||
if (toolName === 'Bash') {
|
||||
const command = typeof input.command === 'string' ? input.command : '';
|
||||
if (command && matchesTeamctlCommand(command)) {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (teamSeen && taskSeenWithoutTeam) {
|
||||
|
|
|
|||
|
|
@ -10,13 +10,6 @@ const logger = createLogger('Service:TeamTaskAttachmentStore');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB
|
||||
|
||||
const KNOWN_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set<string>([
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/gif',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
export class TeamTaskAttachmentStore {
|
||||
private assertSafePathSegment(label: string, value: string): void {
|
||||
if (
|
||||
|
|
@ -42,7 +35,7 @@ export class TeamTaskAttachmentStore {
|
|||
|
||||
private sanitizeStoredFilename(original: string): string {
|
||||
const raw = String(original ?? '').trim();
|
||||
const base = raw ? raw.split(/[\\/]/).pop() ?? raw : '';
|
||||
const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : '';
|
||||
const cleaned = base
|
||||
.replace(/\0/g, '')
|
||||
.replace(/[\r\n\t]/g, ' ')
|
||||
|
|
@ -65,42 +58,15 @@ export class TeamTaskAttachmentStore {
|
|||
return path.join(this.getTaskDir(teamName, taskId), `${attachmentId}--${safeName}`);
|
||||
}
|
||||
|
||||
/** Map known MIME types to file extension (legacy storage format). */
|
||||
private mimeToExt(mimeType: string): string {
|
||||
switch (mimeType) {
|
||||
case 'image/png':
|
||||
return '.png';
|
||||
case 'image/jpeg':
|
||||
return '.jpg';
|
||||
case 'image/gif':
|
||||
return '.gif';
|
||||
case 'image/webp':
|
||||
return '.webp';
|
||||
default:
|
||||
return '.bin';
|
||||
}
|
||||
}
|
||||
|
||||
private async findAttachmentFilePath(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
attachmentId: string,
|
||||
mimeType?: string
|
||||
_mimeType?: string
|
||||
): Promise<string | null> {
|
||||
const dir = this.getTaskDir(teamName, taskId);
|
||||
|
||||
// 1) Prefer legacy path for known image types (older storage format).
|
||||
if (mimeType && KNOWN_IMAGE_MIME_TYPES.has(mimeType)) {
|
||||
const legacy = path.join(dir, `${attachmentId}${this.mimeToExt(mimeType)}`);
|
||||
try {
|
||||
const stat = await fs.promises.stat(legacy);
|
||||
if (stat.isFile()) return legacy;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 2) New format: "<id>--<filename>"
|
||||
// Canonical format: "<id>--<filename>"
|
||||
try {
|
||||
const entries = await fs.promises.readdir(dir);
|
||||
const prefix = `${attachmentId}--`;
|
||||
|
|
@ -108,13 +74,6 @@ export class TeamTaskAttachmentStore {
|
|||
if (matches.length > 0) {
|
||||
return path.join(dir, matches[0]);
|
||||
}
|
||||
|
||||
// 3) Fallback: any file starting with "<id>." (covers legacy when mimeType missing/wrong).
|
||||
const dotPrefix = `${attachmentId}.`;
|
||||
const dotMatches = entries.filter((e) => e.startsWith(dotPrefix));
|
||||
if (dotMatches.length > 0) {
|
||||
return path.join(dir, dotMatches[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null;
|
||||
// Non-directory or other IO errors should surface.
|
||||
|
|
|
|||
|
|
@ -103,24 +103,10 @@ export class TeamTaskReader {
|
|||
if (metadata?._internal === true) {
|
||||
continue;
|
||||
}
|
||||
// CLI sometimes writes "title" instead of "subject" — normalize
|
||||
const subject =
|
||||
typeof parsed.subject === 'string'
|
||||
? parsed.subject
|
||||
: typeof parsed.title === 'string'
|
||||
? parsed.title
|
||||
: '';
|
||||
// Resolve createdAt: prefer JSON field, fallback to fs.stat (reuse fileStat from above)
|
||||
let createdAt: string | undefined;
|
||||
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||
const createdAt = typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined;
|
||||
let updatedAt: string | undefined;
|
||||
if (typeof parsed.createdAt === 'string') {
|
||||
createdAt = parsed.createdAt;
|
||||
}
|
||||
try {
|
||||
if (!createdAt) {
|
||||
const bt = fileStat.birthtime.getTime();
|
||||
createdAt = (bt > 0 ? fileStat.birthtime : fileStat.mtime).toISOString();
|
||||
}
|
||||
updatedAt = fileStat.mtime.toISOString();
|
||||
} catch {
|
||||
/* leave undefined */
|
||||
|
|
@ -354,12 +340,7 @@ export class TeamTaskReader {
|
|||
continue;
|
||||
}
|
||||
|
||||
const subject =
|
||||
typeof parsed.subject === 'string'
|
||||
? parsed.subject
|
||||
: typeof parsed.title === 'string'
|
||||
? parsed.title
|
||||
: '';
|
||||
const subject = typeof parsed.subject === 'string' ? parsed.subject : '';
|
||||
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ export { HunkSnippetMatcher } from './HunkSnippetMatcher';
|
|||
export { MemberStatsComputer } from './MemberStatsComputer';
|
||||
export { ReviewApplierService } from './ReviewApplierService';
|
||||
export { TaskBoundaryParser } from './TaskBoundaryParser';
|
||||
export { TeamAgentToolsInstaller } from './TeamAgentToolsInstaller';
|
||||
export { TeamAttachmentStore } from './TeamAttachmentStore';
|
||||
export { TeamConfigReader } from './TeamConfigReader';
|
||||
export { TeamDataService } from './TeamDataService';
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import {
|
|||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { type Diagnostic, linter, lintGutter } from '@codemirror/lint';
|
||||
import { lintGutter } from '@codemirror/lint';
|
||||
import { search, searchKeymap } from '@codemirror/search';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
|
|
@ -29,7 +29,7 @@ import {
|
|||
} from '@codemirror/view';
|
||||
import { api } from '@renderer/api';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { baseEditorTheme } from '@renderer/utils/codemirrorTheme';
|
||||
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
|
||||
import { AlertTriangle, Check, Loader2, X } from 'lucide-react';
|
||||
|
||||
import type { AppConfig } from '@renderer/types/data';
|
||||
|
|
@ -40,31 +40,6 @@ import type { AppConfig } from '@renderer/types/data';
|
|||
|
||||
const SAVE_DEBOUNCE_MS = 800;
|
||||
|
||||
// =============================================================================
|
||||
// JSON Linter
|
||||
// =============================================================================
|
||||
|
||||
const jsonLinter = linter((view: EditorView) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const text = view.state.doc.toString();
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const match = /position (\d+)/.exec(e.message);
|
||||
const pos = match ? parseInt(match[1], 10) : 0;
|
||||
const safePos = Math.min(pos, text.length);
|
||||
diagnostics.push({
|
||||
from: safePos,
|
||||
to: Math.min(safePos + 1, text.length),
|
||||
severity: 'error',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// Types
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -3,10 +3,23 @@ import React, { useEffect, useRef } from 'react';
|
|||
import { closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
|
||||
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands';
|
||||
import { json } from '@codemirror/lang-json';
|
||||
import { bracketMatching, defaultHighlightStyle, syntaxHighlighting } from '@codemirror/language';
|
||||
import {
|
||||
bracketMatching,
|
||||
foldGutter,
|
||||
indentOnInput,
|
||||
syntaxHighlighting,
|
||||
} from '@codemirror/language';
|
||||
import { lintGutter } from '@codemirror/lint';
|
||||
import { EditorState } from '@codemirror/state';
|
||||
import { oneDark } from '@codemirror/theme-one-dark';
|
||||
import { EditorView, keymap, lineNumbers } from '@codemirror/view';
|
||||
import { oneDarkHighlightStyle } from '@codemirror/theme-one-dark';
|
||||
import {
|
||||
EditorView,
|
||||
highlightActiveLine,
|
||||
highlightActiveLineGutter,
|
||||
keymap,
|
||||
lineNumbers,
|
||||
} from '@codemirror/view';
|
||||
import { baseEditorTheme, jsonLinter } from '@renderer/utils/codemirrorTheme';
|
||||
|
||||
interface MembersJsonEditorProps {
|
||||
value: string;
|
||||
|
|
@ -14,6 +27,16 @@ interface MembersJsonEditorProps {
|
|||
error: string | null;
|
||||
}
|
||||
|
||||
const membersEditorTheme = EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
});
|
||||
|
||||
export const MembersJsonEditor = ({
|
||||
value,
|
||||
onChange,
|
||||
|
|
@ -31,30 +54,25 @@ export const MembersJsonEditor = ({
|
|||
doc: value,
|
||||
extensions: [
|
||||
json(),
|
||||
oneDark,
|
||||
lineNumbers(),
|
||||
highlightActiveLineGutter(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
foldGutter(),
|
||||
indentOnInput(),
|
||||
bracketMatching(),
|
||||
closeBrackets(),
|
||||
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||
syntaxHighlighting(oneDarkHighlightStyle),
|
||||
jsonLinter,
|
||||
lintGutter(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, ...closeBracketsKeymap]),
|
||||
baseEditorTheme,
|
||||
membersEditorTheme,
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (update.docChanged) {
|
||||
onChangeRef.current(update.state.doc.toString());
|
||||
}
|
||||
}),
|
||||
EditorView.theme({
|
||||
'&': {
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
},
|
||||
'.cm-scroller': {
|
||||
overflow: 'auto',
|
||||
},
|
||||
'.cm-content': {
|
||||
fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, monospace',
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
/**
|
||||
* Base CodeMirror 6 theme using CSS variables.
|
||||
* Base CodeMirror 6 theme and shared extensions.
|
||||
*
|
||||
* Extracted from CodeMirrorDiffView.tsx — shared between diff view and project editor.
|
||||
* Extracted from CodeMirrorDiffView.tsx — shared between diff view, config editor, and member editor.
|
||||
* Diff-specific styles (changedLine, deletedChunk, merge toolbar) stay in CodeMirrorDiffView.
|
||||
*/
|
||||
|
||||
import { type Diagnostic, linter } from '@codemirror/lint';
|
||||
import { EditorView } from '@codemirror/view';
|
||||
|
||||
/** Base editor theme — general styling without diff-specific rules */
|
||||
|
|
@ -50,4 +51,88 @@ export const baseEditorTheme = EditorView.theme({
|
|||
'.cm-selectionBackground': {
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.3) !important',
|
||||
},
|
||||
|
||||
/* ---- Lint tooltips & diagnostics (dark-theme aware) ---- */
|
||||
'.cm-tooltip': {
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
border: '1px solid var(--color-border-emphasis)',
|
||||
borderRadius: '6px',
|
||||
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.4)',
|
||||
},
|
||||
'.cm-tooltip-lint': {
|
||||
padding: '0',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
},
|
||||
'.cm-diagnostic': {
|
||||
padding: '6px 10px',
|
||||
borderLeft: '3px solid transparent',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.4',
|
||||
},
|
||||
'.cm-diagnostic-error': {
|
||||
borderLeftColor: '#ef4444',
|
||||
color: '#fca5a5',
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.08)',
|
||||
},
|
||||
'.cm-diagnostic-warning': {
|
||||
borderLeftColor: '#f59e0b',
|
||||
color: '#fcd34d',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.08)',
|
||||
},
|
||||
'.cm-diagnostic-info': {
|
||||
borderLeftColor: '#3b82f6',
|
||||
color: '#93c5fd',
|
||||
backgroundColor: 'rgba(59, 130, 246, 0.08)',
|
||||
},
|
||||
'.cm-lintRange-error': {
|
||||
backgroundImage: 'none',
|
||||
textDecoration: 'underline wavy #ef4444',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
'.cm-lintRange-warning': {
|
||||
backgroundImage: 'none',
|
||||
textDecoration: 'underline wavy #f59e0b',
|
||||
textUnderlineOffset: '3px',
|
||||
},
|
||||
|
||||
/* ---- Search panel (dark-theme aware) ---- */
|
||||
'.cm-panels': {
|
||||
backgroundColor: 'var(--color-surface-raised)',
|
||||
color: 'var(--color-text)',
|
||||
borderTop: '1px solid var(--color-border)',
|
||||
},
|
||||
'.cm-panel input, .cm-panel button': {
|
||||
color: 'inherit',
|
||||
},
|
||||
'.cm-panel input[type="text"]': {
|
||||
backgroundColor: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: '4px',
|
||||
padding: '2px 6px',
|
||||
color: 'var(--color-text)',
|
||||
},
|
||||
});
|
||||
|
||||
/** Shared JSON linter — validates JSON and reports syntax errors inline. */
|
||||
export const jsonLinter = linter((view: EditorView) => {
|
||||
const diagnostics: Diagnostic[] = [];
|
||||
const text = view.state.doc.toString();
|
||||
try {
|
||||
JSON.parse(text);
|
||||
} catch (e) {
|
||||
if (e instanceof SyntaxError) {
|
||||
const match = /position (\d+)/.exec(e.message);
|
||||
const pos = match ? parseInt(match[1], 10) : 0;
|
||||
const safePos = Math.min(pos, text.length);
|
||||
diagnostics.push({
|
||||
from: safePos,
|
||||
to: Math.min(safePos + 1, text.length),
|
||||
severity: 'error',
|
||||
message: e.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
});
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ export interface TaskBoundary {
|
|||
event: 'start' | 'complete';
|
||||
lineNumber: number;
|
||||
timestamp: string;
|
||||
mechanism: 'TaskUpdate' | 'teamctl' | 'mcp';
|
||||
mechanism: 'TaskUpdate' | 'mcp';
|
||||
toolUseId?: string;
|
||||
}
|
||||
|
||||
|
|
@ -158,7 +158,7 @@ export interface TaskBoundariesResult {
|
|||
boundaries: TaskBoundary[];
|
||||
scopes: TaskChangeScope[];
|
||||
isSingleTaskSession: boolean;
|
||||
detectedMechanism: 'TaskUpdate' | 'teamctl' | 'mcp' | 'none';
|
||||
detectedMechanism: 'TaskUpdate' | 'mcp' | 'none';
|
||||
}
|
||||
|
||||
/** Расширенный TaskChangeSet с confidence деталями (backwards compatible) */
|
||||
|
|
|
|||
|
|
@ -64,55 +64,7 @@ describe('TaskBoundaryParser', () => {
|
|||
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to legacy teamctl bash parsing for historical logs', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
||||
const jsonlPath = path.join(tmpDir, 'teamctl.jsonl');
|
||||
await fs.writeFile(
|
||||
jsonlPath,
|
||||
[
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-1',
|
||||
name: 'Bash',
|
||||
input: { command: 'node "teamctl.js" --team demo task start 123' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
timestamp: '2026-03-01T10:10:00.000Z',
|
||||
type: 'assistant',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-2',
|
||||
name: 'Bash',
|
||||
input: { command: 'node "teamctl.js" --team demo task set-status 123 completed' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
|
||||
|
||||
expect(result.detectedMechanism).toBe('teamctl');
|
||||
expect(result.boundaries).toHaveLength(2);
|
||||
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
|
||||
expect(result.boundaries.every((entry) => entry.mechanism === 'teamctl')).toBe(true);
|
||||
});
|
||||
|
||||
it('prefers structured mechanisms over legacy teamctl in mixed logs', async () => {
|
||||
it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
||||
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
|
||||
await fs.writeFile(
|
||||
|
|
@ -155,5 +107,7 @@ describe('TaskBoundaryParser', () => {
|
|||
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
|
||||
|
||||
expect(result.detectedMechanism).toBe('mcp');
|
||||
expect(result.boundaries).toHaveLength(1);
|
||||
expect(result.boundaries[0]?.mechanism).toBe('mcp');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -589,7 +589,7 @@ describe('TeamMemberLogsFinder', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('detects structured task markers while keeping legacy teamctl matching as fallback', async () => {
|
||||
it('detects structured task markers and ignores legacy teamctl command lines', async () => {
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-'));
|
||||
|
||||
const structuredPath = path.join(tmpDir, 'structured.jsonl');
|
||||
|
|
@ -646,7 +646,7 @@ describe('TeamMemberLogsFinder', () => {
|
|||
const finder = new TeamMemberLogsFinder();
|
||||
|
||||
await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true);
|
||||
await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(true);
|
||||
await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(false);
|
||||
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue