Repository: fractal-analytics-platform/fractal-client (language: python)

Issue:
Sort `task list` output
As a first version, the sorting order should depend (from highest to lowest priority) on these attributes:
1. `owner` (so that all common tasks are first, and all users' tasks are last)
2. `name`
3. `version` (so that two versions of the same task will go next to each other)

We can then add more branches to this sorting, to account for more complex cases. Note that this will also constitute the basic example for https://github.com/fractal-analytics-platform/fractal-web/issues/149.

The order could be for instance as in
```
Common task "Cellpose segmentation" version 0.1
Common task "Cellpose segmentation" version 0.2
Common task "Create OME Zarr" version 0.2
Common task "Create OME Zarr" version 0.2
User1 task "Test" version 1.0
User1 task "Test" version 2.0
User2 task "Test" (version specified)
User2 task "Create OME Zarr" (no version)
```

(this is the first feature enabled by #493, and part of #345 W
Generalize task cache to also be aware of task source (or version) for some specific tasks
Quoting from #309:
> The limited-scope of this feature is available via #341. If there are no name clashes (e.g. if a single version of tasks is installed), addressing tasks by name should now work also in `fractal workflow add-task`. If there are non-unique names, the cache is never written and only IDs can be used.

See mainly https://github.com/fractal-analytics-platform/fractal/issues/309#issuecomment-1324792116 and following comments.
Generalize task cache to also be aware of task source (or version) for some specific tasks
Quoting from #309:
> The limited-scope of this feature is available via #341. If there are no name clashes (e.g. if a single version of tasks is installed), addressing tasks by name should now work also in `fractal workflow add-task`. If there are non-unique names, the cache is never written and only IDs can be used.

See mainly https://github.com/fractal-analytics-platform/fractal/issues/309#issuecomment-1324792116 and following comments.
Sort `task list` output
As a first version, the sorting order should depend (from highest to lowest priority) on these attributes:
1. `owner` (so that all common tasks are first, and all users' tasks are last)
2. `name`
3. `version` (so that two versions of the same task will go next to each other)

We can then add more branches to this sorting, to account for more complex cases. Note that this will also constitute the basic example for https://github.com/fractal-analytics-platform/fractal-web/issues/149.

The order could be for instance as in
```
Common task "Cellpose segmentation" version 0.1
Common task "Cellpose segmentation" version 0.2
Common task "Create OME Zarr" version 0.2
Common task "Create OME Zarr" version 0.2
User1 task "Test" version 1.0
User1 task "Test" version 2.0
User2 task "Test" (version specified)
User2 task "Create OME Zarr" (no version)
```

(this is the first feature enabled by #493, and part of #345 W


Discussion / hints:

Currently, adding tasks by name is only supported if there is only a single version of them installed. We should add support for adding tasks by name & version. 
And if multiple versions of a task are present + now version is specified, default to the newest version available on the server.
Quoting https://github.com/fractal-analytics-platform/fractal/issues/309#issuecomment-1324792116:
> This feature can be included somewhat easily, if we restrict its scope to a subset of all possible tasks - namely the ones coming from a pypi install of fractal-tasks-core package. This would mean that:

This discussion is progressing in https://github.com/fractal-analytics-platform/fractal-server/issues/1#issuecomment-1403501673 (or in a dedicated issue, soon enough), and will affect this issue on the multiple-installed-versions use cases.
Just stumbled across this again, tagging it so that we consider it a priority after the top 3 discussed in our call. Will be very helpful to have different versions of the same task installed, but still accessible on a name-basis. Default to newest version available would be nice here.
Let's pick up on this again :)
An example of why this is important:
@adrtsc started to add custom tasks. The server accepts tasks with non-unique names. But then, the client-side task adding fails, because the caching can't happen client-side with the non-unique names.

Here's an example error, so this issue becomes findable for someone looking for it

```
WARNING:root:Cannot write task-list cache if task names are not unique (version-based disambiguation will be added in the future).
Current task list includes: ['Create OME-Zarr structure', 'Convert Yokogawa to OME-Zarr', 'Copy OME-Zarr structure', 'Maximum Intensity Projection', 'Cellpose Segmentation', 'Illumination correction', 'Napari workflows wrapper', 'Create OME-ZARR structure (multiplexing)', 'my custom task 1', 'my custom task custom_task', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_0', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_1', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_2', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_3', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_4', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'test']
Traceback (most recent call last):
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/_workflow.py", line 101, in workflow_add_task
    task_id = int(task_id_or_name)
ValueError: invalid literal for int() with base 10: 'Create OME-Zarr structure'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/bin/fractal", line 8, in <module>
    sys.exit(run())
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/__main__.py", line 7, in run
    asyncio.run(main())
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/client.py", line 114, in main
    interface = await handle()
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/client.py", line 104, in handle
    interface = await handler(client, **kwargs)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/__init__.py", line 151, in workflow
    iface = await workflow_add_task(client, batch=batch, **kwargs)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/_workflow.py", line 103, in workflow_add_task
    task_id = await get_cached_task_by_name(task_id_or_name, client)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/utils.py", line 26, in get_cached_task_by_name
    with cache_file.open("r") as f:
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/pathlib.py", line 1241, in open
    return io.open(self, mode, buffering, encoding, errors, newline,
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/pathlib.py", line 1109, in _opener
    return self._accessor.open(self, flags, mode)
FileNotFoundError: [Errno 2] No such file or directory: '/data/homes/atschan/fractal-demos/examples/08_custom_cellpose_task/.cache/tasks'

```
Also account for extras (see pytorch discussion)?
Currently, adding tasks by name is only supported if there is only a single version of them installed. We should add support for adding tasks by name & version. 
And if multiple versions of a task are present + now version is specified, default to the newest version available on the server.
Quoting https://github.com/fractal-analytics-platform/fractal/issues/309#issuecomment-1324792116:
> This feature can be included somewhat easily, if we restrict its scope to a subset of all possible tasks - namely the ones coming from a pypi install of fractal-tasks-core package. This would mean that:

This discussion is progressing in https://github.com/fractal-analytics-platform/fractal-server/issues/1#issuecomment-1403501673 (or in a dedicated issue, soon enough), and will affect this issue on the multiple-installed-versions use cases.
Just stumbled across this again, tagging it so that we consider it a priority after the top 3 discussed in our call. Will be very helpful to have different versions of the same task installed, but still accessible on a name-basis. Default to newest version available would be nice here.
Let's pick up on this again :)
An example of why this is important:
@adrtsc started to add custom tasks. The server accepts tasks with non-unique names. But then, the client-side task adding fails, because the caching can't happen client-side with the non-unique names.

Here's an example error, so this issue becomes findable for someone looking for it

```
WARNING:root:Cannot write task-list cache if task names are not unique (version-based disambiguation will be added in the future).
Current task list includes: ['Create OME-Zarr structure', 'Convert Yokogawa to OME-Zarr', 'Copy OME-Zarr structure', 'Maximum Intensity Projection', 'Cellpose Segmentation', 'Illumination correction', 'Napari workflows wrapper', 'Create OME-ZARR structure (multiplexing)', 'my custom task 1', 'my custom task custom_task', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_0', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_1', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_2', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_3', 'my custom task 20230306_apex2_validation_protease_3D_fractal_custom_cellpose_4', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'Cellpose Segmentation Custom', 'test']
Traceback (most recent call last):
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/_workflow.py", line 101, in workflow_add_task
    task_id = int(task_id_or_name)
ValueError: invalid literal for int() with base 10: 'Create OME-Zarr structure'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/bin/fractal", line 8, in <module>
    sys.exit(run())
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/__main__.py", line 7, in run
    asyncio.run(main())
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/asyncio/runners.py", line 44, in run
    return loop.run_until_complete(main)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/asyncio/base_events.py", line 642, in run_until_complete
    return future.result()
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/client.py", line 114, in main
    interface = await handle()
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/client.py", line 104, in handle
    interface = await handler(client, **kwargs)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/__init__.py", line 151, in workflow
    iface = await workflow_add_task(client, batch=batch, **kwargs)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/_workflow.py", line 103, in workflow_add_task
    task_id = await get_cached_task_by_name(task_id_or_name, client)
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/site-packages/fractal/cmd/utils.py", line 26, in get_cached_task_by_name
    with cache_file.open("r") as f:
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/pathlib.py", line 1241, in open
    return io.open(self, mode, buffering, encoding, errors, newline,
  File "/data/homes/atschan/.conda/envs/fractal-client-1.1.0a2/lib/python3.9/pathlib.py", line 1109, in _opener
    return self._accessor.open(self, flags, mode)
FileNotFoundError: [Errno 2] No such file or directory: '/data/homes/atschan/fractal-demos/examples/08_custom_cellpose_task/.cache/tasks'

```
Also account for extras (see pytorch discussion)?


Fix patch:
diff --git a/fractal/cmd/__init__.py b/fractal/cmd/__init__.py
index b5c55ada2..74a424b00 100644
--- a/fractal/cmd/__init__.py
+++ b/fractal/cmd/__init__.py
@@ -121,7 +121,7 @@ async def task(
     client: AuthClient, subcmd: str, batch: bool = False, **kwargs
 ) -> BaseInterface:
     if subcmd == "list":
-        iface = await get_task_list(client, **kwargs)
+        iface = await get_task_list(client)
     elif subcmd == "collect":
         iface = await task_collect_pip(client, batch=batch, **kwargs)
     elif subcmd == "check-collection":
diff --git a/fractal/cmd/_aux_task_caching.py b/fractal/cmd/_aux_task_caching.py
new file mode 100644
index 000000000..b7e3ad316
--- /dev/null
+++ b/fractal/cmd/_aux_task_caching.py
@@ -0,0 +1,139 @@
+import json
+from pathlib import Path
+from typing import Any
+from typing import Optional
+
+from ..authclient import AuthClient
+from ..config import settings
+from ..response import check_response
+
+TASKS_CACHE_FILENAME = "tasks"
+
+
+# Define a useful type
+_TaskList = list[dict[str, Any]]
+
+
+async def _fetch_task_list(client: AuthClient) -> _TaskList:
+    """
+    Fetch task list through an API request.
+    """
+    res = await client.get(f"{settings.BASE_URL}/task/")
+    task_list = check_response(res, expected_status_code=200)
+    return task_list
+
+
+def _sort_task_list(task_list: _TaskList) -> _TaskList:
+    """
+    Sort tasks according to their (owner, name, version) attributes.
+    """
+    new_task_list = sorted(
+        task_list,
+        key=lambda task: (
+            task["owner"] or "",
+            task["name"],
+            task["version"] or "",
+        ),
+    )
+    return new_task_list
+
+
+def _write_task_list(task_list: _TaskList) -> None:
+    """
+    Write task list to cache file
+    """
+    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
+    cache_dir.mkdir(parents=True, exist_ok=True)
+    with (cache_dir / TASKS_CACHE_FILENAME).open("w") as f:
+        json.dump(task_list, f, indent=4)
+
+
+async def refresh_task_cache(client: AuthClient) -> list[dict[str, Any]]:
+    """
+    Return task list after fetching it, sorting it and writing to cache file.
+    """
+    task_list = await _fetch_task_list(client)
+    task_list = _sort_task_list(task_list)
+    _write_task_list(task_list)
+    return task_list
+
+
+def _get_task_id(
+    task_list: list[dict],
+    *,
+    name: Optional[str] = None,
+    _id: Optional[int] = None,
+    version: Optional[str] = "___VERSION_DEFAULT___",
+    owner: Optional[str] = "___OWNER_DEFAULT___",
+):
+    """
+    Given a task list, extract the ID of the single task matching some
+    conditions.
+
+    Note: this function also lets the user find tasks with `version` or `owner`
+    that are `None`; this won't be necessarily used in other public
+    functions.
+    """
+
+    def _condition(_task):
+        if name and _task["name"] != name:
+            return False
+        if version != "___VERSION_DEFAULT___" and _task["version"] != version:
+            return False
+        if owner != "___OWNER_DEFAULT___" and _task["owner"] != owner:
+            return False
+        if _id and _task["id"] != _id:
+            return False
+        return True
+
+    tasks = [_task for _task in task_list if _condition(_task)]
+    if len(tasks) == 0:
+        raise ValueError("No task matches required attributes")
+    elif len(tasks) > 1:
+        raise ValueError(
+            f"Multiple tasks ({len(tasks)}) match required attributes"
+        )
+    return tasks[0]["id"]
+
+
+async def get_cached_task_by_name(name: str, client: AuthClient) -> int:
+
+    """ """
+
+    # Set paths
+    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
+    cache_file = cache_dir / TASKS_CACHE_FILENAME
+
+    # If cache is missing, create it
+    cache_up_to_date = False
+    if not cache_file.exists():
+        await refresh_task_cache(client)
+        cache_up_to_date = True
+
+    # Read task list and create cache of (name, id) pairs
+    # FIMXE: this step will be modified in view of
+    # https://github.com/fractal-analytics-platform/fractal/issues/345
+    with cache_file.open("r") as f:
+        task_list = json.load(f)
+    task_cache = {}
+    for task in task_list:
+        if task["name"] in task_cache.keys():
+            raise ValueError("Cannot parse task_list")
+        task_cache[task["name"]] = task["id"]
+
+    # Look for name in cache
+    # Case 1: name exists in cache
+    if name in task_cache.keys():
+        return task_cache[name]
+    # Case 2: name is missing, and cache was just updated
+    elif cache_up_to_date:
+        raise KeyError(f'Task "{name}" not in {cache_file}\n')
+    # Case 3: name is missing but cache may be out of date
+    else:
+        await refresh_task_cache(client)
+        with cache_file.open("r") as f:
+            task_cache = json.load(f)
+        try:
+            return task_cache[name]
+        except KeyError as e:
+            raise KeyError(f'Task "{name}" not in {cache_file}\n', str(e))
diff --git a/fractal/cmd/_task.py b/fractal/cmd/_task.py
index 7900682f2..ed0052069 100644
--- a/fractal/cmd/_task.py
+++ b/fractal/cmd/_task.py
@@ -12,12 +12,12 @@
 from ..interface import PrintInterface
 from ..interface import RichJsonInterface
 from ..response import check_response
-from .utils import get_cached_task_by_name
-from .utils import refresh_task_cache
+from ._aux_task_caching import get_cached_task_by_name
+from ._aux_task_caching import refresh_task_cache
 
 
-async def get_task_list(client: AuthClient, **kwargs) -> RichJsonInterface:
-    task_list = await refresh_task_cache(client=client, **kwargs)
+async def get_task_list(client: AuthClient) -> RichJsonInterface:
+    task_list = await refresh_task_cache(client=client)
     return RichJsonInterface(retcode=0, data=task_list)
 
 
diff --git a/fractal/cmd/_workflow.py b/fractal/cmd/_workflow.py
index 77adecc32..e691332cb 100644
--- a/fractal/cmd/_workflow.py
+++ b/fractal/cmd/_workflow.py
@@ -19,7 +19,7 @@
 from ..interface import PrintInterface
 from ..interface import RichJsonInterface
 from ..response import check_response
-from .utils import get_cached_task_by_name
+from ._aux_task_caching import get_cached_task_by_name
 
 
 async def post_workflow(
diff --git a/fractal/cmd/utils.py b/fractal/cmd/utils.py
deleted file mode 100644
index 4d96c6468..000000000
--- a/fractal/cmd/utils.py
+++ /dev/null
@@ -1,80 +0,0 @@
-import json
-import logging
-from pathlib import Path
-from typing import Any
-from typing import Dict
-from typing import List
-
-from ..authclient import AuthClient
-from ..config import settings
-from ..response import check_response
-
-
-async def get_cached_task_by_name(name: str, client: AuthClient) -> int:
-
-    # Set paths
-    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
-    cache_file = cache_dir / "tasks"
-
-    # If cache is missing, create it
-    cache_up_to_date = False
-    if not cache_file.exists():
-        await refresh_task_cache(client)
-        cache_up_to_date = True
-
-    # Read cache
-    with cache_file.open("r") as f:
-        task_cache = json.load(f)
-
-    # Look for name in cache
-    # Case 1: name exists in cache
-    if name in task_cache.keys():
-        return task_cache[name]
-    # Case 2: name is missing, and cache was just updated
-    elif cache_up_to_date:
-        raise KeyError(f'Task "{name}" not in {cache_file}\n')
-    # Case 3: name is missing but cache may be out of date
-    else:
-        await refresh_task_cache(client)
-        with cache_file.open("r") as f:
-            task_cache = json.load(f)
-        try:
-            return task_cache[name]
-        except KeyError as e:
-            raise KeyError(f'Task "{name}" not in {cache_file}\n', str(e))
-
-
-async def refresh_task_cache(
-    client: AuthClient, **kwargs
-) -> List[Dict[str, Any]]:
-
-    # Get task_list
-    res = await client.get(f"{settings.BASE_URL}/task/")
-    task_list = check_response(res, expected_status_code=200)
-
-    # Check that there are no name clashes
-    names = [task["name"] for task in task_list]
-    if len(set(names)) < len(names):
-        msg = (
-            "Cannot write task-list cache if task names are not unique "
-            "(version-based disambiguation will be added in the future).\n"
-            f"Current task list includes: {names}"
-        )
-        logging.warning(msg)
-        return task_list
-
-    # Refresh cache of (name,id) pairs
-    task_cache = {}
-    for task in task_list:
-        task_cache[task["name"]] = task["id"]
-
-    # Set paths and create cache folder (if needed)
-    cache_dir = Path(f"{settings.FRACTAL_CACHE_PATH}").expanduser()
-    cache_file = cache_dir / "tasks"
-    cache_dir.mkdir(parents=True, exist_ok=True)
-
-    # Write task cache
-    with cache_file.open("w") as f:
-        json.dump(task_cache, f, indent=4)
-
-    return task_list


Test patch:
diff --git a/tests/data/fractal_tasks_dummy-0.2.0-py3-none-any.whl b/tests/data/fractal_tasks_dummy-0.2.0-py3-none-any.whl
new file mode 100644
index 000000000..36de21df2
Binary files /dev/null and b/tests/data/fractal_tasks_dummy-0.2.0-py3-none-any.whl differ
diff --git a/tests/fixtures_testserver.py b/tests/fixtures_testserver.py
index 90f4c4631..b1acff95e 100644
--- a/tests/fixtures_testserver.py
+++ b/tests/fixtures_testserver.py
@@ -64,6 +64,7 @@ def run_server():
                 "fractal_server.main:app",
                 port=10080,
                 log_level="debug",
+                timeout_keep_alive=10,
             )
         )
 
diff --git a/tests/test_task.py b/tests/test_task.py
index fb7baddc7..9737bcf73 100644
--- a/tests/test_task.py
+++ b/tests/test_task.py
@@ -1,4 +1,3 @@
-import asyncio
 import json
 import time
 from pathlib import Path
@@ -45,21 +44,17 @@ async def test_task_collection_command(register_user, invoke, caplog):
     assert payload["python_version"] == PYTHON_VERSION
 
 
-async def test_task_collection_and_list(register_user, invoke, testdata_path):
+async def test_task_collection(register_user, invoke, testdata_path):
     """
     GIVEN a pip installable package containing fractal-compatible tasks
-
     WHEN the collection subcommand is called
     THEN
         * the collection is initiated in the background
         * the server returns immediately
-
-    WHEN the list command is called
-    THEN the tasks collected are shown
     """
-    PACKAGE_NAME = testdata_path / "fractal_tasks_dummy-0.1.0-py3-none-any.whl"
+    PACKAGE = testdata_path / "fractal_tasks_dummy-0.1.0-py3-none-any.whl"
 
-    res0 = await invoke(f"task collect {PACKAGE_NAME}")
+    res0 = await invoke(f"task collect {PACKAGE}")
     debug(res0)
     res0.show()
 
@@ -76,7 +71,7 @@ async def test_task_collection_and_list(register_user, invoke, testdata_path):
         res1 = await invoke(f"task check-collection {state_id}")
         assert res1.retcode == 0
         res1.show()
-        await asyncio.sleep(1)
+        time.sleep(1)
         if res1.data["data"]["status"] == "OK":
             break
         assert time.perf_counter() - starting_time < COLLECTION_TIMEOUT
@@ -92,12 +87,6 @@ async def test_task_collection_and_list(register_user, invoke, testdata_path):
     assert res2.data["data"]["log"]
     assert res2.data["data"]["status"] == "OK"
 
-    # List tasks
-    res = await invoke("task list")
-    res.show()
-    assert res.retcode == 0
-    assert len(res.data) == 2
-
     # Show again the check-collection output, without --do-not-separate-logs,
     # for visual inspection
     res = await invoke(f"task check-collection {state_id} --include-logs")
@@ -113,9 +102,9 @@ async def test_repeated_task_collection(register_user, invoke, testdata_path):
     THEN
         * TBD..
     """
-    PACKAGE_NAME = testdata_path / "fractal_tasks_dummy-0.1.0-py3-none-any.whl"
+    PACKAGE = testdata_path / "fractal_tasks_dummy-0.1.0-py3-none-any.whl"
 
-    res0 = await invoke(f"task collect {PACKAGE_NAME}")
+    res0 = await invoke(f"task collect {PACKAGE}")
     debug(res0)
     res0.show()
 
@@ -130,13 +119,13 @@ async def test_repeated_task_collection(register_user, invoke, testdata_path):
     starting_time = time.perf_counter()
     while True:
         res1 = await invoke(f"task check-collection {state_id}")
-        await asyncio.sleep(1)
+        time.sleep(1)
         if res1.data["data"]["status"] == "OK":
             break
         assert time.perf_counter() - starting_time < COLLECTION_TIMEOUT
 
     # Second collection
-    res0 = await invoke(f"task collect {PACKAGE_NAME}")
+    res0 = await invoke(f"task collect {PACKAGE}")
     res0.show()
     assert res0.data["data"]["info"] == "Already installed"
 
@@ -242,3 +231,77 @@ async def test_task_delete(register_user, invoke):
     with pytest.raises(NotImplementedError):
         debug(f"task delete {task_id}")
         res = await invoke(f"task delete {task_id}")
+
+
+async def test_task_list(register_user, invoke, testdata_path):
+    """
+    Install tasks from a package
+    Add a custom task with an owner
+    List tasks in the appropriate way
+    """
+
+    for version in ["0.1.0", "0.2.0"]:
+        # Task collection
+        PACKAGE = (
+            testdata_path / f"fractal_tasks_dummy-{version}-py3-none-any.whl"
+        )
+        res = await invoke(f"task collect {PACKAGE}")
+        assert res.retcode == 0
+        state_id = res.data["id"]
+
+        # Wait until collection is complete
+        time.sleep(1)
+        starting_time = time.perf_counter()
+        while True:
+            res = await invoke(f"task check-collection {state_id}")
+            assert res.retcode == 0
+            if res.data["data"]["status"] == "OK":
+                break
+            assert time.perf_counter() - starting_time < COLLECTION_TIMEOUT
+            time.sleep(1)
+
+    # Add custom task
+    custom_task_name = "custom_task_name"
+    custom_task_command = "custom_task_command"
+    custom_task_source = "custom_task_source"
+    custom_task_version = "9.9"
+    res = await invoke(
+        f"task new {custom_task_name} {custom_task_command} "
+        f"{custom_task_source} --version {custom_task_version}"
+    )
+    debug(res)
+    assert res.retcode == 0
+
+    # List tasks
+    res = await invoke("task list")
+    assert res.retcode == 0
+    task_list = res.data
+
+    # Remove some attributes, to de-clutter output
+    for task in task_list:
+        for key in [
+            "command",
+            "default_args",
+            "meta",
+            "input_type",
+            "output_type",
+        ]:
+            task.pop(key)
+    debug(task_list)
+
+    # Check that tasks are sorted as expected
+    assert task_list[0]["name"] == "dummy"
+    assert task_list[0]["version"] == "0.1.0"
+    assert task_list[0]["owner"] is None
+    assert task_list[1]["name"] == "dummy"
+    assert task_list[1]["version"] == "0.2.0"
+    assert task_list[1]["owner"] is None
+    assert task_list[2]["name"] == "dummy parallel"
+    assert task_list[2]["version"] == "0.1.0"
+    assert task_list[2]["owner"] is None
+    assert task_list[3]["name"] == "dummy parallel"
+    assert task_list[3]["version"] == "0.2.0"
+    assert task_list[3]["owner"] is None
+    assert task_list[4]["name"] == custom_task_name
+    assert task_list[4]["version"] == custom_task_version
+    assert task_list[4]["owner"] is not None
diff --git a/tests/test_unit_task_cache.py b/tests/test_unit_task_cache.py
new file mode 100644
index 000000000..e70e52cc6
--- /dev/null
+++ b/tests/test_unit_task_cache.py
@@ -0,0 +1,47 @@
+import pytest
+from devtools import debug
+
+from fractal.cmd._aux_task_caching import _get_task_id
+
+
+TASK_LIST = [
+    # Common tasks (with owner=None)
+    dict(name="Task A", id=1, version="0.1.1", owner=None),
+    dict(name="Task A", id=2, version="1.2.0", owner=None),
+    dict(name="Task A", id=3, version="1.3.1", owner=None),
+    dict(name="Task B", id=4, version="0.1.1", owner=None),
+    dict(name="Task B", id=5, version="1.2.0", owner=None),
+    dict(name="Task B", id=6, version="1.3.1", owner=None),
+    # UserA tasks
+    dict(name="Task A", id=7, version="1.3.1", owner="User1"),
+]
+
+NOTSET = "__THIS_VALUE_IS_NOT_SET__"
+RAISE = "__THIS_MUST_FAIL__"
+
+CASES = [
+    (NOTSET, NOTSET, NOTSET, 1, 1),
+    (NOTSET, NOTSET, "User1", NOTSET, 7),
+    (NOTSET, NOTSET, NOTSET, 999, RAISE),
+    # FIXME: add more cases
+]
+
+
+def test_get_task_id():
+    for (name, version, owner, _id, expected_id) in CASES:
+        kwargs = {}
+        if name != NOTSET:
+            kwargs["name"] = name
+        if version != NOTSET:
+            kwargs["version"] = version
+        if _id != NOTSET:
+            kwargs["_id"] = _id
+        if owner != NOTSET:
+            kwargs["owner"] = owner
+        debug(kwargs)
+
+        if expected_id == RAISE:
+            with pytest.raises(ValueError):
+                _get_task_id(TASK_LIST, **kwargs)
+        else:
+            assert _get_task_id(TASK_LIST, **kwargs) == expected_id
diff --git a/tests/test_workflow.py b/tests/test_workflow.py
index d44081bcf..c69337141 100644
--- a/tests/test_workflow.py
+++ b/tests/test_workflow.py
@@ -212,6 +212,7 @@ async def test_workflow_add_task_by_name(
     assert res.data["task"]["id"] == task.id
 
 
+@pytest.mark.skip(reason="Definition of expected behavior is ongoing")
 async def test_task_cache_with_non_unique_names(
     invoke,
     register_user,
@@ -242,7 +243,7 @@ async def test_task_cache_with_non_unique_names(
     res = await invoke("task list")
     assert res.retcode == 0
     debug(caplog.text)
-    assert "Cannot write task-list cache" in caplog.text
+    assert "Cannot" in caplog.text
 
     # Verify that adding tasks to a worfklow by name (as opposed to "by id")
     # fails because of missing cache file