• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

graphty-org / graphty-element / 20496206861

25 Dec 2025 12:31AM UTC coverage: 82.525% (-0.02%) from 82.543%
20496206861

push

github

apowers313
fix: fix webllm dynamic import, change default server / port for dev

5183 of 6111 branches covered (84.81%)

Branch coverage included in aggregate %.

9 of 20 new or added lines in 4 files covered. (45.0%)

4 existing lines in 3 files now uncovered.

24900 of 30342 relevant lines covered (82.06%)

6729.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.61
/src/managers/OperationQueueManager.ts
1
import PQueue from "p-queue";
3✔
2
import toposort from "toposort";
3!
3

4
import {OBSOLESCENCE_RULES} from "../constants/obsolescence-rules";
3✔
5
import {GraphtyLogger, type Logger} from "../logging/GraphtyLogger.js";
3✔
6
import type {OperationMetadata, OperationProgress} from "../types/operations";
7
import type {EventManager} from "./EventManager";
8
import type {Manager} from "./interfaces";
9

10
// Constants for operation queue management
11
const PROGRESS_CANCELLATION_THRESHOLD = 90; // Progress threshold for respecting in-progress operations
3✔
12
const CLEANUP_DELAY_MS = 1000; // Delay before cleaning up operation progress tracking
3✔
13
const OPERATION_POLL_INTERVAL_MS = 10; // Polling interval for waitForCompletion
3✔
14

15
export interface ProgressContext {
16
    setProgress(percent: number): void;
17
    setMessage(message: string): void;
18
    setPhase(phase: string): void;
19
}
20

21
export interface OperationContext {
22
    signal: AbortSignal;
23
    progress: ProgressContext;
24
    id: string;
25
}
26

27
export interface Operation {
28
    id: string;
29
    category: OperationCategory;
30
    execute: (context: OperationContext) => Promise<void> | void;
31
    abortController?: AbortController;
32
    metadata?: OperationMetadata;
33
    resolve?: (value: void | PromiseLike<void>) => void;
34
    reject?: (reason?: unknown) => void;
35
}
36

37
export type OperationCategory =
38
    | "style-init" // Initialize style template
39
    | "style-apply" // Apply styles to existing elements
40
    | "data-add" // Add nodes/edges
41
    | "data-remove" // Remove nodes/edges
42
    | "data-update" // Update node/edge properties
43
    | "layout-set" // Set layout engine
44
    | "layout-update" // Update layout positions
45
    | "algorithm-run" // Run graph algorithms
46
    | "camera-update" // Update camera position/mode
47
    | "render-update"; // Update rendering settings
48

49
export class OperationQueueManager implements Manager {
3✔
50
    private queue: PQueue;
51
    private pendingOperations = new Map<string, Operation>();
11✔
52
    private operationCounter = 0;
11✔
53
    private batchingEnabled = true;
11✔
54
    private currentBatch = new Set<string>();
11✔
55
    private logger: Logger = GraphtyLogger.getLogger(["graphty", "operation"]);
11✔
56

57
    // Progress tracking
58
    private operationProgress = new Map<string, OperationProgress>();
11✔
59

60
    // Active operation controllers for cancellation
61
    private activeControllers = new Map<string, AbortController>();
11✔
62

63
    // Track running vs queued operations
64
    private runningOperations = new Map<string, Operation>();
11✔
65
    private queuedOperations = new Map<string, Operation>();
11✔
66

67
    // Track completed operation categories for cross-batch dependency resolution
68
    private completedCategories = new Set<OperationCategory>();
11✔
69

70
    // Batch mode support
71
    private batchMode = false;
11✔
72
    private batchOperations = new Set<string>();
11✔
73
    private batchPromises = new Map<string, Promise<void>>();
11✔
74

75
    // Trigger system
76
    private triggers = new Map<OperationCategory, ((metadata?: OperationMetadata) => {
11✔
77
        category: OperationCategory;
78
        execute: (context: OperationContext) => Promise<void> | void;
79
        description?: string;
80
    } | null)[]>();
11✔
81

82
    // Callback for when operations are queued (for testing)
83
    onOperationQueued?: (category: OperationCategory, description?: string) => void;
84

85
    // Check if layout engine exists (will be set by Graph)
86
    hasLayoutEngine?: () => boolean;
87

88
    // Dependency graph based on actual manager dependencies
89
    private static readonly CATEGORY_DEPENDENCIES: [OperationCategory, OperationCategory][] = [
11✔
90
        // Style must be initialized before applying
91
        ["style-apply", "style-init"],
11✔
92

93
        // Data operations depend on style being ready
94
        ["data-add", "style-init"],
11✔
95
        ["data-update", "style-init"],
11✔
96

97
        // Layout-set does NOT depend on data - it can create an empty layout engine
98
        // Data will be added to the layout engine when data-add runs later
99
        // ["layout-set", "data-add"], // REMOVED for stateless design
100

101
        // Layout-update DOES depend on data existing
102
        ["layout-update", "data-add"],
11✔
103
        // Layout-update depends on layout being set first
104
        ["layout-update", "layout-set"],
11✔
105

106
        // Algorithms depend on data
107
        ["algorithm-run", "data-add"],
11✔
108

109
        // Style application depends on algorithms (for calculated styles)
110
        ["style-apply", "algorithm-run"],
11✔
111

112
        // Camera updates may depend on layout for zoom-to-fit
113
        ["camera-update", "layout-set"],
11✔
114

115
        // Render updates come last
116
        ["render-update", "style-apply"],
11✔
117
        ["render-update", "data-add"],
11✔
118
        ["render-update", "layout-update"],
11✔
119
    ];
11✔
120

121
    // Post-execution triggers: operations that automatically trigger after other operations
122
    private static readonly POST_EXECUTION_TRIGGERS: Partial<Record<OperationCategory, OperationCategory[]>> = {
11✔
123
        "data-add": ["layout-update"],
11✔
124
        "data-remove": ["layout-update"],
11✔
125
        "data-update": ["layout-update"],
11✔
126
        "algorithm-run": ["style-apply"],
11✔
127
    };
11✔
128

129
    constructor(
11✔
130
        private eventManager: EventManager,
873✔
131
        options: {
873✔
132
            concurrency?: number;
133
            autoStart?: boolean;
134
            intervalCap?: number;
135
            interval?: number;
136
        } = {},
873✔
137
    ) {
873✔
138
        const queueOptions: {
873✔
139
            concurrency?: number;
140
            autoStart?: boolean;
141
            intervalCap?: number;
142
            interval?: number;
143
        } = {
873✔
144
            concurrency: options.concurrency ?? 1, // Sequential by default
873✔
145
            autoStart: options.autoStart ?? true,
873✔
146
        };
873✔
147

148
        if (options.intervalCap !== undefined) {
873!
149
            queueOptions.intervalCap = options.intervalCap;
×
150
        }
×
151

152
        if (options.interval !== undefined) {
873!
153
            queueOptions.interval = options.interval;
×
154
        }
×
155

156
        this.queue = new PQueue(queueOptions);
873✔
157

158
        // Listen for queue events
159
        this.queue.on("active", () => {
873✔
160
            this.eventManager.emitGraphEvent("operation-queue-active", {
39,730✔
161
                size: this.queue.size,
39,730✔
162
                pending: this.queue.pending,
39,730✔
163
            });
39,730✔
164
        });
873✔
165

166
        this.queue.on("idle", () => {
873✔
167
            this.eventManager.emitGraphEvent("operation-queue-idle", {});
1,740✔
168
        });
873✔
169
    }
873✔
170

171
    async init(): Promise<void> {
11✔
172
        // No initialization needed
173
    }
747✔
174

175
    dispose(): void {
11✔
176
        // Cancel all active operations
177
        this.activeControllers.forEach((controller) => {
586✔
178
            controller.abort();
36,301✔
179
        });
586✔
180

181
        this.queue.clear();
586✔
182
        this.pendingOperations.clear();
586✔
183
        this.operationProgress.clear();
586✔
184
        this.currentBatch.clear();
586✔
185
        this.activeControllers.clear();
586✔
186
        this.runningOperations.clear();
586✔
187
        this.queuedOperations.clear();
586✔
188
    }
586✔
189

190
    /**
191
     * Queue an operation for execution
192
     * Returns the operation ID
193
     */
194
    queueOperation(
11✔
195
        category: OperationCategory,
42,031✔
196
        execute: (context: OperationContext) => Promise<void> | void,
42,031✔
197
        options?: Partial<OperationMetadata>,
42,031✔
198
    ): string {
42,031✔
199
        const id = `op-${this.operationCounter++}`;
42,031✔
200
        const controller = new AbortController();
42,031✔
201

202
        // Initialize progress tracking with all fields
203
        this.operationProgress.set(id, {
42,031✔
204
            percent: 0,
42,031✔
205
            message: undefined,
42,031✔
206
            phase: undefined,
42,031✔
207
            startTime: Date.now(),
42,031✔
208
            lastUpdate: Date.now(),
42,031✔
209
        });
42,031✔
210

211
        const operation: Operation = {
42,031✔
212
            id,
42,031✔
213
            category,
42,031✔
214
            execute: async(ctx) => {
42,031✔
215
                const result = execute(ctx);
15,061✔
216
                if (result instanceof Promise) {
15,061✔
217
                    await result;
2,386✔
218
                }
2,359✔
219
            },
15,061✔
220
            abortController: controller,
42,031✔
221
            metadata: {
42,031✔
222
                ... options,
42,031✔
223
                timestamp: Date.now(),
42,031✔
224
            },
42,031✔
225
        };
42,031✔
226

227
        this.pendingOperations.set(id, operation);
42,031✔
228
        this.activeControllers.set(id, controller);
42,031✔
229

230
        this.logger.debug("Operation queued", {
42,031✔
231
            id,
42,031✔
232
            category,
42,031✔
233
            description: options?.description,
42,031✔
234
        });
42,031✔
235

236
        // Handle batch mode differently
237
        if (this.batchMode) {
42,031✔
238
            // In batch mode: queue but don't execute yet
239
            this.batchOperations.add(id);
177✔
240
        } else {
42,031✔
241
            // Normal mode: add to current batch for immediate execution
242
            this.currentBatch.add(id);
41,854✔
243

244
            // Apply obsolescence rules before scheduling
245
            this.applyObsolescenceRules(operation);
41,854✔
246

247
            // Schedule batch execution
248
            if (this.batchingEnabled) {
41,854✔
249
                // When batching is enabled, schedule batch execution on next microtask
250
                if (this.currentBatch.size === 1) {
41,854✔
251
                    queueMicrotask(() => {
41,810✔
252
                        this.executeBatch();
41,810✔
253
                    });
41,810✔
254
                }
41,810✔
255
            } else {
41,854!
256
                // When batching is disabled, execute immediately
257
                queueMicrotask(() => {
×
258
                    this.executeBatch();
×
259
                });
×
260
            }
×
261
        }
41,854✔
262

263
        return id;
42,031✔
264
    }
42,031✔
265

266
    /**
267
     * Apply obsolescence rules for a new operation
268
     */
269
    private applyObsolescenceRules(newOperation: Operation): void {
11✔
270
        const {metadata} = newOperation;
41,854✔
271
        const defaultRule = OBSOLESCENCE_RULES[newOperation.category];
41,854✔
272
        // Only apply obsolescence if explicitly requested via metadata or default rules
273
        if (!metadata?.obsoletes &&
41,854✔
274
            !metadata?.shouldObsolete &&
41,842✔
275
            !metadata?.respectProgress &&
41,837✔
276
            !metadata?.skipRunning &&
41,837✔
277
            !defaultRule?.obsoletes) {
41,854✔
278
            return;
154✔
279
        }
154✔
280

281
        // Get obsolescence rules from metadata or defaults
282
        const customObsoletes = metadata?.obsoletes ?? [];
41,854✔
283
        const defaultObsoletes = defaultRule?.obsoletes ?? [];
41,854✔
284

285
        const categoriesToObsolete = [... new Set([... customObsoletes, ... defaultObsoletes])];
41,854✔
286
        const shouldObsolete = metadata?.shouldObsolete;
41,854✔
287
        const skipRunning = (metadata?.skipRunning ?? defaultRule?.skipRunning) ?? false;
41,854✔
288
        const respectProgress = (metadata?.respectProgress ?? defaultRule?.respectProgress) ?? true;
41,854✔
289

290
        // Check all operations for obsolescence
291
        const allOperations = [
41,854✔
292
            ... this.pendingOperations.values(),
41,854✔
293
            ... this.queuedOperations.values(),
41,854✔
294
            ... (skipRunning ? [] : this.runningOperations.values()),
41,854✔
295
        ];
41,854✔
296

297
        for (const operation of allOperations) {
41,854✔
298
            // Skip if it's the same operation
299
            if (operation.id === newOperation.id) {
14,315,289✔
300
                continue;
41,700✔
301
            }
41,700✔
302

303
            let shouldCancel = false;
14,273,589✔
304

305
            // Check category-based obsolescence
306
            if (categoriesToObsolete.includes(operation.category)) {
14,315,289✔
307
                shouldCancel = true;
24,856✔
308
            }
24,856✔
309

310
            // Check custom shouldObsolete function
311
            if (!shouldCancel && shouldObsolete) {
14,315,289✔
312
                shouldCancel = shouldObsolete({
3✔
313
                    category: operation.category,
3✔
314
                    id: operation.id,
3✔
315
                    metadata: operation.metadata,
3✔
316
                });
3✔
317
            }
3✔
318

319
            if (shouldCancel) {
14,314,860✔
320
                // Check progress if respectProgress is enabled
321
                if (respectProgress && this.runningOperations.has(operation.id)) {
24,858✔
322
                    const progress = this.operationProgress.get(operation.id);
5✔
323
                    if (progress && progress.percent >= PROGRESS_CANCELLATION_THRESHOLD) {
5!
324
                        // Don't cancel near-complete operations
UNCOV
325
                        continue;
×
UNCOV
326
                    }
×
327
                }
5✔
328

329
                // Cancel the operation
330
                const controller = this.activeControllers.get(operation.id);
24,858✔
331
                if (controller && !controller.signal.aborted) {
24,858✔
332
                    controller.abort();
24,820✔
333

334
                    // Emit obsolescence event
335
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
24,820✔
336
                        id: operation.id,
24,820✔
337
                        category: operation.category,
24,820✔
338
                        reason: `Obsoleted by ${newOperation.category} operation`,
24,820✔
339
                        obsoletedBy: newOperation.id,
24,820✔
340
                    });
24,820✔
341

342
                    // Remove from pending/queued
343
                    this.pendingOperations.delete(operation.id);
24,820✔
344
                    this.queuedOperations.delete(operation.id);
24,820✔
345
                    this.currentBatch.delete(operation.id);
24,820✔
346
                }
24,820✔
347
            }
24,858✔
348
        }
14,315,289✔
349
    }
41,854✔
350

351
    /**
352
     * Execute all operations in the current batch
353
     */
354
    private executeBatch(): void {
11✔
355
        // Get all operations in current batch
356
        const batchIds = Array.from(this.currentBatch);
83,619✔
357
        this.currentBatch.clear();
83,619✔
358

359
        const operations = batchIds
83,619✔
360
            .map((id) => this.pendingOperations.get(id))
83,619✔
361
            .filter((op): op is Operation => op !== undefined);
83,619✔
362

363
        if (operations.length > 0) {
83,619✔
364
            this.logger.debug("Executing operation batch", {
41,833✔
365
                operationCount: operations.length,
41,833✔
366
                categories: [... new Set(operations.map((op) => op.category))],
41,833✔
367
            });
41,833✔
368
        }
41,833✔
369

370
        // Remove from pending and add to queued
371
        batchIds.forEach((id) => {
83,619✔
372
            const op = this.pendingOperations.get(id);
42,017✔
373
            if (op) {
42,017✔
374
                this.queuedOperations.set(id, op);
42,017✔
375
            }
42,017✔
376

377
            this.pendingOperations.delete(id);
42,017✔
378
        });
83,619✔
379

380
        if (operations.length === 0) {
83,619✔
381
            return;
41,786✔
382
        }
41,786✔
383

384
        // Sort operations by dependency order
385
        const sortedOperations = this.sortOperations(operations);
41,833✔
386

387
        // Add to queue with p-queue's signal support
388
        for (const operation of sortedOperations) {
42,307✔
389
            // queue.add always returns a promise
390
            void this.queue.add(
42,017✔
391
                async({signal}) => {
42,017✔
392
                    try {
15,061✔
393
                        const context: OperationContext = {
15,061✔
394
                            signal: signal ?? operation.abortController?.signal ?? new AbortController().signal,
15,061!
395
                            progress: this.createProgressContext(operation.id, operation.category),
15,061✔
396
                            id: operation.id,
15,061✔
397
                        };
15,061✔
398
                        await this.executeOperation(operation, context);
15,061✔
399
                    } finally {
15,060✔
400
                        // Cleanup progress and controller after delay
401
                        setTimeout(() => {
15,060✔
402
                            this.operationProgress.delete(operation.id);
13,589✔
403
                            this.activeControllers.delete(operation.id);
13,589✔
404
                        }, CLEANUP_DELAY_MS);
15,060✔
405
                    }
15,060✔
406
                },
15,061✔
407
                {
42,017✔
408
                    signal: operation.abortController?.signal,
42,017✔
409
                },
42,017✔
410
            ).catch((error: unknown) => {
42,017✔
411
                if (error && (error as Error).name === "AbortError") {
24,792✔
412
                    this.eventManager.emitGraphEvent("operation-obsoleted", {
24,792✔
413
                        id: operation.id,
24,792✔
414
                        category: operation.category,
24,792✔
415
                        reason: "Obsoleted by newer operation",
24,792✔
416
                    });
24,792✔
417
                }
24,792✔
418
            });
42,017✔
419
        }
42,017✔
420

421
        // Emit batch complete event after all operations
422
        this.eventManager.emitGraphEvent("operation-batch-complete", {
41,833✔
423
            operationCount: sortedOperations.length,
41,833✔
424
            operations: sortedOperations.map((op) => ({
41,833✔
425
                id: op.id,
42,017✔
426
                category: op.category,
42,017✔
427
                description: op.metadata?.description,
42,017✔
428
            })),
41,833✔
429
        });
41,833✔
430
    }
83,619✔
431

432
    /**
433
     * Sort operations based on category dependencies
434
     */
435
    private sortOperations(operations: Operation[]): Operation[] {
11✔
436
        // Group by category
437
        const operationsByCategory = new Map<OperationCategory, Operation[]>();
41,833✔
438
        operations.forEach((op) => {
41,833✔
439
            const ops = operationsByCategory.get(op.category) ?? [];
42,017✔
440
            ops.push(op);
42,017✔
441
            operationsByCategory.set(op.category, ops);
42,017✔
442
        });
41,833✔
443

444
        // Get unique categories
445
        const categories = Array.from(operationsByCategory.keys());
41,833✔
446

447
        // Build dependency edges for toposort
448
        const edges: [OperationCategory, OperationCategory][] = [];
41,833✔
449
        OperationQueueManager.CATEGORY_DEPENDENCIES.forEach(([dependent, dependency]) => {
41,833✔
450
            // Only add edge if:
451
            // 1. The dependent operation is in this batch
452
            // 2. The dependency hasn't already been completed in a previous batch
453
            // 3. The dependency is either in this batch OR needs to be waited for
454
            if (categories.includes(dependent) && !this.completedCategories.has(dependency)) {
460,163✔
455
                if (categories.includes(dependency)) {
592✔
456
                    // Both are in this batch - normal dependency
457
                    edges.push([dependency, dependent]); // toposort expects [from, to]
24✔
458
                }
24✔
459
                // If dependency is not in this batch and not completed, the dependent operation
460
                // may fail or produce incorrect results. In a truly stateless system, we should
461
                // either wait for the dependency or auto-queue it. For now, we allow it to proceed
462
                // and rely on the manager's internal checks (e.g., DataManager checking for styles).
463
            }
592✔
464
        });
41,833✔
465

466
        // Sort categories
467
        let sortedCategories: OperationCategory[];
41,833✔
468
        try {
41,833✔
469
            sortedCategories = toposort.array(categories, edges);
41,833✔
470
        } catch (error) {
41,833!
471
            // Circular dependency detected - emit error event
472
            this.eventManager.emitGraphError(
×
473
                null,
×
474
                error instanceof Error ? error : new Error("Circular dependency detected"),
×
475
                "other",
×
476
                {categories, edges},
×
477
            );
×
478
            sortedCategories = categories; // Fallback to original order
×
479
        }
×
480

481
        // Flatten operations in sorted category order
482
        const sortedOperations: Operation[] = [];
41,833✔
483
        sortedCategories.forEach((category) => {
41,833✔
484
            const categoryOps = operationsByCategory.get(category) ?? [];
41,878!
485
            sortedOperations.push(... categoryOps);
41,878✔
486
        });
41,833✔
487

488
        return sortedOperations;
41,833✔
489
    }
41,833✔
490

491
    /**
492
     * Execute a single operation
493
     */
494
    private async executeOperation(operation: Operation, context: OperationContext): Promise<void> {
11✔
495
        // Move from queued to running
496
        this.queuedOperations.delete(operation.id);
15,061✔
497
        this.runningOperations.set(operation.id, operation);
15,061✔
498

499
        this.logger.debug("Operation started", {
15,061✔
500
            id: operation.id,
15,061✔
501
            category: operation.category,
15,061✔
502
            description: operation.metadata?.description,
15,061✔
503
        });
15,061✔
504

505
        this.eventManager.emitGraphEvent("operation-start", {
15,061✔
506
            id: operation.id,
15,061✔
507
            category: operation.category,
15,061✔
508
            description: operation.metadata?.description,
15,061✔
509
        });
15,061✔
510

511
        const startTime = performance.now();
15,061✔
512

513
        try {
15,061✔
514
            await operation.execute(context);
15,061✔
515

516
            // Mark as complete
517
            context.progress.setProgress(100);
15,030✔
518

519
            // Mark category as completed for cross-batch dependency resolution
520
            this.completedCategories.add(operation.category);
15,030✔
521

522
            const duration = performance.now() - startTime;
15,030✔
523

524
            this.logger.debug("Operation completed", {
15,030✔
525
                id: operation.id,
15,030✔
526
                category: operation.category,
15,030✔
527
                duration: duration.toFixed(2),
15,030✔
528
            });
15,030✔
529

530
            this.eventManager.emitGraphEvent("operation-complete", {
15,030✔
531
                id: operation.id,
15,030✔
532
                category: operation.category,
15,030✔
533
                duration,
15,030✔
534
            });
15,030✔
535

536
            // Resolve the operation's promise
537
            if (operation.resolve) {
15,036✔
538
                operation.resolve();
14,948✔
539
            }
14,948✔
540

541
            // Trigger post-execution operations if not skipped
542
            if (!operation.metadata?.skipTriggers) {
15,061✔
543
                this.triggerPostExecutionOperations(operation);
14,585✔
544
            }
14,585✔
545
        } catch (error) {
15,061✔
546
            if (error && (error as Error).name === "AbortError") {
30✔
547
                // Reject the operation's promise
548
                if (operation.reject) {
3!
549
                    operation.reject(error);
×
550
                }
×
551

552
                // Remove from running on abort
553
                this.runningOperations.delete(operation.id);
3✔
554
                throw error; // Let p-queue handle abort errors
3✔
555
            }
3✔
556

557
            // Reject the operation's promise
558
            if (operation.reject) {
27✔
559
                operation.reject(error);
24✔
560
            }
24✔
561

562
            this.handleOperationError(operation, error);
27✔
563
        } finally {
15,061✔
564
            // Always remove from running operations
565
            this.runningOperations.delete(operation.id);
15,060✔
566
        }
15,060✔
567
    }
15,061✔
568

569
    /**
570
     * Create progress context for an operation
571
     */
572
    private createProgressContext(id: string, category: OperationCategory): ProgressContext {
11✔
573
        return {
15,061✔
574
            setProgress: (percent: number) => {
15,061✔
575
                const progress = this.operationProgress.get(id);
15,089✔
576
                if (progress) {
15,089✔
577
                    progress.percent = percent;
15,062✔
578
                    progress.lastUpdate = Date.now();
15,062✔
579
                    this.emitProgressUpdate(id, category, progress);
15,062✔
580
                }
15,062✔
581
            },
15,089✔
582
            setMessage: (message: string) => {
15,061✔
583
                const progress = this.operationProgress.get(id);
75✔
584
                if (progress) {
75✔
585
                    progress.message = message;
75✔
586
                    progress.lastUpdate = Date.now();
75✔
587
                    this.emitProgressUpdate(id, category, progress);
75✔
588
                }
75✔
589
            },
75✔
590
            setPhase: (phase: string) => {
15,061✔
591
                const progress = this.operationProgress.get(id);
3✔
592
                if (progress) {
3✔
593
                    progress.phase = phase;
3✔
594
                    progress.lastUpdate = Date.now();
3✔
595
                    this.emitProgressUpdate(id, category, progress);
3✔
596
                }
3✔
597
            },
3✔
598
        };
15,061✔
599
    }
15,061✔
600

601
    /**
602
     * Emit progress update event
603
     */
604
    private emitProgressUpdate(id: string, category: OperationCategory, progress: OperationProgress): void {
11✔
605
        this.eventManager.emitGraphEvent("operation-progress", {
15,140✔
606
            id,
15,140✔
607
            category,
15,140✔
608
            progress: progress.percent,
15,140✔
609
            message: progress.message,
15,140✔
610
            phase: progress.phase,
15,140✔
611
            duration: Date.now() - progress.startTime,
15,140✔
612
        });
15,140✔
613
    }
15,140✔
614

615
    /**
616
     * Handle operation errors
617
     */
618
    private handleOperationError(operation: Operation, error: unknown): void {
11✔
619
        this.logger.error(
27✔
620
            "Operation failed",
27✔
621
            error instanceof Error ? error : new Error(String(error)),
27!
622
            {
27✔
623
                id: operation.id,
27✔
624
                category: operation.category,
27✔
625
                description: operation.metadata?.description,
27✔
626
            },
27✔
627
        );
27✔
628

629
        this.eventManager.emitGraphError(
27✔
630
            null,
27✔
631
            error instanceof Error ? error : new Error(String(error)),
27!
632
            "other",
27✔
633
            {
27✔
634
                operationId: operation.id,
27✔
635
                category: operation.category,
27✔
636
                description: operation.metadata?.description,
27✔
637
            },
27✔
638
        );
27✔
639
    }
27✔
640

641
    /**
642
     * Wait for all queued operations to complete
643
     */
644
    async waitForCompletion(): Promise<void> {
11✔
645
        // First, ensure any pending batch is queued
646
        if (this.currentBatch.size > 0) {
348✔
647
            this.executeBatch();
32✔
648
        }
32✔
649

650
        // Then wait for queue to be idle
651
        await this.queue.onIdle();
348✔
652
    }
348✔
653

654
    /**
655
     * Get queue statistics
656
     */
657
    getStats(): {
11✔
658
        pending: number;
659
        size: number;
660
        isPaused: boolean;
661
    } {
4✔
662
        return {
4✔
663
            pending: this.queue.pending,
4✔
664
            size: this.queue.size,
4✔
665
            isPaused: this.queue.isPaused,
4✔
666
        };
4✔
667
    }
4✔
668

669
    /**
670
     * Pause/resume queue execution
671
     */
672
    pause(): void {
11✔
673
        this.queue.pause();
2✔
674
    }
2✔
675

676
    resume(): void {
11✔
677
        this.queue.start();
2✔
678
    }
2✔
679

680
    /**
681
     * Clear all pending operations
682
     */
683
    clear(): void {
11✔
684
        // Cancel all active operations
685
        this.activeControllers.forEach((controller, id) => {
6✔
686
            if (!controller.signal.aborted) {
11✔
687
                controller.abort();
7✔
688
                this.eventManager.emitGraphEvent("operation-cancelled", {
7✔
689
                    id,
7✔
690
                    reason: "Queue cleared",
7✔
691
                });
7✔
692
            }
7✔
693
        });
6✔
694

695
        this.queue.clear();
6✔
696
        this.pendingOperations.clear();
6✔
697
        this.currentBatch.clear();
6✔
698
        this.runningOperations.clear();
6✔
699
        this.queuedOperations.clear();
6✔
700
    }
6✔
701

702
    /**
703
     * Disable batching (execute operations immediately)
704
     */
705
    disableBatching(): void {
11✔
706
        this.batchingEnabled = false;
×
707
    }
×
708

709
    enableBatching(): void {
11✔
710
        this.batchingEnabled = true;
×
711
        // TODO: Operations queued while batching was disabled will be executed
712
        // when waitForCompletion() is called
713
    }
×
714

715
    /**
716
     * Enter batch mode - operations will be queued but not executed
717
     */
718
    enterBatchMode(): void {
11✔
719
        this.batchMode = true;
36✔
720
        this.batchOperations.clear();
36✔
721
    }
36✔
722

723
    /**
724
     * Exit batch mode - execute all batched operations in dependency order
725
     */
726
    async exitBatchMode(): Promise<void> {
11✔
727
        if (!this.batchMode) {
36✔
728
            return;
1✔
729
        }
1✔
730

731
        this.batchMode = false;
35✔
732

733
        // Move all batched operations to currentBatch
734
        this.batchOperations.forEach((id) => {
35✔
735
            this.currentBatch.add(id);
177✔
736
        });
35✔
737

738
        // Collect all batch promises
739
        const promises = Array.from(this.batchPromises.values());
35✔
740

741
        // Clear batch tracking
742
        this.batchOperations.clear();
35✔
743
        this.batchPromises.clear();
35✔
744

745
        // Execute the batch
746
        this.executeBatch();
35✔
747

748
        // Wait for all operations to complete
749
        // Use allSettled to handle both resolved and rejected promises
750
        // Individual operation errors are already handled via handleOperationError
751
        await Promise.allSettled(promises);
35✔
752
    }
36✔
753

754
    /**
755
     * Check if currently in batch mode
756
     */
757
    isInBatchMode(): boolean {
11✔
758
        return this.batchMode;
10✔
759
    }
10✔
760

761
    /**
762
     * Queue an operation and get a promise for its completion
763
     * Used for batch mode operations
764
     */
765
    queueOperationAsync(
11✔
766
        category: OperationCategory,
41,919✔
767
        execute: (context: OperationContext) => Promise<void> | void,
41,919✔
768
        options?: Partial<OperationMetadata>,
41,919✔
769
    ): Promise<void> {
41,919✔
770
        const id = this.queueOperation(category, execute, options);
41,919✔
771

772
        // Create promise that resolves when this specific operation completes
773
        const promise = new Promise<void>((resolve, reject) => {
41,919✔
774
            // Store resolvers with the operation
775
            const operation = this.pendingOperations.get(id);
41,919✔
776
            if (operation) {
41,919✔
777
                operation.resolve = resolve;
41,919✔
778
                operation.reject = reject;
41,919✔
779
            }
41,919✔
780
        });
41,919✔
781

782
        if (this.batchMode) {
41,919✔
783
            // In batch mode, track the promise for later
784
            this.batchPromises.set(id, promise);
177✔
785
            // Return immediately resolved promise to avoid deadlock
786
            // The actual operation will execute when exitBatchMode is called
787
            return Promise.resolve();
177✔
788
        }
177✔
789

790
        // Not in batch mode, execute normally
791
        this.executeBatch();
41,742✔
792
        return promise;
41,742✔
793
    }
41,919✔
794

795
    /**
796
     * Wait for a specific operation to complete
797
     */
798
    private async waitForOperation(id: string): Promise<void> {
11✔
799
        while (
×
800
            this.pendingOperations.has(id) ||
×
801
            this.queuedOperations.has(id) ||
×
802
            this.runningOperations.has(id)
×
803
        ) {
×
804
            await new Promise((resolve) => setTimeout(resolve, OPERATION_POLL_INTERVAL_MS));
×
805
        }
×
806
    }
×
807

808
    /**
809
     * Get the AbortController for a specific operation
810
     */
811
    getOperationController(operationId: string): AbortController | undefined {
11✔
812
        return this.activeControllers.get(operationId);
×
813
    }
×
814

815
    /**
816
     * Cancel a specific operation
817
     */
818
    cancelOperation(operationId: string): boolean {
11✔
819
        const controller = this.activeControllers.get(operationId);
×
820
        if (controller && !controller.signal.aborted) {
×
821
            controller.abort();
×
822

823
            // Emit cancellation event
824
            this.eventManager.emitGraphEvent("operation-cancelled", {
×
825
                id: operationId,
×
826
                reason: "Manual cancellation",
×
827
            });
×
828

829
            return true;
×
830
        }
×
831

832
        return false;
×
833
    }
×
834

835
    /**
836
     * Mark a category as completed (for satisfying cross-batch dependencies)
837
     * This is useful when a category's requirements are met through other means
838
     * (e.g., style-init is satisfied by constructor initialization)
839
     */
840
    markCategoryCompleted(category: OperationCategory): void {
11✔
841
        this.completedCategories.add(category);
727✔
842
    }
727✔
843

844
    /**
845
     * Clear completed status for a category
846
     * This is useful when a category needs to be re-executed
847
     * (e.g., setStyleTemplate is called explicitly, overriding initial styles)
848
     */
849
    clearCategoryCompleted(category: OperationCategory): void {
11✔
850
        this.completedCategories.delete(category);
520✔
851
    }
520✔
852

853
    /**
854
     * Cancel all operations of a specific category
855
     */
856
    cancelByCategory(category: OperationCategory): number {
11✔
857
        let cancelledCount = 0;
×
858

859
        // Cancel pending operations
860
        this.pendingOperations.forEach((operation) => {
×
861
            if (operation.category === category) {
×
862
                if (this.cancelOperation(operation.id)) {
×
863
                    cancelledCount++;
×
864
                }
×
865
            }
×
866
        });
×
867

868
        return cancelledCount;
×
869
    }
×
870

871
    /**
872
     * Get current progress for an operation
873
     */
874
    getOperationProgress(operationId: string): OperationProgress | undefined {
11✔
875
        return this.operationProgress.get(operationId);
×
876
    }
×
877

878
    /**
879
     * Get all active operation IDs
880
     */
881
    getActiveOperations(): string[] {
11✔
882
        return Array.from(this.activeControllers.keys());
×
883
    }
×
884

885
    /**
886
     * Register a custom trigger for a specific operation category
887
     */
888
    registerTrigger(
11✔
889
        category: OperationCategory,
804✔
890
        trigger: (metadata?: OperationMetadata) => {
804✔
891
            category: OperationCategory;
892
            execute: (context: OperationContext) => Promise<void> | void;
893
            description?: string;
894
        } | null,
895
    ): void {
804✔
896
        if (!this.triggers.has(category)) {
804✔
897
            this.triggers.set(category, []);
803✔
898
        }
803✔
899

900
        const triggerArray = this.triggers.get(category);
804✔
901
        if (triggerArray) {
804✔
902
            triggerArray.push(trigger);
804✔
903
        }
804✔
904
    }
804✔
905

906
    /**
907
     * Trigger post-execution operations based on the completed operation
908
     */
909
    private triggerPostExecutionOperations(operation: Operation): void {
11✔
910
        // Check for default triggers
911
        const defaultTriggers = OperationQueueManager.POST_EXECUTION_TRIGGERS[operation.category];
14,585✔
912

913
        // Check for custom triggers
914
        const customTriggers = this.triggers.get(operation.category) ?? [];
14,585✔
915

916
        // Process default triggers
917
        if (defaultTriggers) {
14,585✔
918
            for (const triggerCategory of defaultTriggers) {
12,663✔
919
                // Check prerequisites
920
                if (triggerCategory === "layout-update" && this.hasLayoutEngine && !this.hasLayoutEngine()) {
12,663✔
921
                    continue; // Skip if no layout engine
2✔
922
                }
2✔
923

924
                // Queue the triggered operation
925
                void this.queueTriggeredOperation(triggerCategory, operation.metadata);
12,661✔
926
            }
12,661✔
927
        }
12,663✔
928

929
        // Process custom triggers
930
        for (const trigger of customTriggers) {
14,585✔
931
            const result = trigger(operation.metadata);
12,523✔
932
            if (result) {
12,523✔
933
                // Queue the custom triggered operation
934
                void this.queueTriggeredOperation(
12,521✔
935
                    result.category,
12,521✔
936
                    operation.metadata,
12,521✔
937
                    result.execute,
12,521✔
938
                    result.description,
12,521✔
939
                );
12,521✔
940
            }
12,521✔
941
        }
12,523✔
942
    }
14,585✔
943

944
    /**
945
     * Queue a triggered operation
946
     */
947
    private async queueTriggeredOperation(
11✔
948
        category: OperationCategory,
25,182✔
949
        sourceMetadata?: OperationMetadata,
25,182✔
950
        execute?: (context: OperationContext) => Promise<void> | void,
25,182✔
951
        description?: string,
25,182✔
952
    ): Promise<void> {
25,182✔
953
        // Notify test callback if set
954
        if (this.onOperationQueued) {
25,182✔
955
            this.onOperationQueued(category, description);
4✔
956
        }
4✔
957

958
        // Default execute function for layout-update
959
        if (!execute && category === "layout-update") {
25,182✔
960
            execute = (context: OperationContext) => {
12,593✔
961
                // This will be implemented by the Graph/LayoutManager
962
                context.progress.setMessage("Updating layout positions");
48✔
963
            };
48✔
964
        }
12,593✔
965

966
        if (!execute) {
25,182✔
967
            return; // No execute function provided
68✔
968
        }
68✔
969

970
        // Queue the operation
971
        await this.queueOperationAsync(
25,114✔
972
            category,
25,114✔
973
            execute,
25,114✔
974
            {
25,114✔
975
                description: description ?? `Triggered ${category}`,
25,178✔
976
                source: "trigger",
25,182✔
977
                skipTriggers: true, // Prevent trigger loops
25,182✔
978
            },
25,182✔
979
        );
25,182✔
980
    }
25,182✔
981
}
11✔
982

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc