horus-runtime 0.2.1__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (186) hide show
  1. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/PKG-INFO +1 -1
  2. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/tui_subscriber.py +30 -13
  3. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/map.py +77 -5
  4. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/scheduler.py +27 -5
  5. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/expander.py +41 -7
  6. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/lowering.py +16 -1
  7. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/base.py +5 -6
  8. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/exceptions.py +9 -2
  9. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/sanitize.py +71 -29
  10. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_tui_subscriber.py +32 -0
  11. horus_runtime-0.2.2/tests/unit/test_sanitize.py +456 -0
  12. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_conditions.py +2 -2
  13. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_edges.py +29 -18
  14. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_map.py +145 -0
  15. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_scheduler.py +44 -0
  16. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_subworkflow.py +49 -10
  17. horus_runtime-0.2.1/tests/unit/test_sanitize.py +0 -206
  18. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/.gitignore +0 -0
  19. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/LICENSE +0 -0
  20. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/pyproject.toml +0 -0
  21. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/__init__.py +0 -0
  22. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/__init__.py +0 -0
  23. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/file.py +0 -0
  24. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/folder.py +0 -0
  25. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/json.py +0 -0
  26. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/pickle.py +0 -0
  27. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/__init__.py +0 -0
  28. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/artifact_event.py +0 -0
  29. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/log_subscriber.py +0 -0
  30. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/task_event.py +0 -0
  31. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/workflow_event.py +0 -0
  32. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/__init__.py +0 -0
  33. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/_cwd_lock.py +0 -0
  34. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/python_exec.py +0 -0
  35. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/python_fn.py +0 -0
  36. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/shell.py +0 -0
  37. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/__init__.py +0 -0
  38. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/cli.py +0 -0
  39. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/__init__.py +0 -0
  40. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/confirm.py +0 -0
  41. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/dropdown.py +0 -0
  42. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/file.py +0 -0
  43. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/string.py +0 -0
  44. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/task_log_file.py +0 -0
  45. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/task_time.py +0 -0
  46. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/workflow_time.py +0 -0
  47. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/py.typed +0 -0
  48. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/__init__.py +0 -0
  49. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/command.py +0 -0
  50. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python.py +0 -0
  51. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python_script.py +0 -0
  52. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python_string.py +0 -0
  53. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/substitution.py +0 -0
  54. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/target/__init__.py +0 -0
  55. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/target/local.py +0 -0
  56. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/__init__.py +0 -0
  57. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/function.py +0 -0
  58. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/horus_task.py +0 -0
  59. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/transfer/__init__.py +0 -0
  60. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/transfer/local_noop.py +0 -0
  61. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/tui.py +0 -0
  62. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/__init__.py +0 -0
  63. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/branch.py +0 -0
  64. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/condition.py +0 -0
  65. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/dag.py +0 -0
  66. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/horus_workflow.py +0 -0
  67. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/loop.py +0 -0
  68. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/__init__.py +0 -0
  69. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/errors.py +0 -0
  70. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/ports.py +0 -0
  71. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/__init__.py +0 -0
  72. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/cli.py +0 -0
  73. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/context.py +0 -0
  74. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/__init__.py +0 -0
  75. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/__init__.py +0 -0
  76. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/base.py +0 -0
  77. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/exceptions.py +0 -0
  78. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/store.py +0 -0
  79. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/__init__.py +0 -0
  80. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/base.py +0 -0
  81. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/exceptions.py +0 -0
  82. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/__init__.py +0 -0
  83. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/base.py +0 -0
  84. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/exceptions.py +0 -0
  85. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/renderer.py +0 -0
  86. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/transport.py +0 -0
  87. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/placement.py +0 -0
  88. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/resources.py +0 -0
  89. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/__init__.py +0 -0
  90. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/base.py +0 -0
  91. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/events.py +0 -0
  92. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/__init__.py +0 -0
  93. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/base.py +0 -0
  94. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/channel.py +0 -0
  95. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/exceptions.py +0 -0
  96. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/__init__.py +0 -0
  97. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/base.py +0 -0
  98. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/exceptions.py +0 -0
  99. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/status.py +0 -0
  100. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/__init__.py +0 -0
  101. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/exceptions.py +0 -0
  102. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/generic.py +0 -0
  103. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/strategy.py +0 -0
  104. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/__init__.py +0 -0
  105. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/condition.py +0 -0
  106. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/edge.py +0 -0
  107. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/status.py +0 -0
  108. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/__init__.py +0 -0
  109. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/async_loop.py +0 -0
  110. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/base.py +0 -0
  111. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/bus.py +0 -0
  112. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/subscriber.py +0 -0
  113. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/transport.py +0 -0
  114. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/i18n.py +0 -0
  115. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/es/LC_MESSAGES/horus_runtime.mo +0 -0
  116. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/es/LC_MESSAGES/horus_runtime.po +0 -0
  117. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/messages.pot +0 -0
  118. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/logging.py +0 -0
  119. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/__init__.py +0 -0
  120. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/auto_middleware.py +0 -0
  121. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/executor.py +0 -0
  122. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/interaction.py +0 -0
  123. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/runtime.py +0 -0
  124. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/target.py +0 -0
  125. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/target_command.py +0 -0
  126. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/task.py +0 -0
  127. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/transfer.py +0 -0
  128. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/workflow.py +0 -0
  129. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/packaging.py +0 -0
  130. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/py.typed +0 -0
  131. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/__init__.py +0 -0
  132. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/auto_registry.py +0 -0
  133. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/auto_registry_product.py +0 -0
  134. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/exceptions.py +0 -0
  135. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/settings.py +0 -0
  136. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/version.py +0 -0
  137. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/__init__.py +0 -0
  138. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/conftest.py +0 -0
  139. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/integration/test_runtime_integration.py +0 -0
  140. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_artifact_base.py +0 -0
  141. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_builtin_artifact.py +0 -0
  142. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_store.py +0 -0
  143. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/boot/test_boot.py +0 -0
  144. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/cli/test_cli.py +0 -0
  145. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/cli/test_run.py +0 -0
  146. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/core/test_placement.py +0 -0
  147. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/core/test_resources.py +0 -0
  148. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/common.py +0 -0
  149. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_builtin_log_subscriber.py +0 -0
  150. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_event_system.py +0 -0
  151. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_builtin_executor.py +0 -0
  152. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_collect_side_artifacts.py +0 -0
  153. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_executor_base.py +0 -0
  154. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/i18n/test_i18n.py +0 -0
  155. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/interaction/test_builtin_interactions.py +0 -0
  156. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/interaction/test_interaction.py +0 -0
  157. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_middleware.py +0 -0
  158. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_target_command.py +0 -0
  159. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_task_log_file.py +0 -0
  160. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/registry/test_auto_registry.py +0 -0
  161. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/registry/test_schema_generation.py +0 -0
  162. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_anchor_local_paths.py +0 -0
  163. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_builtin_runtime.py +0 -0
  164. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_python_script.py +0 -0
  165. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_python_string.py +0 -0
  166. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_runtime_base.py +0 -0
  167. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_substitution.py +0 -0
  168. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_channel.py +0 -0
  169. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_detach.py +0 -0
  170. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_local_target.py +0 -0
  171. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_target_base.py +0 -0
  172. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/conftest.py +0 -0
  173. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_builtin_function_task.py +0 -0
  174. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_builtin_task.py +0 -0
  175. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_task_base.py +0 -0
  176. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_logging.py +0 -0
  177. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_main.py +0 -0
  178. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_packaging.py +0 -0
  179. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/transfer/test_builtin_transfer.py +0 -0
  180. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/transfer/test_transfer.py +0 -0
  181. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_branch_router.py +0 -0
  182. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_builtin_workflow.py +0 -0
  183. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_dynamic_mutation.py +0 -0
  184. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_loops.py +0 -0
  185. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_run_layout.py +0 -0
  186. {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_workflow.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: horus-runtime
3
- Version: 0.2.1
3
+ Version: 0.2.2
4
4
  Summary: The Horus workflow manager runtime. Execute advanced workflows with multi-environment support, HPC integration, automatic artifact management, and more.
5
5
  License-File: LICENSE
6
6
  Classifier: License :: OSI Approved :: GNU Affero General Public License v3
@@ -243,8 +243,9 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
243
243
  _live: Live | None = PrivateAttr(default=None)
244
244
  _workflow: BaseWorkflow | None = PrivateAttr(default=None)
245
245
 
246
- # Execution scope (planned task ids) for an honest progress total.
247
- _scope: set[str] = PrivateAttr(default_factory=set)
246
+ # Task the run was triggered from; the execution scope behind the
247
+ # progress total is re-planned from it on every frame (see _scope_ids).
248
+ _trigger: str | None = PrivateAttr(default=None)
248
249
  _started_at: float | None = PrivateAttr(default=None)
249
250
 
250
251
  # Per-task timing, derived from status transitions / completion events.
@@ -268,18 +269,34 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
268
269
  Render *workflow*'s tasks before the run starts.
269
270
  """
270
271
  self._workflow = workflow
272
+ self._trigger = trigger_id or (
273
+ workflow.tasks[0].id if workflow.tasks else None
274
+ )
275
+
276
+ def _scope_ids(self, workflow: BaseWorkflow) -> set[str]:
277
+ """
278
+ Ids in the run's execution scope, re-planned from *workflow*'s
279
+ current tasks and edges.
280
+
281
+ A ``map:`` or ``sub:`` expander adds its clones, and the edges that
282
+ reach the gather task, only when it runs (see
283
+ :meth:`~horus_runtime.core.workflow.base.BaseWorkflow.expand`), so a
284
+ plan frozen before the run undercounts every fan-out: a five-clone
285
+ loop map reported ``1/1 tasks``. Re-planning per frame counts the
286
+ DAG as it actually is.
287
+ """
288
+ if self._trigger is None:
289
+ return {t.id for t in workflow.tasks}
271
290
  try:
272
- target = trigger_id or (
273
- workflow.tasks[0].id if workflow.tasks else None
274
- )
275
- if target is not None:
276
- self._scope = set(
277
- execution_plan(
278
- workflow.tasks, trigger_id=target, edges=workflow.edges
279
- )
291
+ return set(
292
+ execution_plan(
293
+ workflow.tasks,
294
+ trigger_id=self._trigger,
295
+ edges=workflow.edges,
280
296
  )
297
+ )
281
298
  except Exception:
282
- self._scope = {t.id for t in workflow.tasks}
299
+ return {t.id for t in workflow.tasks}
283
300
 
284
301
  @contextmanager
285
302
  def live(self) -> Iterator[None]:
@@ -470,7 +487,7 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
470
487
  return Panel(line, border_style=style, padding=(0, 1))
471
488
 
472
489
  def _render_progress(self, workflow: BaseWorkflow) -> RenderableType:
473
- scope = self._scope or {t.id for t in workflow.tasks}
490
+ scope = self._scope_ids(workflow)
474
491
  total = len(scope)
475
492
  tasks = [t for t in workflow.tasks if t.id in scope]
476
493
  done = sum(1 for t in tasks if t.status in _TERMINAL)
@@ -586,7 +603,7 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
586
603
  if workflow is None:
587
604
  return Text(_("No workflow ran."), style="dim")
588
605
 
589
- scope = self._scope or {t.id for t in workflow.tasks}
606
+ scope = self._scope_ids(workflow)
590
607
  tasks = [t for t in workflow.tasks if t.id in scope]
591
608
  done = sum(1 for t in tasks if t.status in _TERMINAL)
592
609
  elapsed = (
@@ -334,7 +334,9 @@ class MapExpander(HorusTask):
334
334
  )
335
335
  self._set_input_path(clone, self.over.index_input, index_path)
336
336
 
337
- self._set_output_path(clone, gathered_root / str(i))
337
+ await self._set_output_path(
338
+ clone, gathered_root / str(i), orchestrator
339
+ )
338
340
 
339
341
  clones.append(clone)
340
342
  edges.append(
@@ -592,8 +594,31 @@ class MapExpander(HorusTask):
592
594
  )
593
595
  self._pin_path(artifact, path)
594
596
 
595
- def _set_output_path(self, clone: BaseTask, path: Path) -> None:
596
- """Pin *clone*'s (sole) declared output at *path*."""
597
+ async def _set_output_path(
598
+ self, clone: BaseTask, slot_dir: Path, orchestrator: BaseTarget
599
+ ) -> None:
600
+ """
601
+ Pin *clone*'s (sole) declared output under its ``{i}/`` slot dir.
602
+
603
+ A :class:`.FolderArtifact` output already names a directory, so it
604
+ is pinned straight at *slot_dir* (the gather task then sees
605
+ ``{i}/<whatever the clone wrote>``). Any other output keeps its
606
+ declared filename, pinned at ``slot_dir / <filename>``; since
607
+ nothing else creates *slot_dir* for a bare file output, it is
608
+ created here first (only in this branch -- pre-creating it for a
609
+ folder output would make the clone look already-complete and skip
610
+ it on resume).
611
+
612
+ A :class:`~horus_builtin.workflow.subworkflow.expander.
613
+ SubworkflowExpander` clone is a third case: its sole declared
614
+ output is always a never-written ``FileArtifact`` port placeholder,
615
+ regardless of what the real inner producer's output kind is. Its
616
+ own ``_run`` re-points that real producer at wherever the
617
+ placeholder got pinned (see ``SubworkflowExpander._pin_out_port``),
618
+ so it is pinned straight at *slot_dir* like a folder output,
619
+ leaving that re-pointing free to hand the real producer's own
620
+ declared filename onward.
621
+ """
597
622
  if len(clone.outputs) != 1:
598
623
  raise MapConfigurationError(
599
624
  _(
@@ -602,7 +627,20 @@ class MapExpander(HorusTask):
602
627
  )
603
628
  % {"id": self.id}
604
629
  )
605
- self._pin_path(clone.outputs[0], path)
630
+ # Imported here, as in BaseWorkflow.sub, to avoid a
631
+ # base.py <-> map.py <-> expander.py cycle at module load time.
632
+ from horus_builtin.workflow.subworkflow.expander import ( # noqa: PLC0415
633
+ SubworkflowExpander,
634
+ )
635
+
636
+ artifact = clone.outputs[0]
637
+ if isinstance(artifact, FolderArtifact) or isinstance(
638
+ clone, SubworkflowExpander
639
+ ):
640
+ self._pin_path(artifact, slot_dir)
641
+ else:
642
+ await orchestrator.mkdir(str(slot_dir))
643
+ self._pin_path(artifact, slot_dir / artifact.path.name)
606
644
 
607
645
  @staticmethod
608
646
  def _clone_output_id(clone: BaseTask) -> str:
@@ -665,9 +703,30 @@ def lower_map_entry(
665
703
  ``(expander_dict, edges)`` — a ``kind: map_expander`` task dict
666
704
  ready for ``BaseTask.model_validate``, and any edges (as plain
667
705
  dicts) that must accompany it into the workflow's ``edges`` list.
706
+
707
+ Raises:
708
+ MapConfigurationError: If *entry* is missing the ``id`` key, or its
709
+ ``map`` block is missing ``template`` or ``gather``, or
710
+ ``gather`` is missing ``task`` or ``input``.
668
711
  """
669
- task_id = entry["id"]
712
+ task_id = entry.get("id")
713
+ if task_id is None:
714
+ raise MapConfigurationError(
715
+ _("A task with a 'map' block is missing required key 'id'.")
716
+ )
670
717
  map_block = entry["map"]
718
+
719
+ if "template" not in map_block:
720
+ raise MapConfigurationError(
721
+ _("Map task '%(id)s' is missing required key 'map.template'.")
722
+ % {"id": task_id}
723
+ )
724
+ if "gather" not in map_block:
725
+ raise MapConfigurationError(
726
+ _("Map task '%(id)s' is missing required key 'map.gather'.")
727
+ % {"id": task_id}
728
+ )
729
+
671
730
  over_block = map_block.get("over") or {}
672
731
  range_value = map_block.get("range")
673
732
  index_input = map_block.get("index_input", over_block.get("index_input"))
@@ -681,6 +740,19 @@ def lower_map_entry(
681
740
  }
682
741
 
683
742
  gather_block = map_block["gather"]
743
+ if not isinstance(gather_block, dict) or "task" not in gather_block:
744
+ raise MapConfigurationError(
745
+ _("Map task '%(id)s' 'map.gather' is missing required key 'task'.")
746
+ % {"id": task_id}
747
+ )
748
+ if "input" not in gather_block:
749
+ raise MapConfigurationError(
750
+ _(
751
+ "Map task '%(id)s' 'map.gather' is missing required "
752
+ "key 'input'."
753
+ )
754
+ % {"id": task_id}
755
+ )
684
756
 
685
757
  expander: dict[str, Any] = {
686
758
  "kind": "map_expander",
@@ -182,8 +182,21 @@ async def _execute_ready_task(
182
182
  await workflow.transfer_artifacts(task, source_map)
183
183
 
184
184
  # Execute the task on its target and wait for it to finish.
185
- await target.dispatch(task)
186
- await target.wait()
185
+ try:
186
+ await target.dispatch(task)
187
+ await target.wait()
188
+ except asyncio.CancelledError:
189
+ # Cancelling this wrapper also cancels the inner task future
190
+ # (cancellation propagates through `target.wait()`'s await),
191
+ # so by now the task has recorded CANCELED and the future is
192
+ # done; `target.cancel()` would early-return. Call the
193
+ # executor's kill hook directly so any external process
194
+ # (container, remote job) that does not die with the
195
+ # coroutine is terminated. Shielded so a second cancel
196
+ # cannot abort the kill mid-flight.
197
+ if task.status is TaskStatus.CANCELED:
198
+ await asyncio.shield(task.executor.cancel_execution())
199
+ raise
187
200
  finally:
188
201
  task.target = declared_target
189
202
  pool.release(declared_target, target)
@@ -379,9 +392,18 @@ async def run_schedule(workflow: BaseWorkflow, trigger_id: str) -> None:
379
392
  )
380
393
  running[wrapper] = task_id
381
394
 
382
- done, _pending = await asyncio.wait(
383
- running, return_when=asyncio.FIRST_COMPLETED
384
- )
395
+ try:
396
+ done, _pending = await asyncio.wait(
397
+ running, return_when=asyncio.FIRST_COMPLETED
398
+ )
399
+ except asyncio.CancelledError:
400
+ # Workflow-level cancellation: `asyncio.wait` does not cancel the
401
+ # tasks it waits on, so propagate the cancel to every in-flight
402
+ # wrapper (each one kills its executor via `target.cancel()`, see
403
+ # `_execute_ready_task`) and wait for them to unwind before
404
+ # re-raising.
405
+ await _cancel_running(running)
406
+ raise
385
407
 
386
408
  failures = _collect_completions(done, running, completed)
387
409
  if failures and workflow.failure_policy == "fail_fast":
@@ -252,7 +252,7 @@ class SubworkflowExpander(HorusTask):
252
252
  )
253
253
  )
254
254
 
255
- edges.extend(self._out_port_edges(wf, out_ports))
255
+ edges.extend(self._out_port_edges(wf, out_ports, by_id))
256
256
 
257
257
  wf.expand(tasks=tasks, edges=edges, artifacts=artifacts)
258
258
  horus_logger.log.debug(
@@ -442,7 +442,10 @@ class SubworkflowExpander(HorusTask):
442
442
  )
443
443
 
444
444
  def _out_port_edges(
445
- self, wf: BaseWorkflow, out_ports: list[SubworkflowPort]
445
+ self,
446
+ wf: BaseWorkflow,
447
+ out_ports: list[SubworkflowPort],
448
+ by_id: dict[str, BaseTask],
446
449
  ) -> list[WorkflowEdge]:
447
450
  """
448
451
  Re-emit every parent edge sourcing an out-port from the real inner
@@ -451,12 +454,16 @@ class SubworkflowExpander(HorusTask):
451
454
  """
452
455
  edges: list[WorkflowEdge] = []
453
456
  for port in out_ports:
454
- if port.task is None or _is_pinned(self.outputs, port.name):
457
+ if port.task is None:
458
+ continue
459
+ if _is_pinned(self.outputs, port.name):
455
460
  # An enclosing construct (a MapExpander materializing this
456
- # clone's slot) has taken the port over and consumes the
457
- # placeholder directly. Known gap: fan-in of a mapped
458
- # subworkflow's results therefore sees the placeholder
459
- # rather than the inner producer's real output.
461
+ # clone's slot) has taken the port over and pinned the
462
+ # placeholder to the slot's absolute path. Point the real
463
+ # inner producer's output at that same path instead, so
464
+ # the slot receives the clone's actual result rather than
465
+ # the never-written placeholder.
466
+ self._pin_out_port(by_id, port)
460
467
  continue
461
468
  for edge in wf.edges:
462
469
  if edge.source != self.id or edge.source_output != port.name:
@@ -487,6 +494,33 @@ class SubworkflowExpander(HorusTask):
487
494
  )
488
495
  return edges
489
496
 
497
+ def _pin_out_port(
498
+ self, by_id: dict[str, BaseTask], port: SubworkflowPort
499
+ ) -> None:
500
+ """
501
+ Point the real inner producer's output artifact at the absolute
502
+ path an enclosing construct pinned *port*'s placeholder to.
503
+
504
+ Mirrors the "pinned" branch of :meth:`_resolve_in_port`: instead of
505
+ the placeholder, the actual producer now writes directly to the
506
+ slot the enclosing construct materialized, so it is filled with
507
+ the real result rather than staying empty.
508
+ """
509
+ assert port.task is not None
510
+ producer = by_id.get(self._prefixed(port.task))
511
+ if producer is None:
512
+ return
513
+ artifact = next(
514
+ (a for a in producer.outputs if a.id == port.artifact), None
515
+ )
516
+ placeholder = next(
517
+ (a for a in self.outputs if a.id == port.name), None
518
+ )
519
+ if artifact is None or placeholder is None:
520
+ return
521
+ assert placeholder.declared_path is not None
522
+ _pin(artifact, placeholder.declared_path)
523
+
490
524
 
491
525
  def _is_pinned(artifacts: list[BaseArtifact], port_name: str) -> bool:
492
526
  """Whether *port_name*'s placeholder was pinned to an absolute path."""
@@ -21,6 +21,9 @@ YAML sugar lowering for the subworkflow construct.
21
21
 
22
22
  from typing import Any
23
23
 
24
+ from horus_builtin.workflow.subworkflow.errors import SubworkflowError
25
+ from horus_runtime.i18n import tr as _
26
+
24
27
 
25
28
  def lower_subworkflow_entry(entry: dict[str, Any]) -> dict[str, Any]:
26
29
  """
@@ -37,8 +40,20 @@ def lower_subworkflow_entry(entry: dict[str, Any]) -> dict[str, Any]:
37
40
  Returns:
38
41
  A ``kind: subworkflow`` task dict ready for
39
42
  ``BaseTask.model_validate``.
43
+
44
+ Raises:
45
+ SubworkflowError: If *entry* is missing the ``id`` or ``sub`` key.
40
46
  """
41
- task_id = entry["id"]
47
+ task_id = entry.get("id")
48
+ if task_id is None:
49
+ raise SubworkflowError(
50
+ _("A task with a 'sub' block is missing required key 'id'.")
51
+ )
52
+ if "sub" not in entry:
53
+ raise SubworkflowError(
54
+ _("Subworkflow task '%(id)s' is missing required key 'sub'.")
55
+ % {"id": task_id}
56
+ )
42
57
  data: dict[str, Any] = {
43
58
  "kind": "subworkflow",
44
59
  "id": task_id,
@@ -253,12 +253,11 @@ class BaseWorkflow(AutoRegistry, entry_point="workflow"):
253
253
  _revision: int = PrivateAttr(default=0)
254
254
  """
255
255
  Monotonically increasing counter for the workflow's DAG structure
256
- (tasks/edges/artifacts). Nothing bumps it yet: today the DAG is fixed for
257
- the lifetime of a run, so it is built once and stays at ``0``. It exists
258
- so a future dynamic-DAG feature (fan-out/map/loops) can increment it when
259
- mutating the graph mid-run, and the scheduler's cached source map (see
260
- :meth:`cached_source_map`) picks up the change without any changes to the
261
- scheduler itself.
256
+ (tasks/edges/artifacts). Bumped by :meth:`add_task`, :meth:`add_edge`,
257
+ :meth:`add_artifact` and :meth:`expand` whenever the graph mutates
258
+ mid-run (e.g. a dynamic fan-out/map/loop expansion), so the scheduler's
259
+ cached source map (see :meth:`cached_source_map`) picks up the change
260
+ without any changes to the scheduler itself.
262
261
  """
263
262
 
264
263
  @model_validator(mode="before")
@@ -27,8 +27,15 @@ if TYPE_CHECKING:
27
27
  from horus_runtime.core.workflow.base import BaseWorkflow
28
28
 
29
29
 
30
- class WorkflowError(Exception):
31
- """Base exception for workflow-related errors."""
30
+ class WorkflowError(ValueError):
31
+ """
32
+ Base exception for workflow-related errors.
33
+
34
+ Derives from :class:`ValueError` so that any subclass raised inside a
35
+ Pydantic validator (e.g. ``BaseWorkflow``'s ``model_validator`` hooks)
36
+ is automatically wrapped into a :class:`pydantic.ValidationError` by
37
+ Pydantic, instead of escaping as a raw, unwrapped exception.
38
+ """
32
39
 
33
40
 
34
41
  class OneWorkflowAtATimeError(WorkflowError):
@@ -40,9 +40,13 @@ the comments and the executor anchor that these files are written around.
40
40
  """
41
41
 
42
42
  import re
43
- from dataclasses import dataclass
43
+ from dataclasses import dataclass, replace
44
44
  from pathlib import Path
45
45
 
46
+ import yaml
47
+
48
+ from horus_builtin.workflow.map import MapExpander
49
+ from horus_runtime.core.artifact.base import BaseArtifact
46
50
  from horus_runtime.core.workflow.base import BaseWorkflow
47
51
 
48
52
 
@@ -55,17 +59,31 @@ class RootInput:
55
59
  """An unwired task input, and the root artifact it would be promoted to."""
56
60
 
57
61
  root_id: str
58
- """Id for the new root artifact."""
59
-
60
- kind: str
61
- """Artifact kind, copied from the task input."""
62
+ """Id for the new root artifact; differs from the input's own id only
63
+ when that id is already taken (see :func:`_root_id`)."""
62
64
 
63
- path: Path
64
- """Workflow-relative path, exactly as declared."""
65
+ artifact: BaseArtifact
66
+ """
67
+ The task input being promoted, kept whole rather than field-by-field so
68
+ every declared field (name, description, and whatever a plugin artifact
69
+ kind adds) travels to the root artifact without this module knowing it.
70
+ """
65
71
 
66
72
  consumers: tuple[tuple[str, str], ...]
67
73
  """``(task_id, input_id)`` pairs to wire, one edge each."""
68
74
 
75
+ @property
76
+ def path(self) -> Path:
77
+ """Workflow-relative path, exactly as declared."""
78
+ declared = self.artifact.declared_path
79
+ assert declared is not None # only set for inputs with a declared path
80
+ return declared
81
+
82
+ @property
83
+ def kind(self) -> str:
84
+ """Artifact kind, as declared on the task input."""
85
+ return self.artifact.kind
86
+
69
87
 
70
88
  @dataclass(frozen=True)
71
89
  class MissingEdge:
@@ -119,6 +137,14 @@ def find_root_inputs(
119
137
  for edge in workflow.edges
120
138
  if edge.target_input is not None
121
139
  }
140
+ # A map's fan-in input is wired by MapExpander at run time, not by a
141
+ # static edge, so it would otherwise look like an author-supplied root
142
+ # input and get promoted to one.
143
+ wired |= {
144
+ (task.gather_task, task.gather_input)
145
+ for task in workflow.tasks
146
+ if isinstance(task, MapExpander)
147
+ }
122
148
  # Reuses the runtime's own produced-vs-external rule rather than
123
149
  # restating it here, so sanitizing can never drift from packaging.
124
150
  produced = workflow._produced_declared_paths() # noqa: SLF001
@@ -151,13 +177,12 @@ def find_root_inputs(
151
177
  )
152
178
  continue
153
179
  # One file consumed by several tasks travels as one root
154
- # artifact with one edge per consumer.
180
+ # artifact with one edge per consumer; the first consumer's
181
+ # declaration is the one promoted.
155
182
  existing = by_path.get(declared)
156
183
  if existing is not None:
157
- by_path[declared] = RootInput(
158
- root_id=existing.root_id,
159
- kind=existing.kind,
160
- path=existing.path,
184
+ by_path[declared] = replace(
185
+ existing,
161
186
  consumers=(*existing.consumers, (task.id, artifact.id)),
162
187
  )
163
188
  continue
@@ -165,25 +190,41 @@ def find_root_inputs(
165
190
  taken.add(root_id)
166
191
  by_path[declared] = RootInput(
167
192
  root_id=root_id,
168
- kind=artifact.kind,
169
- path=declared,
193
+ artifact=artifact,
170
194
  consumers=((task.id, artifact.id),),
171
195
  )
172
196
 
173
197
  return sorted(by_path.values(), key=lambda r: r.path), missing
174
198
 
175
199
 
200
+ def _artifact_block(root: RootInput) -> list[str]:
201
+ """Render one promoted artifact, with every field the author declared."""
202
+ # exclude_defaults keeps the block to what the author actually wrote. id
203
+ # and path are then overridden: the model holds the artifact's own id
204
+ # (the root may have been renamed to avoid a clash) and a CWD-resolved
205
+ # absolute path (see BaseArtifact.resolve_path), neither of which belongs
206
+ # in a portable workflow file.
207
+ data = root.artifact.model_dump(mode="json", exclude_defaults=True)
208
+ data["id"] = root.root_id
209
+ data["path"] = root.path.as_posix()
210
+ data.setdefault("kind", root.kind) # kind is a default on every subclass
211
+ # default_name assigns name = id after validation, so an unset name
212
+ # survives exclude_defaults as an echo of the id, carrying nothing the
213
+ # id doesn't already say.
214
+ if data.get("name") == root.artifact.id:
215
+ data.pop("name")
216
+ block = yaml.safe_dump([data], sort_keys=False, allow_unicode=True)
217
+ # Blank lines occur inside a quoted multi-line scalar, where they are the
218
+ # line break itself; indenting them would only add trailing whitespace.
219
+ return [f" {line}" if line else "" for line in block.splitlines()] + [""]
220
+
221
+
176
222
  def _render(root_inputs: list[RootInput]) -> tuple[list[str], list[str]]:
177
223
  """Render the ``artifacts:`` entries and ``edges:`` entries to insert."""
178
224
  artifacts: list[str] = []
179
225
  edges: list[str] = []
180
226
  for root in root_inputs:
181
- artifacts += [
182
- f" - id: {root.root_id}",
183
- f" kind: {root.kind}",
184
- f" path: {root.path.as_posix()}",
185
- "",
186
- ]
227
+ artifacts += _artifact_block(root)
187
228
  for task_id, input_id in root.consumers:
188
229
  edges += [
189
230
  f" - source: artifact-{root.root_id}",
@@ -228,22 +269,23 @@ def apply_promotions(text: str, root_inputs: list[RootInput]) -> str:
228
269
  artifact_lines, edge_lines = _render(root_inputs)
229
270
  keys = _top_level_keys(lines)
230
271
 
231
- if "edges" not in keys:
232
- raise SanitizeError(
233
- "Workflow has no top-level 'edges:' block to extend."
234
- )
235
-
236
272
  # Insert the later block first so the earlier block's index stays valid.
237
- edges_at = _block_end(lines, keys["edges"], keys)
238
- lines[edges_at:edges_at] = ["", *edge_lines] if edge_lines else []
273
+ if "edges" in keys:
274
+ edges_at = _block_end(lines, keys["edges"], keys)
275
+ lines[edges_at:edges_at] = ["", *edge_lines] if edge_lines else []
276
+ else:
277
+ # A map:-only workflow has no top-level edges: block of its own (the
278
+ # map block describes all its wiring); create one at the end.
279
+ lines += ["", "edges:", *edge_lines]
239
280
 
240
281
  if "artifacts" in keys:
241
282
  at = _block_end(lines, keys["artifacts"], keys)
242
283
  lines[at:at] = artifact_lines
243
284
  else:
244
285
  # Root inputs read best above the tasks that consume them; fall back
245
- # to the edges block for a workflow with no tasks key of its own.
246
- anchor = keys.get("tasks", keys["edges"])
286
+ # to the edges block (just created above if it didn't already
287
+ # exist) for a workflow with no tasks key of its own.
288
+ anchor = keys.get("tasks", keys.get("edges", len(lines)))
247
289
  lines[anchor:anchor] = ["artifacts:", *artifact_lines]
248
290
 
249
291
  return "\n".join(lines) + "\n"
@@ -34,6 +34,7 @@ from horus_runtime.core.interaction.transport import (
34
34
  )
35
35
  from horus_runtime.core.task.base import BaseTask
36
36
  from horus_runtime.core.task.status import TaskStatus
37
+ from horus_runtime.core.workflow.edge import WorkflowEdge
37
38
  from horus_runtime.event.subscriber import BaseEventSubscriber
38
39
 
39
40
 
@@ -116,6 +117,37 @@ class TestWorkflowTUISubscriber:
116
117
  tui.handle(answered)
117
118
  assert tui._paused is False
118
119
 
120
+ def test_progress_total_follows_a_growing_dag(self) -> None:
121
+ """
122
+ Tasks added after ``track()`` count towards the total.
123
+
124
+ A ``map:``/``sub:`` expander only adds its clones once it runs, so a
125
+ scope planned before the run reported ``1/1 tasks`` for a five-clone
126
+ loop map.
127
+ """
128
+ wf = _workflow()
129
+ wf.edges.append(WorkflowEdge(source="a", target="b"))
130
+ tui = WorkflowTUISubscriber()
131
+ tui.track(wf, trigger_id="a")
132
+
133
+ console = Console(record=True, width=120)
134
+ console.print(tui.render())
135
+ assert "0/2 " in console.export_text()
136
+
137
+ wf.tasks.append(
138
+ HorusTask(
139
+ id="c",
140
+ name="Gamma",
141
+ runtime=CommandRuntime(command="true"),
142
+ executor=ShellExecutor(),
143
+ )
144
+ )
145
+ wf.edges.append(WorkflowEdge(source="b", target="c"))
146
+
147
+ console = Console(record=True, width=120)
148
+ console.print(tui.render())
149
+ assert "0/3 " in console.export_text()
150
+
119
151
  def test_setup_is_noop(self) -> None:
120
152
  """setup() is a no-op and must not raise."""
121
153
  WorkflowTUISubscriber().setup()