@disco_trooper/apple-notes-mcp 1.7.0 → 1.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,185 @@
1
+ import { describe, it, expect, vi } from "vitest";
2
+ import { createIndexJobManager } from "./job-manager.js";
3
+ import { IndexCancelledError } from "./contracts.js";
4
+
5
+ async function flushPromises(): Promise<void> {
6
+ await Promise.resolve();
7
+ await Promise.resolve();
8
+ }
9
+
10
+ describe("job-manager", () => {
11
+ it("starts a job and returns completed status", async () => {
12
+ const manager = createIndexJobManager({
13
+ indexNotes: vi.fn().mockImplementation(async (_mode, options) => {
14
+ options?.onProgress?.({
15
+ stage: "fetch",
16
+ current: 1,
17
+ total: 1,
18
+ message: "Fetched",
19
+ });
20
+ return {
21
+ total: 1,
22
+ indexed: 1,
23
+ errors: 0,
24
+ timeMs: 50,
25
+ };
26
+ }),
27
+ fullChunkIndex: vi.fn().mockResolvedValue({
28
+ totalNotes: 1,
29
+ totalChunks: 2,
30
+ indexed: 2,
31
+ timeMs: 30,
32
+ }),
33
+ now: () => Date.now(),
34
+ newId: () => "job-1",
35
+ });
36
+
37
+ const job = manager.start({ mode: "incremental" });
38
+ const initial = manager.get(job.id);
39
+ expect(initial?.status).toBe("queued");
40
+
41
+ await flushPromises();
42
+
43
+ const final = manager.get(job.id);
44
+ expect(final?.status).toBe("completed");
45
+ expect(final?.progress.percent).toBe(100);
46
+ });
47
+
48
+ it("deduplicates concurrent full jobs", async () => {
49
+ const unresolved = new Promise(() => {
50
+ // intentionally unresolved for dedupe check
51
+ });
52
+
53
+ const manager = createIndexJobManager({
54
+ indexNotes: vi.fn().mockReturnValue(unresolved),
55
+ fullChunkIndex: vi.fn().mockResolvedValue({
56
+ totalNotes: 1,
57
+ totalChunks: 1,
58
+ indexed: 1,
59
+ timeMs: 1,
60
+ }),
61
+ now: () => Date.now(),
62
+ newId: () => crypto.randomUUID(),
63
+ });
64
+
65
+ const first = manager.start({ mode: "full" });
66
+ const second = manager.start({ mode: "full" });
67
+ expect(first.id).toBe(second.id);
68
+ });
69
+
70
+ it("deduplicates active jobs across modes", async () => {
71
+ const unresolved = new Promise(() => {
72
+ // keep running
73
+ });
74
+
75
+ const manager = createIndexJobManager({
76
+ indexNotes: vi.fn().mockReturnValue(unresolved),
77
+ fullChunkIndex: vi.fn(),
78
+ now: () => Date.now(),
79
+ newId: () => crypto.randomUUID(),
80
+ });
81
+
82
+ const full = manager.start({ mode: "full" });
83
+ const incremental = manager.start({ mode: "incremental" });
84
+
85
+ expect(incremental.id).toBe(full.id);
86
+ });
87
+
88
+ it("tracks failed job state and error message", async () => {
89
+ const manager = createIndexJobManager({
90
+ indexNotes: vi.fn().mockRejectedValue(new Error("boom")),
91
+ fullChunkIndex: vi.fn(),
92
+ now: () => Date.now(),
93
+ newId: () => "job-fail",
94
+ });
95
+
96
+ const job = manager.start({ mode: "incremental" });
97
+ await flushPromises();
98
+
99
+ const final = manager.get(job.id);
100
+ expect(final?.status).toBe("failed");
101
+ expect(final?.error).toBeTruthy();
102
+ });
103
+
104
+ it("updates running progress beyond initial 10 percent", async () => {
105
+ const manager = createIndexJobManager({
106
+ indexNotes: vi.fn().mockImplementation((_mode, options) => {
107
+ options?.onProgress?.({
108
+ stage: "embed",
109
+ current: 1,
110
+ total: 2,
111
+ message: "Embedded batch 1/2",
112
+ });
113
+
114
+ return new Promise(() => {
115
+ // keep running so we can inspect intermediate progress
116
+ });
117
+ }),
118
+ fullChunkIndex: vi.fn(),
119
+ now: () => Date.now(),
120
+ newId: () => "job-progress",
121
+ });
122
+
123
+ const job = manager.start({ mode: "incremental" });
124
+ await flushPromises();
125
+
126
+ const running = manager.get(job.id);
127
+ expect(running?.status).toBe("running");
128
+ expect((running?.progress.percent ?? 0)).toBeGreaterThan(10);
129
+ });
130
+
131
+ it("marks job as cancelled when cancel is requested", async () => {
132
+ const manager = createIndexJobManager({
133
+ indexNotes: vi.fn().mockImplementation((_mode, options) => {
134
+ return new Promise((_resolve, reject) => {
135
+ options?.signal?.addEventListener("abort", () => {
136
+ reject(new IndexCancelledError("cancelled"));
137
+ });
138
+ });
139
+ }),
140
+ fullChunkIndex: vi.fn(),
141
+ now: () => Date.now(),
142
+ newId: () => "job-cancel",
143
+ });
144
+
145
+ const started = manager.start({ mode: "incremental" });
146
+ await flushPromises();
147
+
148
+ const cancelling = manager.cancel(started.id);
149
+ expect(cancelling?.status).toBe("cancelling");
150
+
151
+ await flushPromises();
152
+ const cancelled = manager.get(started.id);
153
+ expect(cancelled?.status).toBe("cancelled");
154
+ });
155
+
156
+ it("returns null when cancelling unknown job", async () => {
157
+ const manager = createIndexJobManager();
158
+ expect(manager.cancel("missing")).toBeNull();
159
+ });
160
+
161
+ it("does not start a queued job after cancel", async () => {
162
+ const indexNotes = vi.fn().mockResolvedValue({
163
+ total: 0,
164
+ indexed: 0,
165
+ errors: 0,
166
+ timeMs: 1,
167
+ });
168
+
169
+ const manager = createIndexJobManager({
170
+ indexNotes,
171
+ fullChunkIndex: vi.fn(),
172
+ now: () => Date.now(),
173
+ newId: () => "q1",
174
+ });
175
+
176
+ const started = manager.start({ mode: "incremental" });
177
+ const cancelled = manager.cancel(started.id);
178
+ expect(cancelled?.status).toBe("cancelled");
179
+
180
+ await flushPromises();
181
+
182
+ expect(indexNotes).not.toHaveBeenCalled();
183
+ expect(manager.get(started.id)?.status).toBe("cancelled");
184
+ });
185
+ });
@@ -0,0 +1,377 @@
1
+ import {
2
+ DEFAULT_INDEX_JOB_RETENTION_SECONDS,
3
+ MAX_INDEX_JOB_HISTORY,
4
+ } from "../config/constants.js";
5
+ import { indexNotes, type IndexResult } from "../search/indexer.js";
6
+ import { fullChunkIndex, type ChunkIndexResult } from "../search/chunk-indexer.js";
7
+ import {
8
+ type IndexProgressEvent,
9
+ type IndexRunOptions,
10
+ isIndexCancelledError,
11
+ } from "./contracts.js";
12
+ import { sanitizeErrorMessage } from "../utils/errors.js";
13
+ import { createDebugLogger } from "../utils/debug.js";
14
+
15
+ const debug = createDebugLogger("INDEX-JOBS");
16
+
17
+ export type IndexJobStatus =
18
+ | "queued"
19
+ | "running"
20
+ | "cancelling"
21
+ | "completed"
22
+ | "failed"
23
+ | "cancelled";
24
+
25
+ export interface IndexJobProgress {
26
+ phase: string;
27
+ percent: number;
28
+ message: string;
29
+ }
30
+
31
+ export interface IndexJob {
32
+ id: string;
33
+ mode: "full" | "incremental";
34
+ status: IndexJobStatus;
35
+ progress: IndexJobProgress;
36
+ createdAt: string;
37
+ startedAt?: string;
38
+ finishedAt?: string;
39
+ result?: IndexResult;
40
+ chunkResult?: ChunkIndexResult;
41
+ error?: string;
42
+ }
43
+
44
+ export interface StartIndexJobOptions {
45
+ mode: "full" | "incremental";
46
+ }
47
+
48
+ interface IndexJobManagerDeps {
49
+ indexNotes: (mode: "full" | "incremental", options?: IndexRunOptions) => Promise<IndexResult>;
50
+ fullChunkIndex: (options?: IndexRunOptions) => Promise<ChunkIndexResult>;
51
+ now: () => number;
52
+ newId: () => string;
53
+ }
54
+
55
+ export interface IndexJobManager {
56
+ start: (options: StartIndexJobOptions) => IndexJob;
57
+ get: (jobId: string) => IndexJob | null;
58
+ list: (limit?: number) => IndexJob[];
59
+ cancel: (jobId: string) => IndexJob | null;
60
+ }
61
+
62
+ function cloneJob(job: IndexJob): IndexJob {
63
+ return {
64
+ ...job,
65
+ progress: { ...job.progress },
66
+ };
67
+ }
68
+
69
+ function getRetentionSeconds(): number {
70
+ const raw = process.env.INDEX_JOB_RETENTION_SECONDS;
71
+ if (!raw) return DEFAULT_INDEX_JOB_RETENTION_SECONDS;
72
+ const parsed = Number.parseInt(raw, 10);
73
+ if (!Number.isFinite(parsed) || parsed <= 0) {
74
+ return DEFAULT_INDEX_JOB_RETENTION_SECONDS;
75
+ }
76
+ return parsed;
77
+ }
78
+
79
+ function clampPercent(percent: number): number {
80
+ return Math.max(0, Math.min(100, Math.round(percent)));
81
+ }
82
+
83
+ function ratio(current: number, total: number): number {
84
+ if (total <= 0) return 0;
85
+ return Math.max(0, Math.min(1, current / total));
86
+ }
87
+
88
+ function mapProgressForIncremental(event: IndexProgressEvent): number {
89
+ switch (event.stage) {
90
+ case "fetch":
91
+ return 5 + ratio(event.current, event.total) * 15;
92
+ case "prepare":
93
+ return 20 + ratio(event.current, event.total) * 10;
94
+ case "embed":
95
+ return 30 + ratio(event.current, event.total) * 35;
96
+ case "persist":
97
+ return 65 + ratio(event.current, event.total) * 25;
98
+ case "delete":
99
+ return 75 + ratio(event.current, event.total) * 10;
100
+ case "rebuild-fts":
101
+ return 90 + ratio(event.current, event.total) * 9;
102
+ case "done":
103
+ return 99;
104
+ default:
105
+ return 10;
106
+ }
107
+ }
108
+
109
+ function mapProgressForFullNotes(event: IndexProgressEvent): number {
110
+ switch (event.stage) {
111
+ case "fetch":
112
+ return 5 + ratio(event.current, event.total) * 15;
113
+ case "prepare":
114
+ return 20 + ratio(event.current, event.total) * 10;
115
+ case "embed":
116
+ return 30 + ratio(event.current, event.total) * 20;
117
+ case "persist":
118
+ return 50 + ratio(event.current, event.total) * 15;
119
+ case "rebuild-fts":
120
+ return 65 + ratio(event.current, event.total) * 5;
121
+ case "done":
122
+ return 70;
123
+ default:
124
+ return 10;
125
+ }
126
+ }
127
+
128
+ function mapProgressForFullChunks(event: IndexProgressEvent): number {
129
+ switch (event.stage) {
130
+ case "fetch":
131
+ return 70 + ratio(event.current, event.total) * 5;
132
+ case "prepare":
133
+ return 75 + ratio(event.current, event.total) * 5;
134
+ case "embed":
135
+ return 80 + ratio(event.current, event.total) * 10;
136
+ case "persist":
137
+ return 90 + ratio(event.current, event.total) * 5;
138
+ case "done":
139
+ return 95;
140
+ default:
141
+ return 75;
142
+ }
143
+ }
144
+
145
+ export function createIndexJobManager(
146
+ deps: Partial<IndexJobManagerDeps> = {}
147
+ ): IndexJobManager {
148
+ const resolvedDeps: IndexJobManagerDeps = {
149
+ indexNotes,
150
+ fullChunkIndex,
151
+ now: () => Date.now(),
152
+ newId: () => crypto.randomUUID(),
153
+ ...deps,
154
+ };
155
+
156
+ const jobs = new Map<string, IndexJob>();
157
+ const order: string[] = [];
158
+ const controllers = new Map<string, AbortController>();
159
+
160
+ function prune(): void {
161
+ const retentionMs = getRetentionSeconds() * 1000;
162
+ const now = resolvedDeps.now();
163
+
164
+ for (const jobId of [...order]) {
165
+ const job = jobs.get(jobId);
166
+ if (!job) continue;
167
+
168
+ if (
169
+ (job.status === "completed" || job.status === "failed" || job.status === "cancelled") &&
170
+ job.finishedAt
171
+ ) {
172
+ const ageMs = now - Date.parse(job.finishedAt);
173
+ if (ageMs > retentionMs) {
174
+ jobs.delete(jobId);
175
+ controllers.delete(jobId);
176
+ }
177
+ }
178
+ }
179
+
180
+ const alive = order.filter((id) => jobs.has(id));
181
+ order.length = 0;
182
+ order.push(...alive);
183
+
184
+ while (order.length > MAX_INDEX_JOB_HISTORY) {
185
+ const oldest = order.shift();
186
+ if (oldest) {
187
+ jobs.delete(oldest);
188
+ controllers.delete(oldest);
189
+ }
190
+ }
191
+ }
192
+
193
+ function getActiveJob(): IndexJob | null {
194
+ for (let i = order.length - 1; i >= 0; i -= 1) {
195
+ const job = jobs.get(order[i]);
196
+ if (!job) continue;
197
+ if (
198
+ (job.status === "queued" || job.status === "running" || job.status === "cancelling")
199
+ ) {
200
+ return job;
201
+ }
202
+ }
203
+ return null;
204
+ }
205
+
206
+ function setProgress(job: IndexJob, phase: string, percent: number, message: string): void {
207
+ job.progress = {
208
+ phase,
209
+ percent: clampPercent(percent),
210
+ message,
211
+ };
212
+ }
213
+
214
+ function noteProgressHandler(job: IndexJob) {
215
+ return (event: IndexProgressEvent): void => {
216
+ if (job.status === "cancelling") {
217
+ return;
218
+ }
219
+ const percent =
220
+ job.mode === "incremental"
221
+ ? mapProgressForIncremental(event)
222
+ : mapProgressForFullNotes(event);
223
+ setProgress(job, `indexing-notes/${event.stage}`, percent, event.message);
224
+ };
225
+ }
226
+
227
+ function chunkProgressHandler(job: IndexJob) {
228
+ return (event: IndexProgressEvent): void => {
229
+ if (job.status === "cancelling") {
230
+ return;
231
+ }
232
+ const percent = mapProgressForFullChunks(event);
233
+ setProgress(job, `indexing-chunks/${event.stage}`, percent, event.message);
234
+ };
235
+ }
236
+
237
+ async function run(jobId: string): Promise<void> {
238
+ const job = jobs.get(jobId);
239
+ if (!job) return;
240
+
241
+ if (job.status === "cancelled") {
242
+ return;
243
+ }
244
+
245
+ const controller = new AbortController();
246
+ controllers.set(jobId, controller);
247
+
248
+ try {
249
+ job.status = "running";
250
+ job.startedAt = new Date(resolvedDeps.now()).toISOString();
251
+ setProgress(job, "indexing-notes", 5, `Running ${job.mode} index`);
252
+
253
+ const result = await resolvedDeps.indexNotes(job.mode, {
254
+ signal: controller.signal,
255
+ onProgress: noteProgressHandler(job),
256
+ });
257
+ job.result = result;
258
+
259
+ if (job.mode === "full") {
260
+ setProgress(job, "indexing-chunks", 70, "Building chunk index");
261
+ const chunkResult = await resolvedDeps.fullChunkIndex({
262
+ signal: controller.signal,
263
+ onProgress: chunkProgressHandler(job),
264
+ });
265
+ job.chunkResult = chunkResult;
266
+ }
267
+
268
+ job.status = "completed";
269
+ job.finishedAt = new Date(resolvedDeps.now()).toISOString();
270
+ setProgress(job, "completed", 100, "Index job completed");
271
+ } catch (error) {
272
+ const cancelled = controller.signal.aborted || isIndexCancelledError(error);
273
+ if (cancelled) {
274
+ job.status = "cancelled";
275
+ job.finishedAt = new Date(resolvedDeps.now()).toISOString();
276
+ setProgress(job, "cancelled", 100, "Index job cancelled");
277
+ } else {
278
+ job.status = "failed";
279
+ job.finishedAt = new Date(resolvedDeps.now()).toISOString();
280
+ job.error = sanitizeErrorMessage(error instanceof Error ? error.message : String(error));
281
+ setProgress(job, "failed", 100, "Index job failed");
282
+ debug("Background index job failed:", error);
283
+ }
284
+ } finally {
285
+ controllers.delete(jobId);
286
+ prune();
287
+ }
288
+ }
289
+
290
+ function start(options: StartIndexJobOptions): IndexJob {
291
+ prune();
292
+
293
+ const existing = getActiveJob();
294
+ if (existing) {
295
+ return cloneJob(existing);
296
+ }
297
+
298
+ const id = resolvedDeps.newId();
299
+ const job: IndexJob = {
300
+ id,
301
+ mode: options.mode,
302
+ status: "queued",
303
+ progress: {
304
+ phase: "queued",
305
+ percent: 0,
306
+ message: "Job queued",
307
+ },
308
+ createdAt: new Date(resolvedDeps.now()).toISOString(),
309
+ };
310
+
311
+ jobs.set(id, job);
312
+ order.push(id);
313
+
314
+ queueMicrotask(() => {
315
+ void run(id);
316
+ });
317
+
318
+ return cloneJob(job);
319
+ }
320
+
321
+ function get(jobId: string): IndexJob | null {
322
+ prune();
323
+ const job = jobs.get(jobId);
324
+ return job ? cloneJob(job) : null;
325
+ }
326
+
327
+ function list(limit = 10): IndexJob[] {
328
+ prune();
329
+ const boundedLimit = Math.max(1, Math.min(limit, 50));
330
+ const ids = [...order].reverse().slice(0, boundedLimit);
331
+ return ids
332
+ .map((id) => jobs.get(id))
333
+ .filter((job): job is IndexJob => job !== undefined)
334
+ .map(cloneJob);
335
+ }
336
+
337
+ function cancel(jobId: string): IndexJob | null {
338
+ prune();
339
+ const job = jobs.get(jobId);
340
+ if (!job) {
341
+ return null;
342
+ }
343
+
344
+ if (job.status === "completed" || job.status === "failed" || job.status === "cancelled") {
345
+ return cloneJob(job);
346
+ }
347
+
348
+ if (job.status === "queued") {
349
+ job.status = "cancelled";
350
+ job.finishedAt = new Date(resolvedDeps.now()).toISOString();
351
+ setProgress(job, "cancelled", 100, "Index job cancelled before start");
352
+ return cloneJob(job);
353
+ }
354
+
355
+ job.status = "cancelling";
356
+ setProgress(job, "cancelling", job.progress.percent, "Cancellation requested");
357
+ controllers.get(jobId)?.abort();
358
+
359
+ return cloneJob(job);
360
+ }
361
+
362
+ return {
363
+ start,
364
+ get,
365
+ list,
366
+ cancel,
367
+ };
368
+ }
369
+
370
+ let defaultManager: IndexJobManager | null = null;
371
+
372
+ export function getIndexJobManager(): IndexJobManager {
373
+ if (!defaultManager) {
374
+ defaultManager = createIndexJobManager();
375
+ }
376
+ return defaultManager;
377
+ }
@@ -74,16 +74,32 @@ describe("createNote", () => {
74
74
  });
75
75
 
76
76
  it("should create note successfully", async () => {
77
- vi.mocked(runJxa).mockResolvedValueOnce("ok");
77
+ vi.mocked(runJxa).mockResolvedValueOnce(
78
+ JSON.stringify({ id: "note-1", title: "New Note", folder: "Work" })
79
+ );
78
80
 
79
- await expect(createNote("New Note", "Content", "Work")).resolves.toBeUndefined();
81
+ await expect(createNote("New Note", "Content", "Work")).resolves.toEqual({
82
+ id: "note-1",
83
+ title: "New Note",
84
+ folder: "Work",
85
+ requestedTitle: "New Note",
86
+ titleChanged: false,
87
+ });
80
88
  });
81
89
 
82
90
  it("should allow duplicate titles (Apple Notes uses IDs)", async () => {
83
- vi.mocked(runJxa).mockResolvedValueOnce("ok");
91
+ vi.mocked(runJxa).mockResolvedValueOnce(
92
+ JSON.stringify({ id: "note-2", title: "Existing Note", folder: "Notes" })
93
+ );
84
94
 
85
95
  // Should not throw even if a note with same title exists
86
- await expect(createNote("Existing Note", "Content")).resolves.toBeUndefined();
96
+ await expect(createNote("Existing Note", "Content")).resolves.toEqual({
97
+ id: "note-2",
98
+ title: "Existing Note",
99
+ folder: "Notes",
100
+ requestedTitle: "Existing Note",
101
+ titleChanged: false,
102
+ });
87
103
  });
88
104
  });
89
105
 
@@ -116,6 +132,7 @@ describe("updateNote", () => {
116
132
 
117
133
  const result = await updateNote("Test", "New Content");
118
134
  expect(result).toEqual({
135
+ id: "123",
119
136
  originalTitle: "Test",
120
137
  newTitle: "Test",
121
138
  folder: "Work",
@@ -133,6 +150,7 @@ describe("updateNote", () => {
133
150
 
134
151
  const result = await updateNote("Original Title", "# New Heading\n\nContent");
135
152
  expect(result).toEqual({
153
+ id: "123",
136
154
  originalTitle: "Original Title",
137
155
  newTitle: "New Heading",
138
156
  folder: "Work",
@@ -179,7 +197,11 @@ describe("deleteNote", () => {
179
197
  });
180
198
  vi.mocked(runJxa).mockResolvedValueOnce("ok");
181
199
 
182
- await expect(deleteNote("Test")).resolves.toBeUndefined();
200
+ await expect(deleteNote("Test")).resolves.toEqual({
201
+ id: "123",
202
+ title: "Test",
203
+ folder: "Work",
204
+ });
183
205
  });
184
206
 
185
207
  it("should include suggestions in error when multiple notes found", async () => {
@@ -221,7 +243,12 @@ describe("moveNote", () => {
221
243
  });
222
244
  vi.mocked(runJxa).mockResolvedValueOnce("ok");
223
245
 
224
- await expect(moveNote("Test", "Personal")).resolves.toBeUndefined();
246
+ await expect(moveNote("Test", "Personal")).resolves.toEqual({
247
+ id: "123",
248
+ title: "Test",
249
+ fromFolder: "Work",
250
+ toFolder: "Personal",
251
+ });
225
252
  });
226
253
 
227
254
  it("should include suggestions in error when multiple notes found", async () => {