diff --git a/src/mcp/server.ts b/src/mcp/server.ts index 5a0c0b2..9071f2f 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -35,6 +35,8 @@ import { initLogger, getLogger } from "../logger.js"; import { ConfigError, ValidationError } from "../errors.js"; import { errorResponse, withErrorHandling } from "./errors.js"; export { errorResponse, withErrorHandling, type ToolResult } from "./errors.js"; +import { taskRegistry } from "./tasks.js"; +import type { TaskType } from "./tasks.js"; /** Build SpiderOptions from submit-document params. */ function buildSpiderOptions( @@ -180,6 +182,46 @@ async function handleSingleDocSubmit( }; } +/** Fire-and-forget helper: creates a task, runs `work` in background, returns task ID response. */ +function startAsyncTask( + type: TaskType, + work: ( + signal: AbortSignal, + onProgress: (current: number, total: number) => void, + ) => Promise, +): { content: Array<{ type: "text"; text: string }> } { + const { task, signal } = taskRegistry.create(type); + taskRegistry.update(task.id, { status: "running", startedAt: new Date() }); + const onProgress = (current: number, total: number): void => { + taskRegistry.update(task.id, { progress: { current, total } }); + }; + void work(signal, onProgress).then( + (result) => { + if (signal.aborted) { + taskRegistry.update(task.id, { status: "cancelled", completedAt: new Date() }); + } else { + taskRegistry.update(task.id, { status: "completed", completedAt: new Date(), result }); + } + }, + (err: unknown) => { + if (signal.aborted) { + taskRegistry.update(task.id, { status: "cancelled", completedAt: new Date() }); + } else { + taskRegistry.update(task.id, { + status: "failed", + completedAt: new Date(), + error: err instanceof Error ? err.message : String(err), + }); + } + }, + ); + return { + content: [ + { type: "text" as const, text: `Task queued. ID: ${task.id}\nUse get-task to check status.` }, + ], + }; +} + // Start the server async function main(): Promise { let config; @@ -549,6 +591,12 @@ async function main(): Promise { .array(z.string()) .optional() .describe("Glob patterns for URLs to skip (e.g. ['*/changelog*', '*/api/v1/*'])."), + async: z + .boolean() + .optional() + .describe( + "When true, start indexing in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const fetchOptions = { @@ -556,6 +604,17 @@ async function main(): Promise { allowSelfSignedCerts: config.indexing.allowSelfSignedCerts, }; + if (params.async) { + return startAsyncTask("index_document", async () => { + if (params.spider) { + const r = await handleSpiderSubmit(db, provider, params, fetchOptions); + return r.content[0]?.text ?? "Done"; + } + const r = await handleSingleDocSubmit(db, provider, params, fetchOptions); + return r.content[0]?.text ?? "Done"; + }); + } + if (params.spider) { return handleSpiderSubmit(db, provider, params, fetchOptions); } @@ -784,10 +843,40 @@ async function main(): Promise { .max(500) .optional() .describe("Chunks per embedding batch (default: 50)"), + async: z + .boolean() + .optional() + .describe( + "When true, run reindexing in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { reindex } = await import("../core/reindex.js"); + if (params.async) { + return startAsyncTask("reindex_library", async (signal, onProgress) => { + const result = await reindex(db, provider, { + documentIds: params.documentIds, + since: params.since, + before: params.before, + batchSize: params.batchSize, + onProgress: (p) => { + if (signal.aborted) throw new Error("Task cancelled"); + onProgress(p.completed, p.total); + }, + }); + return ( + `Reindex complete.\n` + + `Total chunks: ${result.total}\n` + + `Updated: ${result.completed}\n` + + `Failed: ${result.failed}` + + (result.failedChunkIds.length > 0 + ? `\nFailed chunk IDs: ${result.failedChunkIds.join(", ")}` + : "") + ); + }); + } + const result = await reindex(db, provider, { documentIds: params.documentIds, since: params.since, @@ -827,6 +916,12 @@ async function main(): Promise { .describe( "Thread handling: aggregate (default) combines thread into one doc, separate creates one doc per reply", ), + async: z + .boolean() + .optional() + .describe( + "When true, run the sync in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { syncSlack: doSyncSlack } = await import("../connectors/slack.js"); @@ -838,6 +933,23 @@ async function main(): Promise { threadMode: params.threadMode ?? ("aggregate" as const), }; + if (params.async) { + return startAsyncTask("sync_connector", async () => { + const result = await doSyncSlack(db, provider, slackConfig); + const slackErrorLines = result.errors + .map((e) => ` #${e.channel}: ${e.error}`) + .join("\n"); + const slackErrors = result.errors.length > 0 ? `\nErrors:\n${slackErrorLines}` : ""; + return ( + `Slack sync complete.\n` + + `Channels: ${result.channels}\n` + + `Messages indexed: ${result.messagesIndexed}\n` + + `Threads indexed: ${result.threadsIndexed}` + + slackErrors + ); + }); + } + const result = await doSyncSlack(db, provider, slackConfig); const slackErrorLines = result.errors.map((e) => ` #${e.channel}: ${e.error}`).join("\n"); @@ -860,9 +972,31 @@ async function main(): Promise { { nameOrPath: z.string().describe("Pack name (from registry) or local .json file path"), registryUrl: z.string().optional().describe("Custom registry URL"), + async: z + .boolean() + .optional() + .describe( + "When true, run installation in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { installPack } = await import("../core/packs.js"); + + if (params.async) { + return startAsyncTask("install_pack", async (signal, onProgress) => { + const result = await installPack(db, provider, params.nameOrPath, { + registryUrl: params.registryUrl, + onProgress: (current, total) => { + if (signal.aborted) throw new Error("Task cancelled"); + onProgress(current, total); + }, + }); + return result.alreadyInstalled + ? `Pack "${result.packName}" is already installed.` + : `Pack "${result.packName}" installed successfully (${result.documentsInstalled} documents).`; + }); + } + const result = await installPack(db, provider, params.nameOrPath, { registryUrl: params.registryUrl, }); @@ -934,17 +1068,42 @@ async function main(): Promise { { accessToken: z.string().describe("Microsoft Graph API access token"), notebookName: z.string().optional().describe("Specific notebook name to sync (default: all)"), + async: z + .boolean() + .optional() + .describe( + "When true, run the sync in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { syncOneNote } = await import("../connectors/onenote.js"); - const result = await syncOneNote(db, provider, { + const oneNoteConfig = { clientId: "", tenantId: "common", accessToken: params.accessToken, notebooks: params.notebookName ? [params.notebookName] : ["all"], - excludeSections: [], - }); + excludeSections: [] as string[], + }; + + if (params.async) { + return startAsyncTask("sync_connector", async () => { + const result = await syncOneNote(db, provider, oneNoteConfig); + const oneNoteErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join("; "); + const oneNoteErrors = result.errors.length > 0 ? `\nErrors: ${oneNoteErrorLines}` : ""; + return ( + `OneNote sync complete.\n` + + `Notebooks: ${result.notebooks}\n` + + `Sections: ${result.sections}\n` + + `Pages added: ${result.pagesAdded}\n` + + `Pages updated: ${result.pagesUpdated}\n` + + `Pages deleted: ${result.pagesDeleted}` + + oneNoteErrors + ); + }); + } + + const result = await syncOneNote(db, provider, oneNoteConfig); const oneNoteErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join("; "); const oneNoteErrors = result.errors.length > 0 ? `\nErrors: ${oneNoteErrorLines}` : ""; @@ -975,14 +1134,37 @@ async function main(): Promise { .array(z.string()) .optional() .describe("List of Notion page/database IDs to exclude from sync"), + async: z + .boolean() + .optional() + .describe( + "When true, run the sync in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { syncNotion } = await import("../connectors/notion.js"); - const result = await syncNotion(db, provider, { + + const notionConfig = { token: params.token, lastSync: params.lastSync, excludePages: params.excludePages, - }); + }; + + if (params.async) { + return startAsyncTask("sync_connector", async () => { + const result = await syncNotion(db, provider, notionConfig); + const notionErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join("; "); + const notionErrors = result.errors.length > 0 ? `\nErrors: ${notionErrorLines}` : ""; + return ( + `Notion sync complete.\n` + + `Pages indexed: ${result.pagesIndexed}\n` + + `Databases indexed: ${result.databasesIndexed}` + + notionErrors + ); + }); + } + + const result = await syncNotion(db, provider, notionConfig); const notionErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join("; "); const notionErrors = result.errors.length > 0 ? `\nErrors: ${notionErrorLines}` : ""; @@ -1002,15 +1184,38 @@ async function main(): Promise { "Sync an Obsidian vault into the knowledge base. Parses wikilinks, frontmatter, embeds, and tags with incremental sync support.", { vaultPath: z.string().describe("Absolute path to the Obsidian vault directory"), + async: z + .boolean() + .optional() + .describe( + "When true, run the sync in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { syncObsidianVault } = await import("../connectors/obsidian.js"); - const result = await syncObsidianVault(db, provider, { + const obsidianConfig = { vaultPath: params.vaultPath, - topicMapping: "folder", - excludePatterns: [], - }); + topicMapping: "folder" as const, + excludePatterns: [] as string[], + }; + + if (params.async) { + return startAsyncTask("sync_connector", async () => { + const result = await syncObsidianVault(db, provider, obsidianConfig); + const obsidianErrorLines = result.errors.map((e) => `${e.file}: ${e.error}`).join(", "); + const obsidianErrors = result.errors.length > 0 ? `\nErrors: ${obsidianErrorLines}` : ""; + return ( + `Obsidian vault sync complete.\n` + + `Added: ${result.added}\n` + + `Updated: ${result.updated}\n` + + `Deleted: ${result.deleted}` + + obsidianErrors + ); + }); + } + + const result = await syncObsidianVault(db, provider, obsidianConfig); const obsidianErrorLines = result.errors.map((e) => `${e.file}: ${e.error}`).join(", "); const obsidianErrors = result.errors.length > 0 ? `\nErrors: ${obsidianErrorLines}` : ""; @@ -1038,16 +1243,41 @@ async function main(): Promise { .optional() .describe("Space keys to sync, or ['all'] (default: ['all'])"), excludeSpaces: z.array(z.string()).optional().describe("Space keys to exclude"), + async: z + .boolean() + .optional() + .describe( + "When true, run the sync in the background and return a task ID immediately. Use get-task to poll for completion.", + ), }, withErrorHandling(async (params) => { const { syncConfluence } = await import("../connectors/confluence.js"); - const result = await syncConfluence(db, provider, { + + const confluenceConfig = { baseUrl: params.baseUrl, email: params.email, token: params.token, spaces: params.spaces ?? ["all"], excludeSpaces: params.excludeSpaces, - }); + }; + + if (params.async) { + return startAsyncTask("sync_connector", async () => { + const result = await syncConfluence(db, provider, confluenceConfig); + const confluenceErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join(", "); + const confluenceErrors = + result.errors.length > 0 ? `\nErrors: ${confluenceErrorLines}` : ""; + return ( + `Confluence sync complete.\n` + + `Spaces: ${result.spaces}\n` + + `Pages indexed: ${result.pagesIndexed}\n` + + `Pages updated: ${result.pagesUpdated}` + + confluenceErrors + ); + }); + } + + const result = await syncConfluence(db, provider, confluenceConfig); const confluenceErrorLines = result.errors.map((e) => `${e.page}: ${e.error}`).join(", "); const confluenceErrors = result.errors.length > 0 ? `\nErrors: ${confluenceErrorLines}` : ""; @@ -1345,6 +1575,71 @@ async function main(): Promise { }), ); + // Tool: get-task + server.tool( + "get-task", + "Get the current status, progress, and result of an async background task", + { + taskId: z.string().describe("Task ID returned by an async operation"), + }, + withErrorHandling((params) => { + const task = taskRegistry.get(params.taskId); + if (!task) { + return { + content: [ + { + type: "text" as const, + text: `Task ${params.taskId} not found or has expired (tasks are kept for 1 hour after completion).`, + }, + ], + }; + } + return { content: [{ type: "text" as const, text: JSON.stringify(task, null, 2) }] }; + }), + ); + + // Tool: cancel-task + server.tool( + "cancel-task", + "Request cancellation of a pending or running async background task", + { + taskId: z.string().describe("Task ID to cancel"), + }, + withErrorHandling((params) => { + const outcome = taskRegistry.cancel(params.taskId); + if (outcome === "not_found") { + return { + content: [ + { + type: "text" as const, + text: `Task ${params.taskId} not found or has expired.`, + }, + ], + }; + } + if (outcome === "already_terminal") { + const task = taskRegistry.get(params.taskId); + const status = task?.status ?? "unknown"; + return { + content: [ + { + type: "text" as const, + text: `Task ${params.taskId} cannot be cancelled (current status: ${status}).`, + }, + ], + }; + } + return { + content: [ + { + type: "text" as const, + text: `Cancellation requested for task ${params.taskId}. Running operations will stop at the next checkpoint.`, + }, + ], + }; + }), + ); + const transport = new StdioServerTransport(); await server.connect(transport); } diff --git a/src/mcp/tasks.ts b/src/mcp/tasks.ts new file mode 100644 index 0000000..34795c7 --- /dev/null +++ b/src/mcp/tasks.ts @@ -0,0 +1,96 @@ +import { randomUUID } from "node:crypto"; + +export type TaskStatus = "pending" | "running" | "completed" | "failed" | "cancelled"; +export type TaskType = "index_document" | "reindex_library" | "sync_connector" | "install_pack"; + +export interface TaskProgress { + current: number; + total: number; + message?: string | undefined; +} + +export interface Task { + id: string; + type: TaskType; + status: TaskStatus; + progress?: TaskProgress | undefined; + result?: string | undefined; + error?: string | undefined; + createdAt: Date; + startedAt?: Date | undefined; + completedAt?: Date | undefined; +} + +/** TTL for completed/failed/cancelled tasks before they are pruned (1 hour). */ +const TASK_TTL_MS = 60 * 60 * 1000; + +export class TaskRegistry { + private readonly tasks = new Map(); + private readonly controllers = new Map(); + + /** Create a new task and return it along with its AbortSignal. */ + create(type: TaskType): { task: Task; signal: AbortSignal } { + const id = randomUUID(); + const task: Task = { + id, + type, + status: "pending", + createdAt: new Date(), + }; + const controller = new AbortController(); + this.tasks.set(id, task); + this.controllers.set(id, controller); + return { task, signal: controller.signal }; + } + + /** Retrieve a task by ID. Returns undefined if not found or expired. */ + get(id: string): Task | undefined { + this.prune(); + return this.tasks.get(id); + } + + /** Apply partial updates to a task. No-op if task not found. */ + update(id: string, updates: Partial): void { + const task = this.tasks.get(id); + if (task) { + Object.assign(task, updates); + } + } + + /** + * Attempt to cancel a task. + * Returns: + * "cancelled" — cancellation was requested + * "not_found" — task ID unknown or expired + * "already_terminal" — task already completed, failed, or cancelled + */ + cancel(id: string): "cancelled" | "not_found" | "already_terminal" { + this.prune(); + const task = this.tasks.get(id); + if (!task) return "not_found"; + if (task.status === "completed" || task.status === "failed" || task.status === "cancelled") { + return "already_terminal"; + } + this.controllers.get(id)?.abort(); + if (task.status === "pending") { + task.status = "cancelled"; + task.completedAt = new Date(); + } + // Running tasks detect abort via signal and update their own status. + return "cancelled"; + } + + /** Remove expired completed/failed/cancelled tasks. */ + private prune(): void { + const cutoff = Date.now() - TASK_TTL_MS; + for (const [id, task] of this.tasks) { + if (task.completedAt && task.completedAt.getTime() < cutoff) { + this.tasks.delete(id); + this.controllers.delete(id); + } + } + } +} + +/** Module-level singleton task registry used by the MCP server. */ +export const taskRegistry = new TaskRegistry(); diff --git a/tests/unit/tasks.test.ts b/tests/unit/tasks.test.ts new file mode 100644 index 0000000..5aa3211 --- /dev/null +++ b/tests/unit/tasks.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { TaskRegistry } from "../../src/mcp/tasks.js"; +import type { TaskType } from "../../src/mcp/tasks.js"; + +function makeRegistry(): TaskRegistry { + return new TaskRegistry(); +} + +describe("TaskRegistry", () => { + let registry: TaskRegistry; + + beforeEach(() => { + registry = makeRegistry(); + }); + + describe("create", () => { + it("returns a task with pending status and a unique ID", () => { + const { task } = registry.create("index_document"); + expect(task.id).toBeTruthy(); + expect(task.status).toBe("pending"); + expect(task.type).toBe("index_document"); + expect(task.createdAt).toBeInstanceOf(Date); + }); + + it("returns an AbortSignal that is not yet aborted", () => { + const { signal } = registry.create("reindex_library"); + expect(signal.aborted).toBe(false); + }); + + it("assigns unique IDs to different tasks", () => { + const { task: t1 } = registry.create("index_document"); + const { task: t2 } = registry.create("index_document"); + expect(t1.id).not.toBe(t2.id); + }); + + it("supports all task types", () => { + const types: TaskType[] = [ + "index_document", + "reindex_library", + "sync_connector", + "install_pack", + ]; + for (const type of types) { + const { task } = registry.create(type); + expect(task.type).toBe(type); + } + }); + }); + + describe("get", () => { + it("returns the task after creation", () => { + const { task } = registry.create("install_pack"); + const fetched = registry.get(task.id); + expect(fetched).toBeDefined(); + expect(fetched?.id).toBe(task.id); + }); + + it("returns undefined for unknown ID", () => { + expect(registry.get("nonexistent-id")).toBeUndefined(); + }); + + it("prunes tasks whose completedAt is older than 1 hour", () => { + const { task } = registry.create("reindex_library"); + registry.update(task.id, { + status: "completed", + completedAt: new Date(Date.now() - 61 * 60 * 1000), // 61 minutes ago + }); + expect(registry.get(task.id)).toBeUndefined(); + }); + + it("does not prune tasks that completed less than 1 hour ago", () => { + const { task } = registry.create("sync_connector"); + registry.update(task.id, { + status: "completed", + completedAt: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago + }); + expect(registry.get(task.id)).toBeDefined(); + }); + }); + + describe("update", () => { + it("applies partial updates to a task", () => { + const { task } = registry.create("index_document"); + registry.update(task.id, { status: "running", startedAt: new Date() }); + const updated = registry.get(task.id); + expect(updated?.status).toBe("running"); + expect(updated?.startedAt).toBeInstanceOf(Date); + }); + + it("updates progress fields", () => { + const { task } = registry.create("reindex_library"); + registry.update(task.id, { progress: { current: 10, total: 100 } }); + const updated = registry.get(task.id); + expect(updated?.progress?.current).toBe(10); + expect(updated?.progress?.total).toBe(100); + }); + + it("is a no-op for unknown ID", () => { + expect(() => registry.update("nonexistent-id", { status: "completed" })).not.toThrow(); + }); + }); + + describe("cancel", () => { + it("returns not_found for unknown task ID", () => { + expect(registry.cancel("nonexistent-id")).toBe("not_found"); + }); + + it("cancels a pending task immediately", () => { + const { task } = registry.create("index_document"); + const outcome = registry.cancel(task.id); + expect(outcome).toBe("cancelled"); + const updated = registry.get(task.id); + expect(updated?.status).toBe("cancelled"); + expect(updated?.completedAt).toBeInstanceOf(Date); + }); + + it("aborts the signal when cancelling a pending task", () => { + const { task, signal } = registry.create("install_pack"); + registry.cancel(task.id); + expect(signal.aborted).toBe(true); + }); + + it("returns already_terminal for a completed task", () => { + const { task } = registry.create("sync_connector"); + registry.update(task.id, { status: "completed", completedAt: new Date() }); + expect(registry.cancel(task.id)).toBe("already_terminal"); + }); + + it("returns already_terminal for a failed task", () => { + const { task } = registry.create("reindex_library"); + registry.update(task.id, { status: "failed", completedAt: new Date() }); + expect(registry.cancel(task.id)).toBe("already_terminal"); + }); + + it("returns already_terminal for an already cancelled task", () => { + const { task } = registry.create("index_document"); + registry.cancel(task.id); + expect(registry.cancel(task.id)).toBe("already_terminal"); + }); + + it("aborts the signal when cancelling a running task", () => { + const { task, signal } = registry.create("reindex_library"); + registry.update(task.id, { status: "running", startedAt: new Date() }); + const outcome = registry.cancel(task.id); + expect(outcome).toBe("cancelled"); + expect(signal.aborted).toBe(true); + // Running tasks update their own status asynchronously; status remains "running" until they detect abort + expect(registry.get(task.id)?.status).toBe("running"); + }); + }); + + describe("TTL pruning", () => { + it("does not prune tasks without a completedAt", () => { + const { task } = registry.create("index_document"); + registry.update(task.id, { status: "running", startedAt: new Date() }); + // Simulate passage of time beyond TTL without setting completedAt + expect(registry.get(task.id)).toBeDefined(); + }); + + it("prunes multiple expired tasks in one get call", () => { + const { task: t1 } = registry.create("index_document"); + const { task: t2 } = registry.create("sync_connector"); + const expired = new Date(Date.now() - 61 * 60 * 1000); + registry.update(t1.id, { status: "completed", completedAt: expired }); + registry.update(t2.id, { status: "failed", completedAt: expired }); + // Trigger prune via a get call + registry.get("any-id"); + expect(registry.get(t1.id)).toBeUndefined(); + expect(registry.get(t2.id)).toBeUndefined(); + }); + }); + + describe("async task lifecycle simulation", () => { + it("transitions through pending -> running -> completed", () => { + const { task, signal } = registry.create("reindex_library"); + expect(task.status).toBe("pending"); + + registry.update(task.id, { status: "running", startedAt: new Date() }); + expect(registry.get(task.id)?.status).toBe("running"); + + // Simulate progress updates + registry.update(task.id, { progress: { current: 25, total: 100 } }); + expect(registry.get(task.id)?.progress?.current).toBe(25); + + registry.update(task.id, { + status: "completed", + completedAt: new Date(), + result: "Reindex complete. Total: 100", + progress: { current: 100, total: 100 }, + }); + + const completed = registry.get(task.id); + expect(completed?.status).toBe("completed"); + expect(completed?.result).toContain("Reindex complete"); + expect(signal.aborted).toBe(false); + }); + + it("transitions through pending -> running -> failed", () => { + const { task } = registry.create("install_pack"); + registry.update(task.id, { status: "running", startedAt: new Date() }); + registry.update(task.id, { + status: "failed", + completedAt: new Date(), + error: "Connection timeout", + }); + const failed = registry.get(task.id); + expect(failed?.status).toBe("failed"); + expect(failed?.error).toBe("Connection timeout"); + }); + + it("running task detects cancellation via signal.aborted", async () => { + const { task, signal } = registry.create("sync_connector"); + registry.update(task.id, { status: "running", startedAt: new Date() }); + + let detectedAbort = false; + const worker = new Promise((resolve) => { + // Simulate a worker that checks signal.aborted + const interval = setInterval(() => { + if (signal.aborted) { + detectedAbort = true; + clearInterval(interval); + registry.update(task.id, { status: "cancelled", completedAt: new Date() }); + resolve(); + } + }, 10); + }); + + registry.cancel(task.id); + await worker; + + expect(detectedAbort).toBe(true); + expect(registry.get(task.id)?.status).toBe("cancelled"); + }); + }); +}); + +describe("taskRegistry singleton", () => { + it("exports a module-level TaskRegistry instance", async () => { + const { taskRegistry } = await import("../../src/mcp/tasks.js"); + expect(taskRegistry).toBeInstanceOf(TaskRegistry); + }); +});