diff --git a/ai-hub/app/api/routes/nodes.py b/ai-hub/app/api/routes/nodes.py index c65330a..6d7ba8c 100644 --- a/ai-hub/app/api/routes/nodes.py +++ b/ai-hub/app/api/routes/nodes.py @@ -665,7 +665,7 @@ # ================================================================== @router.get("/{node_id}/fs/ls", response_model=schemas.DirectoryListing, summary="List Directory Content") - def fs_ls(node_id: str, path: str = "."): + def fs_ls(node_id: str, path: str = ".", session_id: str = "__fs_explorer__"): """ Request a directory listing from a node. Returns a tree-structured list for the File Navigator. @@ -678,7 +678,7 @@ logger.error("[FS] Orchestrator service not found in ServiceContainer.") raise HTTPException(status_code=500, detail="Agent Orchestrator service is starting or unavailable.") - res = orchestrator.assistant.ls(node_id, path) + res = orchestrator.assistant.ls(node_id, path, session_id=session_id) if not res: logger.error(f"[FS] Received empty response from node {node_id} for path {path}") @@ -690,13 +690,13 @@ raise HTTPException(status_code=status_code, detail=res["error"]) files = res.get("files", []) - # M6: Check sync status by seeing if path exists in server ghost mirror - workspace_mirror = orchestrator.mirror.get_workspace_path("__fs_explorer__") - for f in files: - # We simply check if it exists in our local mirror stash - # To be perfect, we'd check hash, but ls shallow doesn't give hash. - mirror_item_path = os.path.join(workspace_mirror, f["path"]) - f["is_synced"] = os.path.exists(mirror_item_path) + + # M6: Check sync status ONLY for real user sessions, not for the node-wide navigator + if session_id != "__fs_explorer__": + workspace_mirror = orchestrator.mirror.get_workspace_path(session_id) + for f in files: + mirror_item_path = os.path.join(workspace_mirror, f["path"]) + f["is_synced"] = os.path.exists(mirror_item_path) return schemas.DirectoryListing(node_id=node_id, path=path, files=files) except HTTPException: @@ -706,13 +706,13 @@ raise HTTPException(status_code=500, detail=f"Internal Server Error: {str(e)}") @router.get("/{node_id}/fs/cat", summary="Read File Content") - def fs_cat(node_id: str, path: str): + def fs_cat(node_id: str, path: str, session_id: str = "__fs_explorer__"): """ Read the content of a file on a remote node. """ try: orchestrator = services.orchestrator - res = orchestrator.assistant.cat(node_id, path) + res = orchestrator.assistant.cat(node_id, path, session_id=session_id) if not res: raise HTTPException(status_code=500, detail="Node returned an empty response.") if isinstance(res, dict) and "error" in res: @@ -725,7 +725,7 @@ raise HTTPException(status_code=500, detail=str(e)) @router.post("/{node_id}/fs/touch", summary="Create File or Directory") - def fs_touch(node_id: str, req: schemas.FileWriteRequest): + def fs_touch(node_id: str, req: schemas.FileWriteRequest, session_id: str = "__fs_explorer__"): """ Create a new file or directory on the node. """ @@ -735,7 +735,8 @@ node_id, req.path, req.content.encode('utf-8'), - req.is_dir + req.is_dir, + session_id=session_id ) if not res: raise HTTPException(status_code=500, detail="Node returned an empty response.") @@ -749,13 +750,13 @@ raise HTTPException(status_code=500, detail=str(e)) @router.delete("/{node_id}/fs/rm", summary="Delete File/Directory") - def fs_rm(node_id: str, req: schemas.FileDeleteRequest): + def fs_rm(node_id: str, req: schemas.FileDeleteRequest, session_id: str = "__fs_explorer__"): """ Delete a file or directory from a remote node. """ try: orchestrator = services.orchestrator - res = orchestrator.assistant.rm(node_id, req.path) + res = orchestrator.assistant.rm(node_id, req.path, session_id=session_id) if not res: raise HTTPException(status_code=500, detail="Node returned an empty response.") if isinstance(res, dict) and "error" in res: diff --git a/ai-hub/app/core/grpc/services/assistant.py b/ai-hub/app/core/grpc/services/assistant.py index 409d88a..c9f6d01 100644 --- a/ai-hub/app/core/grpc/services/assistant.py +++ b/ai-hub/app/core/grpc/services/assistant.py @@ -154,7 +154,7 @@ # Modular FS Explorer / Mesh Navigation # ================================================================== - def ls(self, node_id: str, path: str = ".", timeout=10): + def ls(self, node_id: str, path: str = ".", timeout=10, session_id="__fs_explorer__"): """Requests a directory listing from a node (waits for response).""" node = self.registry.get_node(node_id) if not node: return {"error": "Offline"} @@ -164,7 +164,7 @@ node.queue.put(agent_pb2.ServerTaskMessage( file_sync=agent_pb2.FileSyncMessage( - session_id="__fs_explorer__", + session_id=session_id, task_id=tid, control=agent_pb2.SyncControl(action=agent_pb2.SyncControl.LIST, path=path) ) @@ -174,23 +174,24 @@ res = self.journal.get_result(tid) self.journal.pop(tid) - # Proactive Mirroring for Explorer: start fetching content so dots turn green - if res and "files" in res: - self._proactive_explorer_sync(node_id, res["files"]) + # Proactive Mirroring: start fetching content so dots turn green + # (Only for user sessions, not for node management explorer) + if res and "files" in res and session_id != "__fs_explorer__": + self._proactive_explorer_sync(node_id, res["files"], session_id) return res self.journal.pop(tid) return {"error": "Timeout"} - def _proactive_explorer_sync(self, node_id, files): + def _proactive_explorer_sync(self, node_id, files, session_id): """Starts background tasks to mirror files to Hub so dots turn green.""" import threading for f in files: if f.get("is_dir"): continue if not f.get("is_synced") and f.get("size", 0) < 1024 * 512: # Skip large files - threading.Thread(target=self.cat, args=(node_id, f["path"]), daemon=True).start() + threading.Thread(target=self.cat, args=(node_id, f["path"], 15, session_id), daemon=True).start() - def cat(self, node_id: str, path: str, timeout=15): + def cat(self, node_id: str, path: str, timeout=15, session_id="__fs_explorer__"): """Requests file content from a node (waits for result).""" node = self.registry.get_node(node_id) if not node: return {"error": "Offline"} @@ -202,7 +203,7 @@ node.queue.put(agent_pb2.ServerTaskMessage( file_sync=agent_pb2.FileSyncMessage( - session_id="__fs_explorer__", + session_id=session_id, task_id=tid, control=agent_pb2.SyncControl(action=agent_pb2.SyncControl.READ, path=path) ) @@ -216,7 +217,7 @@ self.journal.pop(tid) return {"error": "Timeout"} - def write(self, node_id: str, path: str, content: bytes = b"", is_dir: bool = False, timeout=10): + def write(self, node_id: str, path: str, content: bytes = b"", is_dir: bool = False, timeout=10, session_id="__fs_explorer__"): """Creates or updates a file/directory on a node (waits for status).""" node = self.registry.get_node(node_id) if not node: return {"error": "Offline"} @@ -226,7 +227,7 @@ node.queue.put(agent_pb2.ServerTaskMessage( file_sync=agent_pb2.FileSyncMessage( - session_id="__fs_explorer__", + session_id=session_id, task_id=tid, control=agent_pb2.SyncControl( action=agent_pb2.SyncControl.WRITE, @@ -241,9 +242,9 @@ res = self.journal.get_result(tid) self.journal.pop(tid) - # M6: Update mirror locally on hub so ls sees it as synced - if self.mirror and res.get("status") == "OK": - workspace_mirror = self.mirror.get_workspace_path("__fs_explorer__") + # M6: Update mirror locally on hub so ls sees it as synced (Only for real sessions) + if self.mirror and res.get("status") == "OK" and session_id != "__fs_explorer__": + workspace_mirror = self.mirror.get_workspace_path(session_id) dest = os.path.join(workspace_mirror, path) if is_dir: os.makedirs(dest, exist_ok=True) @@ -256,7 +257,7 @@ self.journal.pop(tid) return {"error": "Timeout"} - def rm(self, node_id: str, path: str, timeout=10): + def rm(self, node_id: str, path: str, timeout=10, session_id="__fs_explorer__"): """Deletes a file or directory on a node (waits for status).""" node = self.registry.get_node(node_id) if not node: return {"error": "Offline"} @@ -266,7 +267,7 @@ node.queue.put(agent_pb2.ServerTaskMessage( file_sync=agent_pb2.FileSyncMessage( - session_id="__fs_explorer__", + session_id=session_id, task_id=tid, control=agent_pb2.SyncControl(action=agent_pb2.SyncControl.DELETE, path=path) ) @@ -276,10 +277,10 @@ res = self.journal.get_result(tid) self.journal.pop(tid) - # M6: remove from mirror if successful - if self.mirror and res.get("status") == "OK": + # M6: remove from mirror if successful (Only for real sessions) + if self.mirror and res.get("status") == "OK" and session_id != "__fs_explorer__": import shutil - dest = os.path.join(self.mirror.get_workspace_path("__fs_explorer__"), path) + dest = os.path.join(self.mirror.get_workspace_path(session_id), path) if os.path.isdir(dest): shutil.rmtree(dest) elif os.path.exists(dest): os.remove(dest) diff --git a/ui/client-app/src/components/FileSystemNavigator.js b/ui/client-app/src/components/FileSystemNavigator.js index 5cf8f65..cadbbbe 100644 --- a/ui/client-app/src/components/FileSystemNavigator.js +++ b/ui/client-app/src/components/FileSystemNavigator.js @@ -5,7 +5,12 @@ * A modular File Navigator component similar to VS Code's side panel. * Displays a tree structure from an Agent Node's filesystem. */ -const FileSystemNavigator = ({ nodeId, initialPath = "." }) => { +const FileSystemNavigator = ({ + nodeId, + initialPath = ".", + sessionId = "__fs_explorer__", + showSyncStatus = false +}) => { const [tree, setTree] = useState([]); const [loading, setLoading] = useState(false); const [expanded, setExpanded] = useState({}); // { [path]: boolean } @@ -37,16 +42,19 @@ useEffect(() => { if (nodeId) { loadRoot(); - // Polling for sync status if there are unsynced files - const interval = setInterval(() => { - const hasUnsynced = tree.some(f => !f.is_dir && !f.is_synced); - if (hasUnsynced || tree.length === 0) { - loadRoot(); - } - }, 8000); - return () => clearInterval(interval); + + // Only poll if sync status is enabled and we have pending files + if (showSyncStatus) { + const interval = setInterval(() => { + const hasUnsynced = tree.some(f => !f.is_dir && !f.is_synced); + if (hasUnsynced || tree.length === 0) { + loadRoot(); + } + }, 8000); + return () => clearInterval(interval); + } } - }, [nodeId, loadRoot, tree]); + }, [nodeId, loadRoot, tree, showSyncStatus]); const toggleFolder = async (path) => { const isExpanded = expanded[path]; @@ -161,7 +169,7 @@ className={`flex-1 truncate flex items-center ${node.is_dir ? 'font-bold text-gray-700 dark:text-gray-300' : 'text-gray-600 dark:text-gray-400'}`} onClick={() => node.is_dir ? toggleFolder(node.path) : handleView(node.path)} > - {!node.is_dir && ( + {(!node.is_dir && showSyncStatus) && (