diff --git a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java index eee0ed3ddf..00bb9e4d2e 100644 --- a/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java +++ b/app/core/src/main/java/stirling/software/SPDF/controller/api/pipeline/PipelineDirectoryProcessor.java @@ -48,7 +48,7 @@ import tools.jackson.databind.ObjectMapper; public class PipelineDirectoryProcessor { private static final int MAX_DIRECTORY_DEPTH = 50; // Prevent excessive recursion - private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("\\\\?watchedFolders"); + private static final Pattern WATCHED_FOLDERS_PATTERN = Pattern.compile("[/\\\\]watchedFolders"); private final ObjectMapper objectMapper; private final ApiDocService apiDocService; @@ -105,7 +105,7 @@ public class PipelineDirectoryProcessor { processedDirsInScan.get().clear(); try { handleDirectory(dir.toAbsolutePath().normalize()); - } catch (IOException e) { + } catch (Exception e) { log.error("Error processing directory: {}", dir, e); } finally { processedDirsInScan.remove(); @@ -153,7 +153,8 @@ public class PipelineDirectoryProcessor { && !"processing".equals(dirName) && !"processed".equals(dirName) && !"error".equals(dirName)) { - // Skip server-managed folders — they are processed on-demand via + // Skip server-managed folders — they are processed on-demand + // via // the trigger endpoint; session.json marks them as managed. if (Files.exists(dir.resolve("session.json"))) { return FileVisitResult.SKIP_SUBTREE; @@ -241,7 +242,7 @@ public class PipelineDirectoryProcessor { validateOperation(operation); File[] files = collectFilesForProcessing(dir, jsonFile, operation); if (files.length == 0) { - log.debug("No files detected for {} ", dir); + log.info("No files ready for processing in {}", dir); return; } @@ -274,6 +275,13 @@ public class PipelineDirectoryProcessor { operation.getOperation(), inputExtensions); + if (inputExtensions == null) { + log.warn( + "No input extension info found for operation {} — skipping directory {}", + operation.getOperation(), + dir); + return new File[0]; + } boolean allowAllFiles = inputExtensions.contains("ALL"); // Server-managed folders (session.json present) only process files that have a // corresponding .ready marker, preventing partial-upload races. @@ -313,7 +321,8 @@ public class PipelineDirectoryProcessor { // Check against allowed extensions String extension = fname.contains(".") - ? fname.substring(fname.lastIndexOf('.') + 1) + ? fname.substring( + fname.lastIndexOf('.') + 1) .toLowerCase(Locale.ROOT) : ""; boolean isAllowed = @@ -331,6 +340,9 @@ public class PipelineDirectoryProcessor { .map(Path::toAbsolutePath) .filter( path -> { + // Server-managed folders use the .ready marker to + // guarantee upload completion — skip the timestamp delay. + if (isServerManaged) return true; boolean isReady = fileMonitor.isFileReadyForProcessing(path); if (!isReady) { @@ -382,9 +394,10 @@ public class PipelineDirectoryProcessor { if (moved) { filesToProcess.add(targetPath.toFile()); // Remove the .ready marker now that the file is safely in processingDir - String stem = file.getName().contains(".") - ? file.getName().substring(0, file.getName().lastIndexOf('.')) - : file.getName(); + String stem = + file.getName().contains(".") + ? file.getName().substring(0, file.getName().lastIndexOf('.')) + : file.getName(); try { Files.deleteIfExists(file.toPath().getParent().resolve(stem + ".ready")); } catch (IOException ignore) { @@ -504,13 +517,17 @@ public class PipelineDirectoryProcessor { } private Path determineOutputPath(PipelineConfig config, Path dir) { + String rawOutputDir = + config.getOutputDir() + .replace("{outputFolder}", finishedFoldersDir) + .replace("{folderName}", dir.toString()); + // Only strip the watchedFolders segment for relative (legacy) output paths. + // Server-managed folders set an absolute output path — leave it untouched so + // output lands in the correct {folderId}/processed directory. String outputDir = - WATCHED_FOLDERS_PATTERN - .matcher( - config.getOutputDir() - .replace("{outputFolder}", finishedFoldersDir) - .replace("{folderName}", dir.toString())) - .replaceAll(""); + Paths.get(rawOutputDir).isAbsolute() + ? rawOutputDir + : WATCHED_FOLDERS_PATTERN.matcher(rawOutputDir).replaceAll(""); return Paths.get(outputDir).isAbsolute() ? Paths.get(outputDir) : Paths.get(".", outputDir); } diff --git a/app/core/src/main/java/stirling/software/SPDF/model/PipelineEvent.java b/app/core/src/main/java/stirling/software/SPDF/model/PipelineEvent.java index 70884e0cfd..c4be6e584d 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/PipelineEvent.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/PipelineEvent.java @@ -14,17 +14,17 @@ public sealed interface PipelineEvent { record JobFailed(String sessionId, String jobId, String error) implements PipelineEvent {} /** - * Fired by {@link - * stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor} when a server - * watch folder batch succeeds. {@code outputFiles} are filenames of the form {@code - * {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId. + * Fired by {@link stirling.software.SPDF.controller.api.pipeline.PipelineDirectoryProcessor} + * when a server watch folder batch succeeds. {@code outputFiles} are filenames of the form + * {@code {fileId}.{ext}} — the frontend strips the extension to recover the IDB fileId. */ record FolderCompleted(String sessionId, String folderId, List outputFiles) implements PipelineEvent {} /** - * Fired when a server watch folder batch fails (pipeline reported errors). {@code failedFileIds} - * are the IDB fileIds extracted from the input filenames ({@code {fileId}.{ext}}). + * Fired when a server watch folder batch fails (pipeline reported errors). {@code + * failedFileIds} are the IDB fileIds extracted from the input filenames ({@code + * {fileId}.{ext}}). */ record FolderError(String sessionId, String folderId, List failedFileIds) implements PipelineEvent {} diff --git a/app/core/src/main/java/stirling/software/SPDF/model/PipelineJob.java b/app/core/src/main/java/stirling/software/SPDF/model/PipelineJob.java index 789411ed60..f761ec525a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/PipelineJob.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/PipelineJob.java @@ -14,8 +14,8 @@ import lombok.extern.slf4j.Slf4j; * is set to {@code COMPLETED} or {@code FAILED}, so any thread that observes the terminal status is * guaranteed (by the Java Memory Model volatile write/read ordering) to see the result fields. * - *

Results are stored as a temp file on disk rather than in the JVM heap, preventing large - * PDFs from causing memory pressure when many jobs are in flight simultaneously. + *

Results are stored as a temp file on disk rather than in the JVM heap, preventing large PDFs + * from causing memory pressure when many jobs are in flight simultaneously. */ @Getter @Slf4j @@ -38,6 +38,7 @@ public class PipelineJob { private volatile Status status = Status.PENDING; private String resultFilename; + /** Temp file holding the result bytes; deleted when the job is cleaned up. */ private Path resultPath; diff --git a/app/core/src/main/java/stirling/software/SPDF/model/SessionConfig.java b/app/core/src/main/java/stirling/software/SPDF/model/SessionConfig.java index 16bb103427..a52f1c1a0a 100644 --- a/app/core/src/main/java/stirling/software/SPDF/model/SessionConfig.java +++ b/app/core/src/main/java/stirling/software/SPDF/model/SessionConfig.java @@ -1,24 +1,37 @@ package stirling.software.SPDF.model; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + /** * Persisted to session.json inside each server watch folder. * *

*/ public record SessionConfig( - String sessionId, - String folderId, - Integer outputTtlHours, - Boolean deleteOutputOnDownload) { + @JsonProperty("sessionId") String sessionId, + @JsonProperty("folderId") String folderId, + @JsonProperty("outputTtlHours") Integer outputTtlHours, + @JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) { - /** Compact constructor — treat null booleans as false. */ - public SessionConfig { - if (deleteOutputOnDownload == null) deleteOutputOnDownload = false; + @JsonCreator + public SessionConfig( + @JsonProperty("sessionId") String sessionId, + @JsonProperty("folderId") String folderId, + @JsonProperty("outputTtlHours") Integer outputTtlHours, + @JsonProperty("deleteOutputOnDownload") Boolean deleteOutputOnDownload) { + this.sessionId = sessionId; + this.folderId = folderId; + this.outputTtlHours = outputTtlHours; + this.deleteOutputOnDownload = + deleteOutputOnDownload == null ? false : deleteOutputOnDownload; } /** Convenience accessor with a sensible default. */ diff --git a/frontend/src/core/components/smartFolders/FilePreviewModal.tsx b/frontend/src/core/components/smartFolders/FilePreviewModal.tsx index 80ff9677fb..ebb767597b 100644 --- a/frontend/src/core/components/smartFolders/FilePreviewModal.tsx +++ b/frontend/src/core/components/smartFolders/FilePreviewModal.tsx @@ -7,17 +7,20 @@ import { PdfViewerToolbar } from '@app/components/viewer/PdfViewerToolbar'; import { ViewerProvider } from '@app/contexts/ViewerContext'; interface FilePreviewModalProps { - fileId: FileId | null; + fileId?: FileId | null; + /** Pass a File directly (e.g. server-folder outputs not stored in IDB). */ + file?: File | null; fileName: string; onClose: () => void; } -export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModalProps) { +export function FilePreviewModal({ fileId, file: fileProp, fileName, onClose }: FilePreviewModalProps) { const [file, setFile] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(false); useEffect(() => { + if (fileProp) { setFile(fileProp); setError(false); setLoading(false); return; } if (!fileId) { setFile(null); setError(false); setLoading(false); return; } setError(false); setLoading(true); @@ -28,11 +31,13 @@ export function FilePreviewModal({ fileId, fileName, onClose }: FilePreviewModal }) .catch(() => setError(true)) .finally(() => setLoading(false)); - }, [fileId]); + }, [fileId, fileProp]); + + const opened = !!(fileId || fileProp); return ( (editFolder?.maxRetries ?? 3); @@ -114,12 +114,12 @@ export function SmartFolderManagementModal({ const [nameError, setNameError] = useState(''); const [automationError, setAutomationError] = useState(''); const [saveError, setSaveError] = useState(null); + const [showAdvanced, setShowAdvanced] = useState(isEditMode); const automationSaveTrigger = useRef<(() => void) | null>(null); const resetState = useCallback(() => { setName(editFolder?.name ?? ''); - setDescription(editFolder?.description ?? ''); setIcon(editFolder?.icon ?? 'FolderIcon'); setAccentColor(editFolder?.accentColor ?? '#3b82f6'); setMaxRetries(editFolder?.maxRetries ?? 3); @@ -131,6 +131,7 @@ export function SmartFolderManagementModal({ setOutputTtlHours(editFolder?.outputTtlHours != null ? String(editFolder.outputTtlHours) : 'forever'); setDeleteOutputOnDownload(editFolder?.deleteOutputOnDownload ?? false); outputNameDirty.current = !!editFolder?.outputName; + setShowAdvanced(!!editFolder); setNameError(''); setAutomationError(''); setSaveError(null); @@ -155,7 +156,6 @@ export function SmartFolderManagementModal({ const trimmedName = name.trim(); const isServerFolder = inputSource === 'server-folder'; - // Validate server-folder compatibility before touching IDB let configJson: string | null = null; if (isServerFolder) { configJson = buildPipelineJson(automation, toolRegistry); @@ -172,7 +172,7 @@ export function SmartFolderManagementModal({ const ttlHoursNum = isServerFolder && outputTtlHours !== 'forever' ? Number(outputTtlHours) : null; const folderData = { name: trimmedName, - description: description.trim(), + description: '', icon, accentColor, automationId: automation.id, @@ -195,7 +195,6 @@ export function SmartFolderManagementModal({ } else if (!hasOutputDirectory) { await folderDirectoryHandleStorage.remove(editFolder.id); } - // Sync server watch folder if (isServerFolder && configJson) { if (wasServerFolder) { await updateServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload); @@ -203,7 +202,7 @@ export function SmartFolderManagementModal({ await createServerFolder(editFolder.id, trimmedName, configJson, ttlHoursNum, deleteOutputOnDownload); } } else if (wasServerFolder && !isServerFolder) { - await deleteServerFolder(editFolder.id).catch(() => {}); // best-effort + await deleteServerFolder(editFolder.id).catch(() => {}); } } else { const newFolder = await smartFolderStorage.createFolder(folderData); @@ -223,7 +222,7 @@ export function SmartFolderManagementModal({ } finally { setSaving(false); } - }, [name, description, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]); + }, [name, icon, accentColor, outputMode, outputName, outputNamePosition, outputDirName, maxRetries, retryDelayMinutes, inputSource, outputTtlHours, deleteOutputOnDownload, isEditMode, editFolder, toolRegistry, resetState, onSaved, onClose, t]); const handleSave = () => { const trimmedName = name.trim(); @@ -285,19 +284,11 @@ export function SmartFolderManagementModal({ )} + + - {!outputDirName && ( - setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')} - size="sm" - /> - )} + {/* ── Advanced (collapsible) ── */} +
+ - - - {outputNamePosition === 'auto-number' ? ( - - Auto-number - e.g. document.pdf → document (1).pdf - - ) : ( - { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }} - maxLength={100} - size="sm" - style={{ flex: 1 }} + + + + {/* Replace original — only meaningful for browser mode */} + {inputSource !== 'server-folder' && ( + setOutputMode(e.currentTarget.checked ? 'new_version' : 'new_file')} + size="sm" + /> + )} + + {/* Filename prefix / suffix */} + + + {outputNamePosition === 'auto-number' ? ( + + Auto-number + e.g. document.pdf → document (1).pdf + + ) : ( + { outputNameDirty.current = true; setOutputName(e.currentTarget.value); }} + maxLength={100} + size="sm" + style={{ flex: 1 }} + /> + )} + v && setOutputNamePosition(v as 'prefix' | 'suffix' | 'auto-number')} - data={[ - { value: 'prefix', label: 'Prefix' }, - { value: 'suffix', label: 'Suffix' }, - { value: 'auto-number', label: 'Auto-number' }, - ]} - style={{ width: '8rem', flexShrink: 0 }} - mb={4} - comboboxProps={{ withinPortal: true, zIndex: 400 }} + + + + {/* Retry settings */} + + setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)} + min={0} + max={10} + size="sm" + /> + setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)} + min={1} + max={60} + size="sm" + disabled={maxRetries === 0} /> - - -
- {/* Auto-retry */} -
- Auto-retry - - setMaxRetries(typeof v === 'number' ? Math.max(0, Math.min(10, v)) : 0)} - min={0} - max={10} - size="sm" - /> - setRetryDelayMinutes(typeof v === 'number' ? Math.max(1, Math.min(60, v)) : 5)} - min={1} - max={60} - size="sm" - disabled={maxRetries === 0} - /> - -
- - {/* Data Flow */} -
- Data Flow - - v && setOutputTtlHours(v)} - data={[ - { value: '1', label: '1 hour' }, - { value: '6', label: '6 hours' }, - { value: '24', label: '24 hours' }, - { value: '168', label: '7 days' }, - { value: '720', label: '30 days' }, - { value: 'forever', label: 'Forever' }, - ]} - size="sm" - comboboxProps={{ withinPortal: true, zIndex: 400 }} - /> - setDeleteOutputOnDownload(e.currentTarget.checked)} - size="sm" - disabled={outputDirName === null} - /> - - )} - + +
@@ -529,13 +561,15 @@ export function SmartFolderManagementModal({ {t('cancel', 'Cancel')} - {/* ── Right panel: automation / tool steps ── */} + {/* ── Right panel: automation steps ── */}
Steps @@ -547,7 +581,10 @@ export function SmartFolderManagementModal({ existingAutomation={existingAutomation ?? undefined} onBack={handleClose} onComplete={handleAutomationComplete} - onSaveFailed={() => { setSaving(false); setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.')); }} + onSaveFailed={() => { + setSaving(false); + setAutomationError(t('smartFolders.modal.automationRequired', 'Add at least one configured step before saving.')); + }} toolRegistry={toolRegistry} hideMetadata nameOverride={name.trim() || t('smartFolders.modal.automationNameFallback', 'Watch Folder Automation')} diff --git a/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx b/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx index c38f110344..ffbb262d3e 100644 --- a/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx +++ b/frontend/src/core/components/smartFolders/SmartFolderWorkbenchView.tsx @@ -164,6 +164,7 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps const [inputFiles, setInputFiles] = useState([]); const [previewFileId, setPreviewFileId] = useState(null); const [previewFileName, setPreviewFileName] = useState(''); + const [previewFile, setPreviewFile] = useState(null); // Filter / sort state const [activitySearch, setActivitySearch] = useState(''); @@ -318,16 +319,28 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps }, []); /** Download a server-folder output file on demand (it is not in IDB). */ - const handleServerOutputDownload = useCallback(async (filename: string) => { + const handleServerOutputDownload = useCallback(async (serverFilename: string, displayName?: string) => { if (!folderId) return; try { - const file = await downloadServerFolderOutput(folderId, filename); - await handleDownload(file, filename); + const file = await downloadServerFolderOutput(folderId, serverFilename); + await handleDownload(file, displayName ?? serverFilename); } catch { // Surface as a no-op — the file may have expired from the server } }, [folderId, handleDownload]); + /** Preview a server-folder output file by fetching it on demand. */ + const handleServerOutputPreview = useCallback(async (serverFilename: string, displayName?: string) => { + if (!folderId) return; + try { + const file = await downloadServerFolderOutput(folderId, serverFilename); + setPreviewFileName(displayName ?? serverFilename); + setPreviewFile(file); + } catch { + // no-op + } + }, [folderId]); + const goHome = useCallback(() => { setCustomWorkbenchViewData(SMART_FOLDER_VIEW_ID, { folderId: null }); actions.setWorkbench(SMART_FOLDER_WORKBENCH_ID); @@ -1032,7 +1045,10 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps )} {!isExpanded && isServerFolder && hasPrimaryServerOutput && ( - + + )} + {!isExpanded && isServerFolder && hasPrimaryServerOutput && ( + )} @@ -1067,7 +1083,8 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps out {fname} on server - + + ))} {/* Error detail + retry */} @@ -1284,8 +1301,9 @@ export function SmartFolderWorkbenchView({ data }: SmartFolderWorkbenchViewProps setPreviewFileId(null)} + onClose={() => { setPreviewFileId(null); setPreviewFile(null); }} /> {/* Delete confirmation */} diff --git a/frontend/src/core/hooks/useFolderAutomation.ts b/frontend/src/core/hooks/useFolderAutomation.ts index 5fb4ed9011..0a34e710a5 100644 --- a/frontend/src/core/hooks/useFolderAutomation.ts +++ b/frontend/src/core/hooks/useFolderAutomation.ts @@ -25,10 +25,12 @@ import { submitBackendJob, getBackendJobStatus, getBackendJobResult, + buildPipelineJson, } from '@app/utils/automationExecutor'; import { uploadFileToServerFolder, updateServerFolderSession, + createServerFolder, listServerFolderOutput, downloadServerFolderOutput, deleteServerFolderOutput, @@ -349,7 +351,11 @@ export function useFolderAutomation(toolRegistry: Partial) { if (dirHandle) { const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle); if (hasPermission) { - await folderDirectoryHandleStorage.writeFile(dirHandle, outputFilename, resultFile); + const origName = meta?.name ?? outputFilename; + const outExt = outputFilename.includes('.') ? outputFilename.substring(outputFilename.lastIndexOf('.')) : ''; + const origBase = origName.includes('.') ? origName.substring(0, origName.lastIndexOf('.')) : origName; + const displayName = origBase + outExt; + await folderDirectoryHandleStorage.writeFile(dirHandle, displayName, resultFile); } } } catch { @@ -515,7 +521,9 @@ export function useFolderAutomation(toolRegistry: Partial) { if (dirHandle) { const hasPermission = await folderDirectoryHandleStorage.ensurePermission(dirHandle); if (hasPermission) { - await folderDirectoryHandleStorage.writeFile(dirHandle, outputFile.filename, resultFile); + const outExt = outputFile.filename.includes('.') ? outputFile.filename.substring(outputFile.filename.lastIndexOf('.')) : ''; + const origBase = inputFile.name.includes('.') ? inputFile.name.substring(0, inputFile.name.lastIndexOf('.')) : inputFile.name; + await folderDirectoryHandleStorage.writeFile(dirHandle, origBase + outExt, resultFile); } } } catch { /* best-effort */ } @@ -558,7 +566,11 @@ export function useFolderAutomation(toolRegistry: Partial) { // Server-folder input — upload to watch folder, trigger immediate processing via SSE if (isServerFolderInput(folder)) { - await uploadFileToServerFolder(folder.id, inputFileId, file); + // Load from IDB to guarantee we have the full file bytes. + // The `file` parameter may be a stale drag-event reference whose data + // is no longer readable after the async resolveInputFile call completed. + const uploadFile = await fileStorage.getStirlingFile(inputFileId as FileId) ?? file; + await uploadFileToServerFolder(folder.id, inputFileId, uploadFile); await folderStorage.updateFileMetadata(folder.id, inputFileId, { pendingOnServerFolder: true, }); @@ -638,11 +650,35 @@ export function useFolderAutomation(toolRegistry: Partial) { if (!isServerFolderInput(folder)) continue; try { await updateServerFolderSession(folder.id); - } catch { - // Best-effort — server folder may not exist yet or server may be down + } catch (err: any) { + // 404 means the server directory was never provisioned (e.g. backend wasn't running + // when the folder was created). Re-provision it now using the stored automation. + if (err?.response?.status === 404) { + try { + const automation = await automationStorage.getAutomation(folder.automationId); + if (!automation) { + console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation ${folder.automationId} not found in IDB`); + } else { + const configJson = buildPipelineJson(automation, toolRegistry); + if (!configJson) { + console.warn(`[watch-folders] Cannot re-provision ${folder.id}: automation has browser-only steps`); + } else { + await createServerFolder( + folder.id, folder.name, configJson, + folder.outputTtlHours ?? null, folder.deleteOutputOnDownload ?? false + ); + console.info(`[watch-folders] Re-provisioned server folder ${folder.id}`); + } + } + } catch (reprovisionErr) { + console.warn(`[watch-folders] Re-provision failed for ${folder.id}:`, reprovisionErr); + } + } else { + console.warn(`[watch-folders] updateSession failed for ${folder.id}:`, err?.response?.status, err?.message); + } } } - }, []); + }, [toolRegistry]); // ── Lifecycle effects ────────────────────────────────────────────────────── useEffect(() => {