From 572cfab1a589c4931af37571638eae030e21bd23 Mon Sep 17 00:00:00 2001 From: iliya Date: Tue, 17 Mar 2026 13:53:29 +0200 Subject: [PATCH] feat: enhance attachment handling and metadata management - Added filePath to attachment metadata in various components to support file access. - Updated saveTaskAttachmentFile and related functions to include file paths for stored attachments. - Enhanced documentation and comments to clarify the importance of using MCP tools for task review operations. - Improved UI components to display file paths where applicable, ensuring better user experience with attachments. --- .../src/internal/runtimeHelpers.js | 1 + agent-teams-controller/src/internal/tasks.js | 1 + mcp-server/src/tools/taskTools.ts | 15 +- src/main/ipc/teams.ts | 37 ++- src/main/services/team/TeamAttachmentStore.ts | 210 ++++++++++++++++-- .../services/team/TeamProvisioningService.ts | 8 + .../services/team/TeamTaskAttachmentStore.ts | 1 + .../attachments/SourceMessageAttachments.tsx | 1 + .../team/messages/MessagesPanel.tsx | 7 +- src/renderer/constants/teamColors.ts | 10 +- src/shared/types/team.ts | 14 +- 11 files changed, 261 insertions(+), 44 deletions(-) diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 78d19b90..dae273fb 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -440,6 +440,7 @@ function saveTaskAttachmentFile(paths, taskId, flags) { mimeType, size: stats.size, addedAt: nowIso(), + filePath: destPath, }; return { diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 1a211c0a..d4eff2ac 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -393,6 +393,7 @@ function buildMemberTaskProtocol(teamName) { This is MANDATORY before review_approve or review_request_changes. Without this step, the kanban board may not show the task in REVIEW during your review. 4. If you are asked to review and the task is accepted, move it to APPROVED (not DONE) with MCP tool review_approve: { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true } + CRITICAL: Text comments like "approved" or "LGTM" do NOT change the kanban board. You MUST call review_approve to move a task from REVIEW to APPROVED. Without the tool call the task stays stuck in the REVIEW column. 5. If review fails and changes are needed, use MCP tool review_request_changes: { teamName: "${teamName}", taskId: "", comment: "" } 6. NEVER skip status updates. A task is NOT done until completed status is written. diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 3c2ee87a..bbac5d51 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -170,7 +170,7 @@ export function registerTaskTools(server: Pick) { ...(source ? { source } : {}), }; - // Preserve attachment metadata by reference only — no blob copying + // Preserve attachment metadata — filePath included when available if (Array.isArray(message.attachments) && message.attachments.length > 0) { sourceMessage.attachments = (message.attachments as Record[]) .filter( @@ -185,6 +185,7 @@ export function registerTaskTools(server: Pick) { filename: String(a.filename), mimeType: typeof a.mimeType === 'string' ? a.mimeType : '', size: typeof a.size === 'number' ? a.size : 0, + ...(typeof a.filePath === 'string' ? { filePath: a.filePath } : {}), })); } @@ -212,7 +213,11 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_get', - description: 'Get a task by id', + description: + 'Get a task by id. Response includes:\n' + + '- sourceMessage.attachments: from original user message (filePath = absolute path to file on disk, use Read tool to view)\n' + + '- attachments: files attached to the task (filePath = absolute path, use Read tool to view)\n' + + '- comments[].attachments: files on comments (filePath = absolute path, use Read tool to view)', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -314,7 +319,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_file', - description: 'Attach a file to a task', + description: + 'Attach a file to a task. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), @@ -351,7 +357,8 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_attach_comment_file', - description: 'Attach a file to a task comment', + description: + 'Attach a file to a task comment. Returns attachment metadata with filePath for future access via Read tool.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index a2693474..e0847b54 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -1247,12 +1247,30 @@ async function handleSendMessage( } if (stdinSent) { - const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => ({ - id: a.id, - filename: a.filename, - mimeType: a.mimeType, - size: a.size, - })); + // Save attachment files to disk FIRST to get file paths for metadata + let attachmentFilePaths: Map | undefined; + if (validatedAttachments?.length) { + try { + attachmentFilePaths = await attachmentStore.saveAttachments( + tn, + preGeneratedMessageId, + validatedAttachments + ); + } catch (e) { + logger.warn(`Failed to save attachments: ${e}`); + } + } + + const attachmentMeta: AttachmentMeta[] | undefined = validatedAttachments?.map((a) => { + const fp = attachmentFilePaths?.get(a.id); + return { + id: a.id, + filename: a.filename, + mimeType: a.mimeType, + size: a.size, + ...(fp ? { filePath: fp } : {}), + }; + }); // Persistence is best-effort — stdin already delivered the message let result: SendMessageResult; @@ -1271,12 +1289,7 @@ async function handleSendMessage( result = { deliveredToInbox: false, messageId: preGeneratedMessageId }; } - // Save attachment binary data to disk (best-effort) - if (validatedAttachments?.length && result.messageId) { - void attachmentStore - .saveAttachments(tn, result.messageId, validatedAttachments) - .catch((e) => logger.warn(`Failed to save attachments: ${e}`)); - } + // Attachment files already saved above (before metadata construction) provisioning.pushLiveLeadProcessMessage(tn, { from: 'user', diff --git a/src/main/services/team/TeamAttachmentStore.ts b/src/main/services/team/TeamAttachmentStore.ts index 998139f4..675080e5 100644 --- a/src/main/services/team/TeamAttachmentStore.ts +++ b/src/main/services/team/TeamAttachmentStore.ts @@ -3,18 +3,27 @@ import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs'; import * as path from 'path'; -import { atomicWriteAsync } from './atomicWrite'; - import type { AttachmentFileData, AttachmentPayload } from '@shared/types'; const logger = createLogger('Service:TeamAttachmentStore'); -const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB safety cap +const MAX_ATTACHMENT_SIZE = 20 * 1024 * 1024; // 20 MB per file +const MAX_ATTACHMENTS_FILE_BYTES = 64 * 1024 * 1024; // 64MB legacy JSON cap + +/** Per-attachment metadata stored in the index file. */ +interface StoredAttachmentIndex { + id: string; + filename: string; + mimeType: string; +} export class TeamAttachmentStore { private assertSafePathSegment(label: string, value: string): void { if ( value.length === 0 || + value.trim().length === 0 || + value === '.' || + value === '..' || value.includes('/') || value.includes('\\') || value.includes('..') || @@ -24,37 +33,204 @@ export class TeamAttachmentStore { } } - private getDir(teamName: string): string { + private sanitizeStoredFilename(original: string): string { + const raw = String(original ?? '').trim(); + const base = raw ? (raw.split(/[\\/]/).pop() ?? raw) : ''; + const cleaned = base + .replace(/\0/g, '') + .replace(/[\r\n\t]/g, ' ') + .replace(/[\\/]/g, '_') + .trim(); + if (!cleaned) return 'attachment'; + return cleaned.length > 180 ? cleaned.slice(0, 180) : cleaned; + } + + /** Base directory for all message attachments of a team. */ + private getTeamDir(teamName: string): string { this.assertSafePathSegment('teamName', teamName); return path.join(getAppDataPath(), 'attachments', teamName); } - private getFilePath(teamName: string, messageId: string): string { + /** Directory for a specific message's attachments (new file-based format). */ + private getMessageDir(teamName: string, messageId: string): string { this.assertSafePathSegment('messageId', messageId); - return path.join(this.getDir(teamName), `${messageId}.json`); + return path.join(this.getTeamDir(teamName), messageId); } + /** Path to the metadata index file inside a message attachment directory. */ + private getIndexPath(teamName: string, messageId: string): string { + return path.join(this.getMessageDir(teamName, messageId), '_index.json'); + } + + /** Legacy JSON bundle path (old format). */ + private getLegacyFilePath(teamName: string, messageId: string): string { + this.assertSafePathSegment('messageId', messageId); + return path.join(this.getTeamDir(teamName), `${messageId}.json`); + } + + /** Stored file path for an individual attachment. */ + private getStoredFilePath( + teamName: string, + messageId: string, + attachmentId: string, + filename: string + ): string { + this.assertSafePathSegment('attachmentId', attachmentId); + const safeName = this.sanitizeStoredFilename(filename); + return path.join(this.getMessageDir(teamName, messageId), `${attachmentId}--${safeName}`); + } + + /** + * Save message attachments as individual files on disk. + * Returns a Map of attachmentId → absolute file path for each saved file. + */ async saveAttachments( teamName: string, messageId: string, attachments: AttachmentPayload[] - ): Promise { - if (attachments.length === 0) return; + ): Promise> { + const filePaths = new Map(); + if (attachments.length === 0) return filePaths; - const fileData: AttachmentFileData[] = attachments.map((a) => ({ - id: a.id, - data: a.data, - mimeType: a.mimeType, - })); + const dir = this.getMessageDir(teamName, messageId); + await fs.promises.mkdir(dir, { recursive: true }); + + const indexEntries: StoredAttachmentIndex[] = []; + + for (const att of attachments) { + const buffer = Buffer.from(att.data, 'base64'); + if (buffer.length > MAX_ATTACHMENT_SIZE) { + logger.warn( + `[${teamName}] Skipping oversized attachment ${att.id} (${(buffer.length / (1024 * 1024)).toFixed(1)} MB)` + ); + continue; + } + + const storedPath = this.getStoredFilePath(teamName, messageId, att.id, att.filename); + try { + await fs.promises.writeFile(storedPath, buffer); + } catch (writeError) { + logger.warn(`[${teamName}] Failed to write attachment ${att.id}: ${writeError}`); + continue; + } + filePaths.set(att.id, storedPath); + + indexEntries.push({ + id: att.id, + filename: att.filename, + mimeType: att.mimeType, + }); + } + + // Write metadata index for successful files (mimeType, original filename) + if (indexEntries.length > 0) { + const indexPath = this.getIndexPath(teamName, messageId); + await fs.promises.writeFile(indexPath, JSON.stringify(indexEntries, null, 2)); + } - await atomicWriteAsync(this.getFilePath(teamName, messageId), JSON.stringify(fileData)); logger.debug( - `[${teamName}] Saved ${attachments.length} attachment(s) for message ${messageId}` + `[${teamName}] Saved ${filePaths.size} attachment file(s) for message ${messageId}` ); + return filePaths; } + /** + * Returns a Map of attachmentId → absolute file path. + * Only available for new file-based format. Legacy JSON bundles have no individual file paths. + */ + async getAttachmentFilePaths(teamName: string, messageId: string): Promise> { + const result = new Map(); + + // Try new file-based format first + const dir = this.getMessageDir(teamName, messageId); + try { + const entries = await fs.promises.readdir(dir); + for (const entry of entries) { + if (entry === '_index.json') continue; + const dashIdx = entry.indexOf('--'); + if (dashIdx > 0) { + const attachmentId = entry.slice(0, dashIdx); + result.set(attachmentId, path.join(dir, entry)); + } + } + if (result.size > 0) return result; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + // No new-format files found — not available for legacy format + return result; + } + + /** + * Read attachment data (base64) for rendering in UI. + * Supports both new file-based format and legacy JSON bundle. + */ async getAttachments(teamName: string, messageId: string): Promise { - const filePath = this.getFilePath(teamName, messageId); + // Try new file-based format first + const newResult = await this.readNewFormatAttachments(teamName, messageId); + if (newResult !== null) return newResult; + + // Fallback to legacy JSON format + return this.readLegacyAttachments(teamName, messageId); + } + + /** Read attachments from new file-based directory format. */ + private async readNewFormatAttachments( + teamName: string, + messageId: string + ): Promise { + const indexPath = this.getIndexPath(teamName, messageId); + + let indexRaw: string; + try { + indexRaw = await fs.promises.readFile(indexPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw error; + } + + let index: StoredAttachmentIndex[]; + try { + const parsed = JSON.parse(indexRaw) as unknown; + if (!Array.isArray(parsed)) return null; + index = parsed as StoredAttachmentIndex[]; + } catch { + return null; + } + + const result: AttachmentFileData[] = []; + for (const entry of index) { + if (!entry || typeof entry.id !== 'string') continue; + const filename = + typeof entry.filename === 'string' && entry.filename ? entry.filename : 'attachment'; + const mimeType = + typeof entry.mimeType === 'string' && entry.mimeType + ? entry.mimeType + : 'application/octet-stream'; + const filePath = this.getStoredFilePath(teamName, messageId, entry.id, filename); + try { + const buffer = await fs.promises.readFile(filePath); + result.push({ + id: entry.id, + data: buffer.toString('base64'), + mimeType, + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + } + + return result; + } + + /** Read attachments from legacy JSON bundle format. */ + private async readLegacyAttachments( + teamName: string, + messageId: string + ): Promise { + const filePath = this.getLegacyFilePath(teamName, messageId); let raw: string; try { @@ -103,5 +279,5 @@ export class TeamAttachmentStore { } // TODO: add deleteAttachments(teamName, messageId) for cleanup on failed/cancelled sends. - // Best-effort removal of the attachment JSON file — useful for retry/cancel flows. + // Best-effort removal of attachment files — useful for retry/cancel flows. } diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 7330f878..5acfae5c 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -558,6 +558,13 @@ function buildTeamCtlOpsInstructions(teamName: string, leadName: string): string `- Link related: task_link { teamName: "${teamName}", taskId: "", targetId: "", relationship: "related" }`, `- Unlink: task_unlink { teamName: "${teamName}", taskId: "", targetId: "", relationship: "blocked-by" }`, ``, + `Review operations — use MCP tools directly (text comments do NOT change kanban state):`, + `- Request review (after task_complete): review_request { teamName: "${teamName}", taskId: "", from: "${leadName}", reviewer: "" }`, + `- Start review (reviewer signals they are beginning): review_start { teamName: "${teamName}", taskId: "", from: "" }`, + `- Approve review: review_approve { teamName: "${teamName}", taskId: "", note?: "", notifyOwner: true }`, + `- Request changes: review_request_changes { teamName: "${teamName}", taskId: "", comment: "" }`, + `CRITICAL: Writing "approved" or "LGTM" as a task comment does NOT move the task on the kanban board. You MUST call the review_approve MCP tool. Without the tool call the task stays stuck in the REVIEW column.`, + ``, `Attachment storage modes (IMPORTANT):`, `- Default is copy (safe, robust).`, `- Use mode: "link" to try a hardlink (no duplication). It may fall back to copy unless you disable fallback.`, @@ -831,6 +838,7 @@ function buildProvisioningPrompt(request: TeamCreateRequest): string { - If a task is blocked (uses blockedBy), it MUST be created as pending (for example with task_create + startImmediately: false). Do NOT mark blocked tasks in_progress. - Review guidance: - Prefer NOT creating a separate "review task". Our workflow reviews the work task itself: call review_start when beginning review, then review_approve/review_request_changes on the implementation task #X. + - CRITICAL: Text comments ("approved", "LGTM") do NOT move the task on the kanban board. You MUST call the MCP tool review_approve to move from REVIEW to APPROVED. Without the tool call, the task stays stuck in REVIEW. - If you MUST create a separate review reminder/assignment task, create it as pending and link it to the work task: - Use related to connect it to #X (non-blocking link). - If the review truly cannot start until #X is done, ALSO add blockedBy #X. diff --git a/src/main/services/team/TeamTaskAttachmentStore.ts b/src/main/services/team/TeamTaskAttachmentStore.ts index 45d95a4f..7e509f2a 100644 --- a/src/main/services/team/TeamTaskAttachmentStore.ts +++ b/src/main/services/team/TeamTaskAttachmentStore.ts @@ -124,6 +124,7 @@ export class TeamTaskAttachmentStore { mimeType, size: buffer.length, addedAt: new Date().toISOString(), + filePath, }; logger.debug(`[${teamName}] Saved task attachment ${attachmentId} for task #${taskId}`); diff --git a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx index 46f6f72d..7e162eef 100644 --- a/src/renderer/components/team/attachments/SourceMessageAttachments.tsx +++ b/src/renderer/components/team/attachments/SourceMessageAttachments.tsx @@ -23,6 +23,7 @@ export const SourceMessageAttachments = ({ filename: a.filename, mimeType: a.mimeType, size: a.size, + ...(a.filePath ? { filePath: a.filePath } : {}), })); const truncatedText = diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index 9ae749bb..1bc7c59d 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -9,7 +9,6 @@ import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; -import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { Bell, CheckCheck, @@ -321,7 +320,7 @@ export const MessagesPanel = memo(function MessagesPanel({ ); const messagesContent = ( - <> +
- +
); // ---- Sidebar mode ---- @@ -497,7 +496,7 @@ export const MessagesPanel = memo(function MessagesPanel({ )} {/* Scrollable content */} -
+
= { }, /** Reserved for the human user — never assigned to team members. */ user: { - border: '#f5f5f4', - borderLight: '#78716c', - badge: 'rgba(245, 245, 244, 0.12)', - badgeLight: 'rgba(87, 83, 78, 0.18)', + border: '#a8a29e', + borderLight: '#57534e', + badge: 'rgba(168, 162, 158, 0.18)', + badgeLight: 'rgba(68, 64, 60, 0.14)', text: '#d6d3d1', - textLight: '#44403c', + textLight: '#292524', }, }; diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index c13d9d24..1d016592 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -154,8 +154,14 @@ export interface SourceMessageSnapshot { timestamp: string; /** Message source type (e.g. "user_sent", "inbox"). */ source?: string; - /** Attachment metadata references (IDs only, no blobs). */ - attachments?: { id: string; filename: string; mimeType: string; size: number }[]; + /** Attachment metadata references (IDs only, no blobs). filePath present when file is stored on disk. */ + attachments?: { + id: string; + filename: string; + mimeType: string; + size: number; + filePath?: string; + }[]; } // Fields are validated in TeamTaskReader.getTasks() using `satisfies Record`. @@ -229,6 +235,8 @@ export interface TaskAttachmentMeta { size: number; /** ISO timestamp when the attachment was added. */ addedAt: string; + /** Absolute path to the file on disk. Null/absent for metadata-only references. */ + filePath?: string | null; } /** Payload for uploading an attachment with base64 data (renderer → main). */ @@ -256,6 +264,8 @@ export interface AttachmentMeta { filename: string; mimeType: AttachmentMediaType; size: number; + /** Absolute path to the file on disk. Absent for metadata-only references. */ + filePath?: string; } export interface AttachmentPayload extends AttachmentMeta {