Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions apps/emdash-desktop/src/main/core/tasks/task-service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { events } from '@main/lib/events';
import { taskArchivedChannel } from '@shared/core/tasks/taskEvents';
import { archiveTask as archiveTaskOp } from './operations/archiveTask';

// task-service pulls in the full main-process graph at import time; mock the
// heavy singletons / db so the module loads in a plain node test, and stub
// every task operation so we can drive the service in isolation.
vi.mock('@main/lib/events', () => ({
events: { emit: vi.fn(), on: vi.fn(() => vi.fn()) },
}));
vi.mock('@main/lib/logger', () => ({ log: { error: vi.fn(), warn: vi.fn(), info: vi.fn() } }));
vi.mock('@main/db/client', () => ({ db: {} }));
vi.mock('@main/db/schema', () => ({ tasks: {}, workspaces: {} }));
vi.mock('@main/core/projects/project-manager', () => ({ projectManager: {} }));
vi.mock('@main/core/workspaces/workspace-bootstrap-service', () => ({
workspaceBootstrapService: {},
}));
vi.mock('@main/core/workspaces/workspace-registry', () => ({ workspaceRegistry: {} }));
vi.mock('./task-session-manager', () => ({ taskSessionManager: {} }));
vi.mock('./utils/utils', () => ({ mapTaskRowToTask: vi.fn() }));
vi.mock('./operations/archiveTask', () => ({ archiveTask: vi.fn(async () => undefined) }));
vi.mock('./operations/createTask', () => ({ createTask: vi.fn() }));
vi.mock('./operations/deleteTask', () => ({ deleteTask: vi.fn() }));
vi.mock('./operations/getDeletePreflight', () => ({ getDeletePreflight: vi.fn() }));
vi.mock('./operations/getTasks', () => ({ getTasks: vi.fn() }));
vi.mock('./operations/renameTask', () => ({ renameTask: vi.fn() }));
vi.mock('./operations/restoreTask', () => ({ restoreTask: vi.fn() }));
vi.mock('./operations/setTaskPinned', () => ({ setTaskPinned: vi.fn() }));
vi.mock('./operations/updateLinkedIssue', () => ({ updateLinkedIssue: vi.fn() }));
vi.mock('./operations/updateTaskStatus', () => ({ updateTaskStatus: vi.fn() }));

const { TaskService } = await import('./task-service');

const mockEmit = vi.mocked(events.emit);
const mockArchiveOp = vi.mocked(archiveTaskOp);

describe('TaskService.archiveTask', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('archives via the operation and notifies open windows', async () => {
const service = new TaskService();
await service.archiveTask('project-1', 'task-1');

expect(mockArchiveOp).toHaveBeenCalledWith('project-1', 'task-1');
expect(mockEmit).toHaveBeenCalledWith(taskArchivedChannel, {
taskId: 'task-1',
projectId: 'project-1',
});
});

it('emits the archived event only after the archive operation completes', async () => {
const order: string[] = [];
mockArchiveOp.mockImplementationOnce(async () => {
order.push('archive-op');
});
mockEmit.mockImplementationOnce(() => {
order.push('emit');
return undefined as never;
});

const service = new TaskService();
await service.archiveTask('project-1', 'task-1');

expect(order).toEqual(['archive-op', 'emit']);
Comment thread
dfox288 marked this conversation as resolved.
});

it('does not emit the archived event when the archive operation fails', async () => {
mockArchiveOp.mockRejectedValueOnce(new Error('db constraint violation'));

const service = new TaskService();
await expect(service.archiveTask('project-1', 'task-1')).rejects.toThrow(
'db constraint violation'
);

// An emitted event here would make every open window hide a task that is
// still alive in the database.
expect(mockEmit).not.toHaveBeenCalled();
});
});
10 changes: 9 additions & 1 deletion apps/emdash-desktop/src/main/core/tasks/task-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,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 {
taskArchivedChannel,
taskCreatedChannel,
taskProvisionedChannel,
} from '@shared/core/tasks/taskEvents';
import type {
CreateTaskError,
CreateTaskParams,
Expand Down Expand Up @@ -182,6 +186,10 @@ export class TaskService implements Hookable<TaskLifecycleHooks> {
async archiveTask(projectId: string, taskId: string): Promise<void> {
await archiveTask(projectId, taskId);
this._hooks.callHookBackground('task:archived', taskId, projectId);
// Notify open windows — headless initiators (automations, inbound MCP)
// have no renderer-side optimistic update. The renderer handler is
// idempotent for GUI-initiated archives.
events.emit(taskArchivedChannel, { taskId, projectId });
}

async restoreTask(id: string): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { gitWorkspaceChangedChannel } from '@shared/core/git/gitEvents';
import { prSyncProgressChannel, prUpdatedChannel } from '@shared/core/pull-requests/prEvents';
import {
lifecycleScriptStatusChannel,
taskArchivedChannel,
taskCreatedChannel,
taskProvisionProgressChannel,
taskProvisionedChannel,
Expand Down Expand Up @@ -109,6 +110,7 @@ export class TaskManagerStore {
private _provisionPromises = new Map<string, Promise<void>>();

private _unsubTaskCreated: (() => void) | null = null;
private _unsubTaskArchived: (() => void) | null = null;
private _unsubPrUpdated: (() => void) | null = null;
private _unsubPrSyncProgress: (() => void) | null = null;
private _unsubGitWorkspaceChanged: (() => void) | null = null;
Expand Down Expand Up @@ -144,6 +146,28 @@ export class TaskManagerStore {
});
});

this._unsubTaskArchived = events.on(
taskArchivedChannel,
({ taskId, projectId: evtProjectId }) => {
if (evtProjectId !== this.projectId) return;
const task = this.tasks.get(taskId);
// Idempotent: GUI-initiated archives already updated the store
// optimistically (archivedAt set) before this event arrives. Only
// headless archives (automations, inbound MCP) take this path.
if (!task || !isRegistered(task) || task.data.archivedAt) return;
// Apply the whole archive mutation in one action so reactions never
// observe an intermediate state (archivedAt set but the store not yet
// transitioned). The GUI path is split into two actions only because
// it must await the RPC between them; this headless path is fully
// synchronous, so it can collapse them.
runInAction(() => {
task.data.archivedAt = new Date().toISOString();
this._releaseTaskRegistries(taskId);
task.transitionToDryUnprovisioned({ ...task.data }, 'idle');
});
}
Comment thread
dfox288 marked this conversation as resolved.
);

this._unsubStatusUpdated = events.on(
taskStatusUpdatedChannel,
({ taskId, projectId: evtProjectId, status }) => {
Expand Down Expand Up @@ -656,6 +680,8 @@ export class TaskManagerStore {
dispose(): void {
this._unsubTaskCreated?.();
this._unsubTaskCreated = null;
this._unsubTaskArchived?.();
this._unsubTaskArchived = null;
this._unsubPrUpdated?.();
this._unsubPrUpdated = null;
this._unsubPrSyncProgress?.();
Expand Down
11 changes: 11 additions & 0 deletions apps/emdash-desktop/src/shared/core/tasks/taskEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ import { defineEvent } from '@shared/lib/ipc/events';

export const taskCreatedChannel = defineEvent<{ task: Task }>('task:created');

/**
* Emitted by the main process whenever a task is archived — regardless of
* initiator (renderer, automations, inbound MCP server). The renderer's
* own archive flow updates its store optimistically, so its handler for
* this event must be (and is) idempotent; the event exists so headless
* archives reach open windows too.
*/
export const taskArchivedChannel = defineEvent<{ taskId: string; projectId: string }>(
'task:archived'
);

export const taskStatusUpdatedChannel = defineEvent<{
taskId: string;
projectId: string;
Expand Down