diff --git a/apps/emdash-desktop/src/main/core/storage/controller.ts b/apps/emdash-desktop/src/main/core/storage/controller.ts new file mode 100644 index 0000000000..658d244d31 --- /dev/null +++ b/apps/emdash-desktop/src/main/core/storage/controller.ts @@ -0,0 +1,11 @@ +import { createRPCController } from '@shared/lib/ipc/rpc'; +import { deleteStorageTasks, listTaskStorageUsage } from './storage-service'; + +export const storageController = createRPCController({ + async listTaskStorageUsage(projectId?: string) { + return listTaskStorageUsage(projectId); + }, + async deleteTasks(taskIds: string[]) { + return deleteStorageTasks(taskIds); + }, +}); diff --git a/apps/emdash-desktop/src/main/core/storage/storage-service.ts b/apps/emdash-desktop/src/main/core/storage/storage-service.ts new file mode 100644 index 0000000000..095d4a41fe --- /dev/null +++ b/apps/emdash-desktop/src/main/core/storage/storage-service.ts @@ -0,0 +1,306 @@ +import { measureTaskStorage } from '@emdash/core/storage'; +import { eq } from 'drizzle-orm'; +import { hasWorktreeGitMarker } from '@main/core/tasks/operations/task-lifecycle-utils'; +import { taskService } from '@main/core/tasks/task-service'; +import { taskSessionManager } from '@main/core/tasks/task-session-manager'; +import { getProvisionedWorkspaceBranch } from '@main/core/workspaces/workspace-branch'; +import { db } from '@main/db/client'; +import { projects, tasks, workspaces } from '@main/db/schema'; +import type { + ProjectStorageUsage, + StorageDeleteTaskResult, + StorageDeleteTasksResult, + StoragePathState, + StorageUsageResult, + TaskStorageUsage, +} from '@shared/core/storage/storage'; +import type { TaskLifecycleStatus } from '@shared/core/tasks/tasks'; +import type { WorkspaceConfig } from '@shared/core/workspaces/workspace-config'; + +type TaskStorageRow = { + taskId: string; + taskName: string; + projectId: string; + projectName: string; + projectPath: string; + projectType: string; + status: string; + createdAt: string; + updatedAt: string; + lastInteractedAt: string | null; + archivedAt: string | null; + workspaceId: string | null; + workspaceType: 'local' | 'project-ssh' | 'byoi' | null; + workspaceKind: 'worktree' | 'project-root' | 'byoi' | null; + workspaceLocation: 'local' | 'remote' | null; + workspacePath: string | null; + workspaceBranchName: string | null; + workspaceConfig: WorkspaceConfig | null; +}; + +const MEASURE_CONCURRENCY = 4; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +async function mapWithConcurrency( + items: readonly T[], + limit: number, + mapItem: (item: T) => Promise +): Promise { + const results = new Array(items.length); + let nextIndex = 0; + + async function worker() { + while (nextIndex < items.length) { + const index = nextIndex; + nextIndex += 1; + results[index] = await mapItem(items[index]!); + } + } + + const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker()); + await Promise.all(workers); + return results; +} + +function isWorktreeRow(row: TaskStorageRow): boolean { + return ( + row.workspaceKind === 'worktree' || + (!row.workspaceKind && + !!getProvisionedWorkspaceBranch({ + kind: row.workspaceKind, + branchName: row.workspaceBranchName, + config: row.workspaceConfig, + })) + ); +} + +function isLocalTaskWorkspace(row: TaskStorageRow): boolean { + if (row.projectType !== 'local') return false; + if (row.workspaceLocation === 'remote') return false; + if (row.workspaceType === 'project-ssh' || row.workspaceType === 'byoi') return false; + return true; +} + +async function getRows(projectId?: string): Promise { + const query = db + .select({ + taskId: tasks.id, + taskName: tasks.name, + projectId: tasks.projectId, + projectName: projects.name, + projectPath: projects.path, + projectType: projects.workspaceProvider, + status: tasks.status, + createdAt: tasks.createdAt, + updatedAt: tasks.updatedAt, + lastInteractedAt: tasks.lastInteractedAt, + archivedAt: tasks.archivedAt, + workspaceId: workspaces.id, + workspaceType: workspaces.type, + workspaceKind: workspaces.kind, + workspaceLocation: workspaces.location, + workspacePath: workspaces.path, + workspaceBranchName: workspaces.branchName, + workspaceConfig: workspaces.config, + }) + .from(tasks) + .innerJoin(projects, eq(tasks.projectId, projects.id)) + .leftJoin(workspaces, eq(tasks.workspaceId, workspaces.id)); + + const rows = projectId ? await query.where(eq(tasks.projectId, projectId)) : await query; + return rows.sort((a, b) => { + const projectCompare = a.projectName.localeCompare(b.projectName); + if (projectCompare !== 0) return projectCompare; + return b.updatedAt.localeCompare(a.updatedAt); + }) as TaskStorageRow[]; +} + +async function measureRow(row: TaskStorageRow): Promise { + const branchName = getProvisionedWorkspaceBranch({ + kind: row.workspaceKind, + branchName: row.workspaceBranchName, + config: row.workspaceConfig, + }); + const worktree = isWorktreeRow(row); + const localWorkspace = isLocalTaskWorkspace(row); + const isActive = !!taskSessionManager.getTask(row.taskId); + const canDelete = + row.projectType === 'local' && row.workspaceKind !== 'byoi' && (!worktree || localWorkspace); + + const base: TaskStorageUsage = { + taskId: row.taskId, + taskName: row.taskName, + projectId: row.projectId, + projectName: row.projectName, + projectPath: row.projectPath, + projectType: row.projectType === 'ssh' ? 'ssh' : 'local', + status: row.status as TaskLifecycleStatus, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastInteractedAt: row.lastInteractedAt ?? undefined, + archivedAt: row.archivedAt ?? undefined, + workspaceId: row.workspaceId ?? undefined, + workspacePath: row.workspacePath ?? undefined, + branchName: branchName ?? undefined, + pathState: 'no-path', + isActive, + canDelete, + reclaimableBytes: 0, + errors: [], + }; + + if (!row.workspaceId || !row.workspacePath) { + return { ...base, pathState: row.workspacePath ? 'not-worktree' : 'no-path' }; + } + + if (!worktree) { + return { ...base, pathState: 'not-worktree' }; + } + + if (!localWorkspace) { + return { ...base, pathState: 'remote', canDelete: false }; + } + + const usage = await measureTaskStorage(row.workspacePath); + const hasGitMarker = + usage.exists && usage.isDirectory ? await hasWorktreeGitMarker(row.workspacePath) : false; + const pathState: StoragePathState = !usage.exists + ? 'missing' + : usage.isDirectory + ? usage.errors.length > 0 + ? 'error' + : hasGitMarker + ? 'measured' + : 'not-worktree' + : 'error'; + + return { + ...base, + pathState, + canDelete, + reclaimableBytes: usage.reclaimableBytes, + errors: usage.errors, + }; +} + +function groupProjects(tasksUsage: TaskStorageUsage[]): ProjectStorageUsage[] { + const projectsById = new Map(); + + for (const task of tasksUsage) { + let project = projectsById.get(task.projectId); + if (!project) { + project = { + projectId: task.projectId, + projectName: task.projectName, + projectPath: task.projectPath, + projectType: task.projectType, + taskCount: 0, + reclaimableBytes: 0, + tasks: [], + }; + projectsById.set(task.projectId, project); + } + + project.taskCount += 1; + project.reclaimableBytes += task.reclaimableBytes; + project.tasks.push(task); + } + + return Array.from(projectsById.values()).map((project) => ({ + ...project, + tasks: project.tasks.sort((a, b) => b.reclaimableBytes - a.reclaimableBytes), + })); +} + +export async function listTaskStorageUsage(projectId?: string): Promise { + const rows = await getRows(projectId); + const measuredTasks = await mapWithConcurrency(rows, MEASURE_CONCURRENCY, measureRow); + const projectsUsage = groupProjects(measuredTasks); + + return { + scannedAt: new Date().toISOString(), + taskCount: measuredTasks.length, + reclaimableBytes: measuredTasks.reduce((sum, task) => sum + task.reclaimableBytes, 0), + projects: projectsUsage, + }; +} + +async function deleteStorageTask(row: TaskStorageRow): Promise { + if (row.projectType !== 'local' || row.workspaceKind === 'byoi') { + return { + taskId: row.taskId, + projectId: row.projectId, + taskName: row.taskName, + success: false, + reason: 'unsupported-workspace', + message: 'Only local tasks are supported by storage cleanup in this version.', + }; + } + + if (isWorktreeRow(row) && !isLocalTaskWorkspace(row)) { + return { + taskId: row.taskId, + projectId: row.projectId, + taskName: row.taskName, + success: false, + reason: 'unsupported-workspace', + message: 'Remote task worktrees are not supported by storage cleanup yet.', + }; + } + + try { + await taskService.deleteTask(row.projectId, row.taskId, { + deleteWorktree: true, + deleteBranch: false, + }); + return { + taskId: row.taskId, + projectId: row.projectId, + taskName: row.taskName, + success: true, + }; + } catch (error) { + return { + taskId: row.taskId, + projectId: row.projectId, + taskName: row.taskName, + success: false, + reason: 'delete-failed', + message: errorMessage(error), + }; + } +} + +export async function deleteStorageTasks(taskIds: string[]): Promise { + if (taskIds.length === 0) return { deletedCount: 0, failedCount: 0, results: [] }; + + const rows = await getRows(); + const rowsByTaskId = new Map(rows.map((row) => [row.taskId, row])); + const results: StorageDeleteTaskResult[] = []; + + for (const taskId of taskIds) { + const row = rowsByTaskId.get(taskId); + if (!row) { + results.push({ + taskId, + projectId: '', + taskName: taskId, + success: false, + reason: 'task-not-found', + message: 'Task was not found.', + }); + continue; + } + results.push(await deleteStorageTask(row)); + } + + const deletedCount = results.filter((result) => result.success).length; + return { + deletedCount, + failedCount: results.length - deletedCount, + results, + }; +} diff --git a/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.test.ts b/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.test.ts index a2a2af9866..dec7b5b4c3 100644 --- a/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.test.ts +++ b/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.test.ts @@ -1,3 +1,6 @@ +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { deleteTask } from './deleteTask'; @@ -6,11 +9,17 @@ const mocks = vi.hoisted(() => ({ deleteIndex: vi.fn(), deleteWhere: vi.fn(), delViewState: vi.fn(), + execFile: vi.fn(), getProject: vi.fn(), + getProjectById: vi.fn(), selectLimit: vi.fn(), teardownTask: vi.fn(), })); +vi.mock('node:child_process', () => ({ + execFile: mocks.execFile, +})); + vi.mock('@main/db/client', () => ({ db: { select: () => ({ @@ -32,6 +41,10 @@ vi.mock('@main/core/projects/project-manager', () => ({ }, })); +vi.mock('@main/core/projects/operations/getProjects', () => ({ + getProjectById: mocks.getProjectById, +})); + vi.mock('@main/core/tasks/task-session-manager', () => ({ taskSessionManager: { teardownTask: mocks.teardownTask, @@ -60,7 +73,12 @@ describe('deleteTask', () => { beforeEach(() => { vi.clearAllMocks(); mocks.deleteWhere.mockResolvedValue(undefined); + mocks.execFile.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, '', ''); + return {}; + }); mocks.getProject.mockReturnValue(undefined); + mocks.getProjectById.mockResolvedValue(undefined); }); it('deletes both the aggregate view-state key and the dedicated tabs key', async () => { @@ -85,4 +103,54 @@ describe('deleteTask', () => { expect(mocks.deleteIndex).not.toHaveBeenCalled(); }); + + it('removes an owned local worktree by recorded path when the project is not mounted', async () => { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'emdash-delete-task-')); + const projectPath = path.join(tempDir, 'project'); + const worktreePath = path.join(tempDir, 'task-worktree'); + await mkdir(path.join(worktreePath, '.git'), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await writeFile(path.join(worktreePath, 'file.txt'), 'content'); + + mocks.getProjectById.mockResolvedValue({ + type: 'local', + id: 'project-1', + name: 'Project', + path: projectPath, + baseRef: 'main', + repositoryWorkspaceId: null, + createdAt: '', + updatedAt: '', + }); + mocks.selectLimit + .mockResolvedValueOnce([{ id: 'task-1', workspaceId: 'workspace-1' }]) + .mockResolvedValueOnce([ + { + id: 'workspace-1', + type: 'local', + kind: 'worktree', + location: 'local', + path: worktreePath, + branchName: 'task/branch', + config: null, + }, + ]) + .mockResolvedValueOnce([{ id: 'workspace-1', kind: 'worktree' }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + try { + await deleteTask('project-1', 'task-1'); + + await expect(access(worktreePath)).rejects.toThrow(); + expect(mocks.execFile).toHaveBeenCalledWith( + 'git', + ['-C', projectPath, 'worktree', 'prune'], + { timeout: 5_000 }, + expect.any(Function) + ); + } finally { + await rm(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.ts b/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.ts index f7a191d7e8..affc14bb31 100644 --- a/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.ts +++ b/apps/emdash-desktop/src/main/core/tasks/operations/deleteTask.ts @@ -1,4 +1,5 @@ import { eq } from 'drizzle-orm'; +import { getProjectById } from '@main/core/projects/operations/getProjects'; import { projectManager } from '@main/core/projects/project-manager'; import { taskSessionManager } from '@main/core/tasks/task-session-manager'; import { viewStateService } from '@main/core/view-state/view-state-service'; @@ -9,7 +10,11 @@ import { log } from '@main/lib/logger'; import { telemetryService } from '@main/lib/telemetry'; import type { DeleteTaskOptions } from '@shared/core/tasks/tasks'; import type { WorkspaceConfig } from '@shared/core/workspaces/workspace-config'; -import { deleteWorkspaceIfUnused, removeWorktreeIfUnused } from './task-lifecycle-utils'; +import { + deleteWorkspaceIfUnused, + removeOwnedLocalWorktreeDirectoryIfUnused, + removeWorktreeIfUnused, +} from './task-lifecycle-utils'; export async function deleteTask( projectId: string, @@ -38,7 +43,10 @@ export async function deleteTask( let wsRow: | { id: string; + type: 'local' | 'project-ssh' | 'byoi' | null; kind: 'worktree' | 'project-root' | 'byoi' | null; + location: 'local' | 'remote' | null; + path: string | null; branchName: string | null; config: WorkspaceConfig | null; } @@ -58,10 +66,33 @@ export async function deleteTask( void viewStateService.del(`task:${taskId}:tabs`); telemetryService.capture('task_deleted', { project_id: projectId, task_id: taskId }); - if (project && deleteWorktree && wsRow) { - const worktreeRemoved = await removeWorktreeIfUnused(wsRow, project, false); + if (deleteWorktree && wsRow) { + let worktreeRemoved = false; + if (project) { + worktreeRemoved = await removeWorktreeIfUnused(wsRow, project, false); + } + + if (!worktreeRemoved) { + const projectRow = await getProjectById(projectId); + if (projectRow?.type === 'local') { + worktreeRemoved = await removeOwnedLocalWorktreeDirectoryIfUnused( + wsRow, + projectRow.path, + false + ).catch((e) => { + log.warn('deleteTask: owned worktree directory cleanup failed', { + taskId, + workspaceId: wsRow.id, + path: wsRow.path, + error: String(e), + }); + return false; + }); + } + } + const provisionedBranch = getProvisionedWorkspaceBranch(wsRow); - if (worktreeRemoved && deleteBranch && provisionedBranch) { + if (project && worktreeRemoved && deleteBranch && provisionedBranch) { const fromBranch = wsRow.config?.git.kind === 'create-branch' ? wsRow.config.git.fromBranch : undefined; if (fromBranch && provisionedBranch !== fromBranch.branch) { diff --git a/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.test.ts b/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.test.ts index 71a58e165f..92c21cfad3 100644 --- a/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.test.ts +++ b/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.test.ts @@ -1,13 +1,27 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { WorkspaceConfig } from '@shared/core/workspaces/workspace-config'; -import { deleteWorkspaceIfUnused, removeWorktreeIfUnused } from './task-lifecycle-utils'; +import { + deleteWorkspaceIfUnused, + hasWorktreeGitMarker, + pathExists, + removeOwnedLocalWorktreeDirectory, + removeWorktreeIfUnused, +} from './task-lifecycle-utils'; const mocks = vi.hoisted(() => ({ deleteWhere: vi.fn(), + execFile: vi.fn(), selectLimit: vi.fn(), deleteIndex: vi.fn(), })); +vi.mock('node:child_process', () => ({ + execFile: mocks.execFile, +})); + vi.mock('@main/db/client', () => ({ db: { select: () => ({ @@ -30,9 +44,20 @@ vi.mock('@main/core/search/workspace-file-index-service', () => ({ })); describe('task lifecycle workspace cleanup', () => { - beforeEach(() => { + let tempDir: string; + + beforeEach(async () => { vi.clearAllMocks(); mocks.deleteWhere.mockResolvedValue(undefined); + mocks.execFile.mockImplementation((_cmd, _args, _options, callback) => { + callback(null, '', ''); + return {}; + }); + tempDir = await mkdtemp(path.join(os.tmpdir(), 'emdash-task-cleanup-')); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); }); it('does not remove a project-root workspace when branchName is a current-branch cache', async () => { @@ -107,4 +132,57 @@ describe('task lifecycle workspace cleanup', () => { expect(mocks.deleteWhere).not.toHaveBeenCalled(); expect(mocks.deleteIndex).not.toHaveBeenCalled(); }); + + it('removes an owned local worktree directory and prunes stale git worktree entries', async () => { + const projectPath = path.join(tempDir, 'project'); + const worktreePath = path.join(tempDir, 'task-worktree'); + await mkdir(path.join(worktreePath, '.git'), { recursive: true }); + await mkdir(projectPath, { recursive: true }); + await writeFile(path.join(worktreePath, 'file.txt'), 'content'); + + await expect( + removeOwnedLocalWorktreeDirectory( + { + kind: 'worktree', + type: 'local', + location: 'local', + path: worktreePath, + }, + projectPath + ) + ).resolves.toBe(true); + + await expect(pathExists(worktreePath)).resolves.toBe(false); + expect(mocks.execFile).toHaveBeenCalledWith( + 'git', + ['-C', projectPath, 'worktree', 'prune'], + { timeout: 5_000 }, + expect.any(Function) + ); + }); + + it('refuses to remove the project root', async () => { + const projectPath = path.join(tempDir, 'project'); + await mkdir(projectPath, { recursive: true }); + + await expect( + removeOwnedLocalWorktreeDirectory( + { + kind: 'worktree', + type: 'local', + location: 'local', + path: projectPath, + }, + projectPath + ) + ).rejects.toThrow('Refusing to remove project root path'); + }); + + it('detects a worktree git marker without shelling out', async () => { + const worktreePath = path.join(tempDir, 'task-worktree'); + await mkdir(path.join(worktreePath, '.git'), { recursive: true }); + + await expect(hasWorktreeGitMarker(worktreePath)).resolves.toBe(true); + expect(mocks.execFile).not.toHaveBeenCalled(); + }); }); diff --git a/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.ts b/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.ts index 3d0150761b..c1ec91a841 100644 --- a/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.ts +++ b/apps/emdash-desktop/src/main/core/tasks/operations/task-lifecycle-utils.ts @@ -1,12 +1,122 @@ +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; import { and, eq, isNull, ne } from 'drizzle-orm'; import { workspaceFileIndexService } from '@main/core/search/workspace-file-index-service'; +import { resolveWorkspaceKind } from '@main/core/workspaces/resolve-workspace-kind'; import { getProvisionedWorkspaceBranch } from '@main/core/workspaces/workspace-branch'; import { db } from '@main/db/client'; import { tasks, workspaces } from '@main/db/schema'; import { log } from '@main/lib/logger'; import type { WorkspaceConfig } from '@shared/core/workspaces/workspace-config'; +import type { WorkspaceKind, WorkspaceType } from '@shared/core/workspaces/workspaces'; import type { ProjectProvider } from '../../projects/project-provider'; +const execFileAsync = promisify(execFile); + +export type LocalWorkspaceCleanupTarget = { + id?: string; + kind?: WorkspaceKind | null; + type?: WorkspaceType | null; + location?: 'local' | 'remote' | null; + path?: string | null; +}; + +export async function pathExists(filePath: string): Promise { + try { + await fs.access(filePath); + return true; + } catch { + return false; + } +} + +export function isLocalWorkspace(workspace: LocalWorkspaceCleanupTarget): boolean { + if (workspace.location === 'remote') return false; + if (workspace.type === 'project-ssh' || workspace.type === 'byoi') return false; + return true; +} + +export async function hasWorktreeGitMarker(workspacePath: string | null | undefined) { + return workspacePath ? pathExists(path.join(workspacePath, '.git')) : false; +} + +function isWorktreeWorkspace(workspace: LocalWorkspaceCleanupTarget): boolean { + if (!workspace.type) return workspace.kind === 'worktree'; + return ( + resolveWorkspaceKind({ + kind: workspace.kind, + type: workspace.type, + path: workspace.path, + }) === 'worktree' + ); +} + +async function workspaceHasRemainingTasks( + workspaceId: string, + excludeArchived: boolean +): Promise { + const where = excludeArchived + ? and(eq(tasks.workspaceId, workspaceId), isNull(tasks.archivedAt)) + : eq(tasks.workspaceId, workspaceId); + + const siblings = await db.select({ id: tasks.id }).from(tasks).where(where).limit(1); + return siblings.length > 0; +} + +async function pruneGitWorktrees(projectPath: string): Promise { + try { + await execFileAsync('git', ['-C', projectPath, 'worktree', 'prune'], { timeout: 5_000 }); + } catch (error) { + log.warn('git worktree prune failed after task worktree cleanup', { + projectPath, + error: String(error), + }); + } +} + +export async function removeOwnedLocalWorktreeDirectory( + workspace: LocalWorkspaceCleanupTarget, + projectPath: string +): Promise { + if (!workspace.path || !isLocalWorkspace(workspace)) return false; + + const workspacePath = path.resolve(workspace.path); + const projectRootPath = path.resolve(projectPath); + if (workspacePath === projectRootPath) { + if (workspace.kind === 'worktree') { + throw new Error(`Refusing to remove project root path "${workspace.path}"`); + } + return false; + } + + if (!isWorktreeWorkspace(workspace)) return false; + + await fs.rm(workspacePath, { + recursive: true, + force: true, + maxRetries: 3, + retryDelay: 100, + }); + + if (await pathExists(workspacePath)) { + throw new Error(`Failed to remove worktree directory "${workspace.path}"`); + } + + await pruneGitWorktrees(projectPath); + return true; +} + +export async function removeOwnedLocalWorktreeDirectoryIfUnused( + workspace: LocalWorkspaceCleanupTarget & { id: string }, + projectPath: string, + excludeArchived: boolean +): Promise { + if (await workspaceHasRemainingTasks(workspace.id, excludeArchived)) return false; + return removeOwnedLocalWorktreeDirectory(workspace, projectPath); +} + /** * Removes the worktree for destructive task deletion when no remaining sibling task shares the * same workspace. @@ -29,12 +139,7 @@ export async function removeWorktreeIfUnused( const branchName = getProvisionedWorkspaceBranch(workspace); if (!branchName) return false; - const where = excludeArchived - ? and(eq(tasks.workspaceId, workspace.id), isNull(tasks.archivedAt)) - : eq(tasks.workspaceId, workspace.id); - - const siblings = await db.select({ id: tasks.id }).from(tasks).where(where).limit(1); - if (siblings.length > 0) return false; + if (await workspaceHasRemainingTasks(workspace.id, excludeArchived)) return false; try { await project.removeTaskWorktree(branchName); diff --git a/apps/emdash-desktop/src/main/core/tasks/task-service.ts b/apps/emdash-desktop/src/main/core/tasks/task-service.ts index b4f1d1b786..28c3ac4adf 100644 --- a/apps/emdash-desktop/src/main/core/tasks/task-service.ts +++ b/apps/emdash-desktop/src/main/core/tasks/task-service.ts @@ -12,7 +12,11 @@ import { events } from '@main/lib/events'; import { HookCore, type Hookable } from '@main/lib/hookable'; import { log } from '@main/lib/logger'; import type { LinkedIssue } from '@shared/core/linked-issue'; -import { taskCreatedChannel, taskProvisionedChannel } from '@shared/core/tasks/taskEvents'; +import { + taskCreatedChannel, + taskDeletedChannel, + taskProvisionedChannel, +} from '@shared/core/tasks/taskEvents'; import type { CreateTaskError, CreateTaskParams, @@ -167,7 +171,12 @@ export class TaskService implements Hookable { async deleteTask(projectId: string, taskId: string, options?: DeleteTaskOptions): Promise { await deleteTask(projectId, taskId, options); + this.notifyTaskDeleted(taskId, projectId); + } + + notifyTaskDeleted(taskId: string, projectId: string): void { this._hooks.callHookBackground('task:deleted', taskId, projectId); + events.emit(taskDeletedChannel, { taskId, projectId }); } async deleteTasks( @@ -176,7 +185,7 @@ export class TaskService implements Hookable { options?: DeleteTaskOptions ): Promise { await Promise.all(taskIds.map((id) => deleteTask(projectId, id, options))); - taskIds.forEach((id) => this._hooks.callHookBackground('task:deleted', id, projectId)); + taskIds.forEach((id) => this.notifyTaskDeleted(id, projectId)); } async archiveTask(projectId: string, taskId: string): Promise { diff --git a/apps/emdash-desktop/src/main/rpc.ts b/apps/emdash-desktop/src/main/rpc.ts index 5f5df792ff..bf39c229c2 100644 --- a/apps/emdash-desktop/src/main/rpc.ts +++ b/apps/emdash-desktop/src/main/rpc.ts @@ -33,6 +33,7 @@ import { appSettingsController } from './core/settings/controller'; import { providerSettingsController } from './core/settings/provider-settings-controller'; import { skillsController } from './core/skills/controller'; import { sshController } from './core/ssh/controller'; +import { storageController } from './core/storage/controller'; import { taskController } from './core/tasks/controller'; import { telemetryController } from './core/telemetry/controller'; import { terminalsController } from './core/terminals/controller'; @@ -70,6 +71,7 @@ export const rpcRouter = createRPCRouter({ promptLibrary: promptLibraryController, skills: skillsController, ssh: sshController, + storage: storageController, projectSetup: projectSetupController, projects: projectController, previewServers: previewServersController, diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx index 965e2ce02e..2a151d72c8 100644 --- a/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx +++ b/apps/emdash-desktop/src/renderer/features/settings/components/SettingsPage.tsx @@ -14,6 +14,7 @@ import RepositorySettingsCard from './RepositorySettingsCard'; import ResourceMonitorSettingsCard from './ResourceMonitorSettingsCard'; import SidebarMetadataSettingsCard from './SidebarMetadataSettingsCard'; import { SshConnectionsSettingsCard } from './SshConnectionsSettingsCard'; +import { StorageSettingsPage } from './StorageSettingsPage'; import { AutoApproveByDefaultRow, AutoGenerateTaskNamesRow, @@ -36,6 +37,7 @@ export type SettingsPageTab = | 'connections' | 'browser' | 'repository' + | 'storage' | 'interface' | 'docs'; @@ -137,6 +139,19 @@ function InterfaceSettingsPage() { ); } +function StorageTabPage() { + return ( +
+ + +
+ ); +} + // --------------------------------------------------------------------------- // SettingsPage // --------------------------------------------------------------------------- @@ -163,6 +178,7 @@ export function SettingsPage({ { id: 'integrations', label: 'Integrations' }, { id: 'connections', label: 'Connections' }, { id: 'repository', label: 'Repository' }, + { id: 'storage', label: 'Storage' }, { id: 'interface', label: 'Interface' }, { id: 'browser', label: 'Browser' }, { id: 'docs', label: 'Docs', isExternal: true }, @@ -185,6 +201,7 @@ export function SettingsPage({ ), repository: , + storage: , interface: , }; diff --git a/apps/emdash-desktop/src/renderer/features/settings/components/StorageSettingsPage.tsx b/apps/emdash-desktop/src/renderer/features/settings/components/StorageSettingsPage.tsx new file mode 100644 index 0000000000..02cacbfb5b --- /dev/null +++ b/apps/emdash-desktop/src/renderer/features/settings/components/StorageSettingsPage.tsx @@ -0,0 +1,404 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { Archive, HardDrive, Info, RefreshCw, Trash2 } from 'lucide-react'; +import type { ReactNode } from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { toast } from '@renderer/lib/hooks/use-toast'; +import { rpc } from '@renderer/lib/ipc'; +import { useShowModal } from '@renderer/lib/modal/modal-provider'; +import { Button } from '@renderer/lib/ui/button'; +import { Checkbox } from '@renderer/lib/ui/checkbox'; +import { Spinner } from '@renderer/lib/ui/spinner'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@renderer/lib/ui/tooltip'; +import { cn } from '@renderer/utils/utils'; +import type { StoragePathState, TaskStorageUsage } from '@shared/core/storage/storage'; + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + const units = ['KB', 'MB', 'GB', 'TB']; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[unitIndex]}`; +} + +function formatActivityDate(value?: string): string { + if (!value) return 'Never'; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return 'Unknown'; + const now = new Date(); + return date.toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + ...(date.getFullYear() === now.getFullYear() ? {} : { year: 'numeric' }), + }); +} + +function formatTaskCount(count: number): string { + return `${count} ${count === 1 ? 'task' : 'tasks'}`; +} + +function pathStateLabel(state: StoragePathState): string { + switch (state) { + case 'measured': + return 'Ready'; + case 'missing': + return 'Missing'; + case 'not-worktree': + return 'No worktree'; + case 'remote': + return 'Remote'; + case 'no-path': + return 'No worktree path'; + case 'error': + return 'Scan error'; + } +} + +function InfoTooltip({ label, content }: { label: string; content: ReactNode }) { + return ( + + + + + + {content} + + + ); +} + +function LabelWithInfo({ + label, + tooltipLabel, + tooltip, +}: { + label: string; + tooltipLabel: string; + tooltip: ReactNode; +}) { + return ( + + {label} + + + ); +} + +function StorageStat({ label, value, icon }: { label: ReactNode; value: string; icon: ReactNode }) { + return ( +
+
+ {icon} +
+
+
{label}
+
{value}
+
+
+ ); +} + +type ActionStatus = { + kind: 'info' | 'success' | 'error'; + message: string; +}; + +function TaskRow({ + task, + selected, + onSelectedChange, +}: { + task: TaskStorageUsage; + selected: boolean; + onSelectedChange: (taskId: string, selected: boolean) => void; +}) { + const lastActivity = task.lastInteractedAt ?? task.updatedAt ?? task.createdAt; + const pathStatus = task.pathState === 'measured' ? null : pathStateLabel(task.pathState); + const details = [task.isActive ? 'Active task' : null, pathStatus].filter(Boolean).join(' ยท '); + return ( +
+ onSelectedChange(task.taskId, Boolean(checked))} + /> +
+
+ {task.taskName} + {task.archivedAt && } +
+ {details &&
{details}
} +
+
+ {formatBytes(task.reclaimableBytes)} +
+
+ {formatActivityDate(lastActivity)} +
+
+ ); +} + +export function StorageSettingsPage() { + const showConfirm = useShowModal('confirmActionModal'); + const [actionStatus, setActionStatus] = useState(null); + const [selectedTaskIds, setSelectedTaskIds] = useState>(() => new Set()); + + const { + data: usage = null, + error: usageError, + isFetching: isLoading, + isLoading: isInitialQueryLoading, + refetch: refetchUsage, + } = useQuery({ + queryKey: ['storage', 'taskUsage'], + queryFn: () => rpc.storage.listTaskStorageUsage(), + refetchOnWindowFocus: false, + }); + const { isPending: isDeleting, mutateAsync: deleteStorageTasks } = useMutation({ + mutationFn: (taskIds: string[]) => rpc.storage.deleteTasks(taskIds), + }); + + const allTasks = useMemo( + () => usage?.projects.flatMap((project) => project.tasks) ?? [], + [usage] + ); + const tasksById = useMemo(() => new Map(allTasks.map((task) => [task.taskId, task])), [allTasks]); + const selectedTasks = useMemo( + () => + Array.from(selectedTaskIds) + .map((taskId) => tasksById.get(taskId)) + .filter((task): task is TaskStorageUsage => !!task?.canDelete), + [selectedTaskIds, tasksById] + ); + + const selectedReclaimableBytes = selectedTasks.reduce( + (sum, task) => sum + task.reclaimableBytes, + 0 + ); + const isInitialLoading = isInitialQueryLoading && !usage; + const deleteButtonLabel = selectedTasks.length === 1 ? 'Delete Task' : 'Delete Tasks'; + const loadErrorMessage = usageError + ? `Could not load storage usage: ${ + usageError instanceof Error ? usageError.message : String(usageError) + }` + : null; + + const setTaskSelected = useCallback((taskId: string, selected: boolean) => { + setSelectedTaskIds((current) => { + const next = new Set(current); + if (selected) { + next.add(taskId); + } else { + next.delete(taskId); + } + return next; + }); + }, []); + + const runDelete = useCallback(async () => { + const taskIds = selectedTasks.map((task) => task.taskId); + if (taskIds.length === 0) return; + const taskCount = formatTaskCount(taskIds.length); + setActionStatus({ kind: 'info', message: `Deleting ${taskCount}...` }); + try { + const result = await deleteStorageTasks(taskIds); + if (result.failedCount > 0) { + const firstFailure = result.results.find((item) => !item.success); + setActionStatus({ + kind: 'error', + message: `${result.deletedCount} deleted, ${result.failedCount} failed${ + firstFailure && !firstFailure.success ? `: ${firstFailure.message}` : '' + }`, + }); + toast({ + title: `${result.deletedCount} deleted, ${result.failedCount} failed`, + description: firstFailure && !firstFailure.success ? firstFailure.message : undefined, + variant: 'destructive', + }); + setSelectedTaskIds( + new Set(result.results.filter((item) => !item.success).map((item) => item.taskId)) + ); + } else { + const deletedText = `${formatTaskCount(result.deletedCount)} deleted`; + setActionStatus({ kind: 'success', message: deletedText }); + toast({ title: deletedText }); + setSelectedTaskIds(new Set()); + } + await refetchUsage(); + } catch (error) { + setActionStatus({ + kind: 'error', + message: `Could not delete selected tasks: ${ + error instanceof Error ? error.message : String(error) + }`, + }); + toast({ + title: 'Could not delete selected tasks', + description: error instanceof Error ? error.message : String(error), + variant: 'destructive', + }); + } + }, [deleteStorageTasks, refetchUsage, selectedTasks]); + + const confirmDelete = useCallback(() => { + if (selectedTasks.length === 0) return; + const count = selectedTasks.length; + showConfirm({ + title: `Delete ${count} selected ${count === 1 ? 'task' : 'tasks'}?`, + description: 'This removes the selected task rows and their owned worktrees.', + confirmLabel: count === 1 ? 'Delete Task' : 'Delete Tasks', + variant: 'destructive', + onSuccess: () => { + void runDelete(); + }, + }); + }, [runDelete, selectedTasks.length, showConfirm]); + + const toggleProject = useCallback((tasks: TaskStorageUsage[], selected: boolean) => { + setSelectedTaskIds((current) => { + const next = new Set(current); + for (const task of tasks) { + if (!task.canDelete) continue; + if (selected) { + next.add(task.taskId); + } else { + next.delete(task.taskId); + } + } + return next; + }); + }, []); + + return ( + +
+
+ + } + value={formatBytes(usage?.reclaimableBytes ?? 0)} + icon={} + /> + } + /> +
+ +
+
+ + {usage ? `${formatTaskCount(usage.taskCount)} scanned` : 'Scanning tasks'} + + {actionStatus && ( + + {actionStatus.kind === 'info' && } + {actionStatus.message} + + )} +
+
+ + +
+
+ + {isInitialLoading ? ( +
+ + Scanning storage +
+ ) : !usage ? ( +
+ {loadErrorMessage ?? 'Storage usage is unavailable.'} +
+ ) : ( +
+ {usage?.projects.map((project) => { + const selectableTasks = project.tasks.filter((task) => task.canDelete); + const selectedCount = selectableTasks.filter((task) => + selectedTaskIds.has(task.taskId) + ).length; + const allSelected = + selectableTasks.length > 0 && selectedCount === selectableTasks.length; + return ( +
+
+ toggleProject(project.tasks, Boolean(checked))} + /> +
+
{project.projectName}
+
{project.projectPath}
+
+
Total
+
Last active
+
+
+ {project.tasks.map((task) => ( + + ))} +
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts index 144709c3a7..e4c89940c5 100644 --- a/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts +++ b/apps/emdash-desktop/src/renderer/features/tasks/stores/task-manager.ts @@ -16,6 +16,7 @@ import { prSyncProgressChannel, prUpdatedChannel } from '@shared/core/pull-reque import { lifecycleScriptStatusChannel, taskCreatedChannel, + taskDeletedChannel, taskProvisionProgressChannel, taskProvisionedChannel, taskStatusUpdatedChannel, @@ -101,6 +102,10 @@ function formatCreateTaskWarning(warning: CreateTaskWarning): string { } } +function formatErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + export class TaskManagerStore { private readonly projectId: string; private readonly _repository: GitRepositoryStore; @@ -110,6 +115,7 @@ export class TaskManagerStore { private _provisionPromises = new Map>(); private _unsubTaskCreated: (() => void) | null = null; + private _unsubTaskDeleted: (() => void) | null = null; private _unsubPrUpdated: (() => void) | null = null; private _unsubPrSyncProgress: (() => void) | null = null; private _unsubGitWorktreeUpdate: (() => void) | null = null; @@ -143,6 +149,14 @@ export class TaskManagerStore { }); }); + this._unsubTaskDeleted = events.on( + taskDeletedChannel, + ({ taskId, projectId: evtProjectId }) => { + if (evtProjectId !== this.projectId) return; + this._removeTaskLocally(taskId); + } + ); + this._unsubStatusUpdated = events.on( taskStatusUpdatedChannel, ({ taskId, projectId: evtProjectId, status }) => { @@ -267,6 +281,16 @@ export class TaskManagerStore { terminalRegistry.release(taskId); } + private _removeTaskLocally(taskId: string): void { + const task = this.tasks.get(taskId); + if (!task) return; + this._releaseTaskRegistries(taskId); + task.dispose(); + runInAction(() => { + this.tasks.delete(taskId); + }); + } + loadTasks(): Promise { if (!this._loadPromise) { this._loadPromise = Promise.all([ @@ -647,6 +671,9 @@ export class TaskManagerStore { runInAction(() => { removed.forEach((t, id) => this.tasks.set(id, t)); }); + toast.error(`Could not delete ${taskIds.length === 1 ? 'task' : 'tasks'}`, { + description: formatErrorMessage(e), + }); throw e; } } @@ -654,6 +681,8 @@ export class TaskManagerStore { dispose(): void { this._unsubTaskCreated?.(); this._unsubTaskCreated = null; + this._unsubTaskDeleted?.(); + this._unsubTaskDeleted = null; this._unsubPrUpdated?.(); this._unsubPrUpdated = null; this._unsubPrSyncProgress?.(); diff --git a/apps/emdash-desktop/src/shared/core/storage/storage.ts b/apps/emdash-desktop/src/shared/core/storage/storage.ts new file mode 100644 index 0000000000..3365e9d4c6 --- /dev/null +++ b/apps/emdash-desktop/src/shared/core/storage/storage.ts @@ -0,0 +1,71 @@ +import type { StorageScanError } from '@emdash/core/storage'; +import type { TaskLifecycleStatus } from '@shared/core/tasks/tasks'; + +export type StoragePathState = + | 'measured' + | 'missing' + | 'not-worktree' + | 'remote' + | 'no-path' + | 'error'; + +export type TaskStorageUsage = { + taskId: string; + taskName: string; + projectId: string; + projectName: string; + projectPath: string; + projectType: 'local' | 'ssh'; + status: TaskLifecycleStatus; + createdAt: string; + updatedAt: string; + lastInteractedAt?: string; + archivedAt?: string; + workspaceId?: string; + workspacePath?: string; + branchName?: string; + pathState: StoragePathState; + isActive: boolean; + canDelete: boolean; + reclaimableBytes: number; + errors: StorageScanError[]; +}; + +export type ProjectStorageUsage = { + projectId: string; + projectName: string; + projectPath: string; + projectType: 'local' | 'ssh'; + taskCount: number; + reclaimableBytes: number; + tasks: TaskStorageUsage[]; +}; + +export type StorageUsageResult = { + scannedAt: string; + taskCount: number; + reclaimableBytes: number; + projects: ProjectStorageUsage[]; +}; + +export type StorageDeleteTaskResult = + | { + taskId: string; + projectId: string; + taskName: string; + success: true; + } + | { + taskId: string; + projectId: string; + taskName: string; + success: false; + reason: 'unsupported-workspace' | 'delete-failed' | 'task-not-found'; + message: string; + }; + +export type StorageDeleteTasksResult = { + deletedCount: number; + failedCount: number; + results: StorageDeleteTaskResult[]; +}; diff --git a/apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts b/apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts index ac5a21602d..7d335f9fe4 100644 --- a/apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts +++ b/apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts @@ -4,6 +4,11 @@ import { defineEvent } from '@shared/lib/ipc/events'; export const taskCreatedChannel = defineEvent<{ task: Task }>('task:created'); +export const taskDeletedChannel = defineEvent<{ + taskId: string; + projectId: string; +}>('task:deleted'); + export const taskStatusUpdatedChannel = defineEvent<{ taskId: string; projectId: string; diff --git a/packages/core/package.json b/packages/core/package.json index c8228209e5..4bc60503f5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -41,6 +41,10 @@ "types": "./dist/lib.d.mts", "default": "./dist/lib.mjs" }, + "./storage": { + "types": "./dist/storage.d.mts", + "default": "./dist/storage.mjs" + }, "./agents/plugins": { "types": "./dist/agents-plugins.d.mts", "default": "./dist/agents-plugins.mjs" diff --git a/packages/core/src/storage/index.test.ts b/packages/core/src/storage/index.test.ts new file mode 100644 index 0000000000..9e07ebd1ad --- /dev/null +++ b/packages/core/src/storage/index.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest'; +import * as storage from './index'; + +describe('@emdash/core/storage public exports', () => { + it('exports measurement helpers', () => { + expect(storage.measureTaskStorage).toBeTypeOf('function'); + }); +}); diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts new file mode 100644 index 0000000000..55831cbfac --- /dev/null +++ b/packages/core/src/storage/index.ts @@ -0,0 +1,2 @@ +export { measureTaskStorage } from './measurement'; +export type { PathStorageUsage, StorageScanError, StorageScanErrorType } from './types'; diff --git a/packages/core/src/storage/measurement.test.ts b/packages/core/src/storage/measurement.test.ts new file mode 100644 index 0000000000..6088927607 --- /dev/null +++ b/packages/core/src/storage/measurement.test.ts @@ -0,0 +1,53 @@ +import { link, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { measureTaskStorage } from './measurement'; + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await mkdtemp(path.join(os.tmpdir(), 'emdash-storage-')); +}); + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }); +}); + +describe('measureTaskStorage', () => { + it('measures total directory usage', async () => { + const root = path.join(tmpDir, 'task'); + await mkdir(path.join(root, 'src'), { recursive: true }); + await writeFile(path.join(root, 'src', 'index.ts'), 'source'); + + const usage = await measureTaskStorage(root); + + expect(usage.exists).toBe(true); + expect(usage.isDirectory).toBe(true); + expect(usage.apparentBytes).toBeGreaterThan(0); + expect(usage.reclaimableBytes).toBeGreaterThan(0); + }); + + it('does not count externally linked file contents as reclaimable', async () => { + const root = path.join(tmpDir, 'task'); + const store = path.join(tmpDir, 'store'); + const linkedFile = path.join(root, 'node_modules', 'pkg', 'index.js'); + const storeFile = path.join(store, 'index.js'); + await mkdir(path.dirname(linkedFile), { recursive: true }); + await mkdir(store, { recursive: true }); + await writeFile(storeFile, 'x'.repeat(128 * 1024)); + await link(storeFile, linkedFile); + + const usage = await measureTaskStorage(root); + + expect(usage.apparentBytes).toBeGreaterThan(128 * 1024); + expect(usage.reclaimableBytes).toBeLessThan(usage.apparentBytes); + }); + + it('reports missing paths without throwing', async () => { + const usage = await measureTaskStorage(path.join(tmpDir, 'missing')); + + expect(usage.exists).toBe(false); + expect(usage.errors[0]?.type).toBe('not-found'); + }); +}); diff --git a/packages/core/src/storage/measurement.ts b/packages/core/src/storage/measurement.ts new file mode 100644 index 0000000000..8107223c59 --- /dev/null +++ b/packages/core/src/storage/measurement.ts @@ -0,0 +1,163 @@ +import type { Stats } from 'node:fs'; +import { lstat, readdir } from 'node:fs/promises'; +import path from 'node:path'; +import type { PathStorageUsage } from './types'; + +type EntryUsage = { + apparentBytes: number; + diskBytes: number; + inodeKey: string | null; + linkCount: number; + isDirectory: boolean; +}; + +type ScanState = { + entries: EntryUsage[]; + errors: PathStorageUsage['errors']; +}; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function diskBytes(stats: Stats): number { + const blocks = typeof stats.blocks === 'number' ? stats.blocks : 0; + return blocks > 0 ? blocks * 512 : stats.size; +} + +function inodeKey(stats: Stats): string | null { + if (stats.ino === 0) return null; + return `${stats.dev}:${stats.ino}`; +} + +function createEmptyUsage( + targetPath: string, + exists: boolean, + isDirectory: boolean, + errors: PathStorageUsage['errors'] +): PathStorageUsage { + return { + path: targetPath, + exists, + isDirectory, + apparentBytes: 0, + reclaimableBytes: 0, + errors, + }; +} + +function aggregateEntries(entries: EntryUsage[]): { + apparentBytes: number; + reclaimableBytes: number; +} { + let apparentBytes = 0; + let reclaimableBytes = 0; + const linked = new Map< + string, + { count: number; linkCount: number; diskBytes: number; apparentBytes: number } + >(); + + for (const entry of entries) { + apparentBytes += entry.apparentBytes; + + if (entry.isDirectory || !entry.inodeKey || entry.linkCount <= 1) { + reclaimableBytes += entry.diskBytes; + continue; + } + + const existing = linked.get(entry.inodeKey); + if (existing) { + existing.count += 1; + } else { + linked.set(entry.inodeKey, { + count: 1, + linkCount: entry.linkCount, + diskBytes: entry.diskBytes, + apparentBytes: entry.apparentBytes, + }); + } + } + + for (const group of linked.values()) { + if (group.linkCount <= group.count) { + reclaimableBytes += group.diskBytes; + } + } + + return { apparentBytes, reclaimableBytes }; +} + +async function scanPath(state: ScanState, currentPath: string): Promise { + let stats: Stats; + try { + stats = await lstat(currentPath); + } catch (error) { + state.errors.push({ + type: 'stat-failed', + path: currentPath, + message: errorMessage(error), + }); + return; + } + + const isDirectory = stats.isDirectory(); + state.entries.push({ + apparentBytes: stats.size, + diskBytes: diskBytes(stats), + inodeKey: inodeKey(stats), + linkCount: stats.nlink, + isDirectory, + }); + + if (!isDirectory) return; + + let children; + try { + children = await readdir(currentPath, { withFileTypes: true }); + } catch (error) { + state.errors.push({ + type: 'read-failed', + path: currentPath, + message: errorMessage(error), + }); + return; + } + + for (const child of children) { + await scanPath(state, path.join(currentPath, child.name)); + } +} + +export async function measureTaskStorage(targetPath: string): Promise { + let rootStats: Stats; + try { + rootStats = await lstat(targetPath); + } catch (error) { + return createEmptyUsage(targetPath, false, false, [ + { type: 'not-found', path: targetPath, message: errorMessage(error) }, + ]); + } + + if (!rootStats.isDirectory()) { + return createEmptyUsage(targetPath, true, false, [ + { type: 'not-directory', path: targetPath, message: 'Path is not a directory.' }, + ]); + } + + const state: ScanState = { + entries: [], + errors: [], + }; + + await scanPath(state, targetPath); + + const total = aggregateEntries(state.entries); + return { + path: targetPath, + exists: true, + isDirectory: true, + apparentBytes: total.apparentBytes, + reclaimableBytes: total.reclaimableBytes, + errors: state.errors, + }; +} diff --git a/packages/core/src/storage/types.ts b/packages/core/src/storage/types.ts new file mode 100644 index 0000000000..d785fb3ae8 --- /dev/null +++ b/packages/core/src/storage/types.ts @@ -0,0 +1,16 @@ +export type StorageScanErrorType = 'not-found' | 'not-directory' | 'read-failed' | 'stat-failed'; + +export type StorageScanError = { + type: StorageScanErrorType; + path: string; + message: string; +}; + +export type PathStorageUsage = { + path: string; + exists: boolean; + isDirectory: boolean; + apparentBytes: number; + reclaimableBytes: number; + errors: StorageScanError[]; +}; diff --git a/packages/core/tsdown.config.ts b/packages/core/tsdown.config.ts index 9efa85192f..ff82c84637 100644 --- a/packages/core/tsdown.config.ts +++ b/packages/core/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ fs: 'src/fs/index.ts', git: 'src/git/index.ts', lib: 'src/lib/index.ts', + storage: 'src/storage/index.ts', 'agents-plugins': 'src/agents/plugins/index.ts', 'agents-plugins-helpers': 'src/agents/plugins/helpers/index.ts', },