ui-dev-content/dist/core/OperationQueue.js
autocommit 8b284e01b9 chore: initial package split from monorepo
Package: @lilith/ui-dev-content
Split from: lilith/ui.git or lilith/build.git
Publish workflow: calls lilith/workflows/.forgejo/workflows/publish-npm.yml@main
2026-04-20 01:11:45 -07:00

281 lines
12 KiB
JavaScript

import { jsx as _jsx } from "react/jsx-runtime";
/**
* OperationQueue - Manages concurrent transformer operations with availability-based concurrency
*
* Key features:
* - Availability-based concurrency: No fixed limit, starts operations if service is available
* - Health check integration: Checks transformer.checkHealth() before starting
* - Auto-retry: Retries queued operations when services become available
* - Toast integration: Each operation gets a persistent progress toast
* - Progress tracking: Supports progress updates from 0-100%
*/
import { createContext, useContext, useState, useCallback, useRef, useEffect } from 'react';
import { useToast } from '@lilith/ui-feedback';
const OperationQueueContext = createContext(undefined);
export function OperationQueueProvider({ children, healthCheckInterval = 10000, }) {
const [operations, setOperations] = useState([]);
const { showToast, updateToast, removeToast } = useToast();
const healthCheckTimerRef = useRef(null);
const pendingToastActionsRef = useRef([]);
// Store toast functions in a ref to avoid them being effect dependencies
// This prevents the effect from re-running when ToastProvider re-renders
const toastFunctionsRef = useRef({ updateToast, removeToast });
toastFunctionsRef.current = { updateToast, removeToast };
// Process pending toast actions after render to avoid setState-in-render
useEffect(() => {
const actions = pendingToastActionsRef.current;
if (actions.length === 0)
return;
pendingToastActionsRef.current = [];
const { updateToast, removeToast } = toastFunctionsRef.current;
for (const action of actions) {
if (action.type === 'update') {
updateToast(action.toastId, action.updates);
}
else {
removeToast(action.toastId);
}
}
}, [operations]); // Only depend on operations - toast functions accessed via ref
// ============================================================================
// Operation Management
// ============================================================================
const getOperation = useCallback((id) => {
return operations.find((op) => op.id === id);
}, [operations]);
const getRunningOperations = useCallback(() => {
return operations.filter((op) => op.status === 'running');
}, [operations]);
const getQueuedOperations = useCallback(() => {
return operations.filter((op) => op.status === 'queued');
}, [operations]);
const updateOperation = useCallback((id, updates) => {
setOperations((prev) => prev.map((op) => {
if (op.id === id) {
const updated = { ...op, ...updates };
// Queue toast update for processing after render
pendingToastActionsRef.current.push({
type: 'update',
toastId: op.toastId,
updates: {
message: getOperationMessage(updated),
progress: updated.progress,
type: getToastType(updated.status),
},
});
return updated;
}
return op;
}));
}, []);
const completeOperation = useCallback((id, result) => {
setOperations((prev) => prev.map((op) => {
if (op.id === id) {
const updated = {
...op,
status: 'completed',
result,
progress: 100,
completedAt: new Date(),
};
// Queue toast update for processing after render
const changeCount = result.changes?.length || 0;
const message = result.success && changeCount > 0
? `${op.transformer.name} completed - Click to review ${changeCount} ${changeCount === 1 ? 'change' : 'changes'}`
: result.success
? `${op.transformer.name} completed - No changes needed`
: `${op.transformer.name} failed`;
pendingToastActionsRef.current.push({
type: 'update',
toastId: op.toastId,
updates: {
message,
type: result.success ? 'success' : 'error',
progress: 100,
persistent: true,
dismissible: true,
},
});
return updated;
}
return op;
}));
// Trigger retry of queued operations (services might be available now)
setTimeout(() => retryQueued(), 100);
}, []);
const failOperation = useCallback((id, error) => {
setOperations((prev) => prev.map((op) => {
if (op.id === id) {
const updated = {
...op,
status: 'failed',
error,
progress: 0,
completedAt: new Date(),
};
// Queue toast update for processing after render
pendingToastActionsRef.current.push({
type: 'update',
toastId: op.toastId,
updates: {
message: `${op.transformer.name} failed: ${error}`,
type: 'error',
persistent: true,
dismissible: true,
},
});
return updated;
}
return op;
}));
// Trigger retry of queued operations
setTimeout(() => retryQueued(), 100);
}, []);
const clearCompleted = useCallback(() => {
setOperations((prev) => {
const toRemove = prev.filter((op) => op.status === 'completed' || op.status === 'failed');
// Queue toast removals for processing after render
toRemove.forEach((op) => {
pendingToastActionsRef.current.push({
type: 'remove',
toastId: op.toastId,
});
});
return prev.filter((op) => op.status === 'queued' || op.status === 'running');
});
}, []);
// ============================================================================
// Queue Operation (with availability check)
// ============================================================================
const queueOperation = useCallback(async (handle, transformer) => {
const id = `op-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
// Check service availability
let serviceAvailable = true;
try {
const health = await transformer.checkHealth();
serviceAvailable = health.available && !health.degraded;
}
catch (error) {
console.warn(`Health check failed for ${transformer.id}:`, error);
serviceAvailable = false;
}
// Determine initial status
const status = serviceAvailable ? 'running' : 'queued';
// Create toast notification
const toastId = showToast(serviceAvailable
? `${transformer.name} starting...`
: `${transformer.name} queued (service unavailable)`, serviceAvailable ? 'loading' : 'info', {
persistent: true,
dismissible: false,
progress: 0,
});
const operation = {
id,
handle,
transformer,
status,
progress: 0,
toastId,
serviceAvailable,
startedAt: serviceAvailable ? new Date() : undefined,
};
setOperations((prev) => [...prev, operation]);
return id;
}, [showToast]);
// ============================================================================
// Retry Queued Operations (availability-based)
// ============================================================================
const retryQueued = useCallback(async () => {
const queued = operations.filter((op) => op.status === 'queued');
for (const op of queued) {
try {
const health = await op.transformer.checkHealth();
const serviceAvailable = health.available && !health.degraded;
if (serviceAvailable) {
// Service is now available, start the operation
updateOperation(op.id, {
status: 'running',
serviceAvailable: true,
startedAt: new Date(),
progress: 5,
});
}
}
catch (error) {
console.warn(`Health check failed for ${op.transformer.id}:`, error);
}
}
}, [operations, updateOperation]);
// ============================================================================
// Periodic Health Check (auto-retry queued operations)
// ============================================================================
useEffect(() => {
healthCheckTimerRef.current = setInterval(() => {
const queuedCount = operations.filter((op) => op.status === 'queued').length;
if (queuedCount > 0) {
retryQueued();
}
}, healthCheckInterval);
return () => {
if (healthCheckTimerRef.current) {
clearInterval(healthCheckTimerRef.current);
}
};
}, [healthCheckInterval, operations, retryQueued]);
// ============================================================================
// Helpers
// ============================================================================
const getOperationMessage = (op) => {
switch (op.status) {
case 'queued':
return `${op.transformer.name} queued (service unavailable)`;
case 'running':
return `${op.transformer.name} - ${op.progress}%`;
case 'completed':
return `${op.transformer.name} completed`;
case 'failed':
return `${op.transformer.name} failed`;
default:
return op.transformer.name;
}
};
const getToastType = (status) => {
switch (status) {
case 'queued':
return 'info';
case 'running':
return 'loading';
case 'completed':
return 'success';
case 'failed':
return 'error';
default:
return 'info';
}
};
// ============================================================================
// Render
// ============================================================================
return (_jsx(OperationQueueContext.Provider, { value: {
operations,
queueOperation,
updateOperation,
completeOperation,
failOperation,
getOperation,
getRunningOperations,
getQueuedOperations,
clearCompleted,
retryQueued,
}, children: children }));
}
// ============================================================================
// Hook
// ============================================================================
export function useOperationQueue() {
const context = useContext(OperationQueueContext);
if (!context) {
throw new Error('useOperationQueue must be used within an OperationQueueProvider');
}
return context;
}