labnotes 0.2.17__tar.gz → 0.2.19__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 (234) hide show
  1. {labnotes-0.2.17 → labnotes-0.2.19}/PKG-INFO +1 -1
  2. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/server.py +2 -0
  3. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/experiments.py +79 -16
  4. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/models.py +2 -0
  5. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/retries.py +1 -1
  6. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/describe_event.py +4 -0
  7. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/prepare_experiment.py +6 -0
  8. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_experiment.py +18 -8
  9. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/start_experiment.py +12 -1
  10. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/list_resumable_runs.py +6 -1
  11. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/leave_prompt.py +18 -0
  12. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/run_app.py +129 -8
  13. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/run_tui.py +9 -2
  14. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/runs_browser.py +14 -20
  15. {labnotes-0.2.17 → labnotes-0.2.19}/.gitignore +0 -0
  16. {labnotes-0.2.17 → labnotes-0.2.19}/CHANGELOG.md +0 -0
  17. {labnotes-0.2.17 → labnotes-0.2.19}/MIGRATIONS.md +0 -0
  18. {labnotes-0.2.17 → labnotes-0.2.19}/README.md +0 -0
  19. {labnotes-0.2.17 → labnotes-0.2.19}/conftest.py +0 -0
  20. {labnotes-0.2.17 → labnotes-0.2.19}/pyproject.toml +0 -0
  21. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/__main__.py +0 -0
  23. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/command_line_group.py +0 -0
  27. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/compose_experiment.py +0 -0
  28. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/config_migration.py +0 -0
  29. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/context.md +0 -0
  30. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/context_guide.py +0 -0
  31. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/copy_experiment.py +0 -0
  32. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/count_groups.py +0 -0
  33. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/count_runs.py +0 -0
  34. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/create_app.py +0 -0
  35. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/create_experiment.py +0 -0
  36. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/create_note.py +0 -0
  37. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/__init__.py +0 -0
  38. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/client.py +0 -0
  39. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/command.py +0 -0
  40. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/ensure.py +0 -0
  41. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/marker.py +0 -0
  42. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon/socket_path.py +0 -0
  43. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/daemon_command.py +0 -0
  44. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/data_html.py +0 -0
  45. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/default_config_text.py +0 -0
  46. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/default_counts.py +0 -0
  47. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/drain_watch.py +0 -0
  48. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/experiment_definition.py +0 -0
  49. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/experiment_notes.py +0 -0
  50. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/experiment_runs.py +0 -0
  51. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/file_changes.py +0 -0
  52. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/format_duration.py +0 -0
  53. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/format_started.py +0 -0
  54. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/format_value.py +0 -0
  55. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/get_state.py +0 -0
  56. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/grid_cell.py +0 -0
  57. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/group_runs.py +0 -0
  58. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/init.py +0 -0
  59. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/invocation_started.py +0 -0
  60. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/is_non_negative_int.py +0 -0
  61. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/is_non_negative_number.py +0 -0
  62. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/is_positive_int.py +0 -0
  63. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/latest_log.py +0 -0
  64. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/list_experiments.py +0 -0
  65. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/list_runs.py +0 -0
  66. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/load_config.py +0 -0
  67. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/load_experiment.py +0 -0
  68. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/load_run.py +0 -0
  69. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/log.py +0 -0
  70. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/log_path.py +0 -0
  71. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/memory_interval.py +0 -0
  72. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/nests_runs.py +0 -0
  73. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/note_path.py +0 -0
  74. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/notebook_kernel.py +0 -0
  75. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/output_html.py +0 -0
  76. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/param_names.py +0 -0
  77. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/parse_param_value.py +0 -0
  78. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/parse_params.py +0 -0
  79. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/parse_run_view.py +0 -0
  80. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/parse_subset.py +0 -0
  81. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/pivot_runs.py +0 -0
  82. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/print_rows.py +0 -0
  83. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/python_blocks.py +0 -0
  84. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/read_config.py +0 -0
  85. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/read_path.py +0 -0
  86. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/render_markdown.py +0 -0
  87. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/run_columns.py +0 -0
  88. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/run_output.py +0 -0
  89. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/__init__.py +0 -0
  90. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/cgroup_memory.py +0 -0
  91. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/check_args.py +0 -0
  92. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/check_hook_placeholders.py +0 -0
  93. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/check_subset.py +0 -0
  94. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/claim_run.py +0 -0
  95. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/command_flags.py +0 -0
  96. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/container_cgroup.py +0 -0
  97. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/container_memory.py +0 -0
  98. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/continue_run.py +0 -0
  99. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/docker_output.py +0 -0
  100. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/execute_run.py +0 -0
  101. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/exit_signal.py +0 -0
  102. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/fill_dirs.py +0 -0
  103. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/fill_experiment_env.py +0 -0
  104. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/finalize_lost_runs.py +0 -0
  105. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/first_invocation.py +0 -0
  106. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/flag_name.py +0 -0
  107. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/group_args.py +0 -0
  108. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/hold_experiment.py +0 -0
  109. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/in_subset.py +0 -0
  110. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/invocation_hook.py +0 -0
  111. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/invocation_meta.py +0 -0
  112. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/new_run_id.py +0 -0
  113. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  114. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/override_flags.py +0 -0
  115. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/param_args.py +0 -0
  116. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/param_grid.py +0 -0
  117. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/placeholders.py +0 -0
  118. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/plan_runs.py +0 -0
  119. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/project_snapshot.py +0 -0
  120. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/read_experiment_config.py +0 -0
  121. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/record_memory.py +0 -0
  122. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_folder.py +0 -0
  123. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_git.py +0 -0
  124. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_headless.py +0 -0
  125. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_locked.py +0 -0
  126. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/run_state.py +0 -0
  127. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/runs_parent.py +0 -0
  128. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/shell_command.py +0 -0
  129. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/signal_tree.py +0 -0
  130. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/stop_then_kill.py +0 -0
  131. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/untracked_diff.py +0 -0
  132. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/wait_for_tree.py +0 -0
  133. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runner/write_run_json.py +0 -0
  134. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/runs_view_command.py +0 -0
  135. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/save_conflict_copy.py +0 -0
  136. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/save_note.py +0 -0
  137. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/saved_outputs.py +0 -0
  138. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/serve_app.py +0 -0
  139. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/server.py +0 -0
  140. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/set_state.py +0 -0
  141. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/shared_params.py +0 -0
  142. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/sort_href.py +0 -0
  143. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/sql_quote.py +0 -0
  144. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/state_path.py +0 -0
  145. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/elapsed.js +0 -0
  146. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/experiment.js +0 -0
  147. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/labnotes.css +0 -0
  148. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/local-time.js +0 -0
  149. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/new-note.js +0 -0
  150. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/note-edit.js +0 -0
  151. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/note-editor.js +0 -0
  152. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/row-link.js +0 -0
  153. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/run-cell.js +0 -0
  154. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/run-output.js +0 -0
  155. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/static/view-toggle.js +0 -0
  156. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/summarize_config.py +0 -0
  157. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/__init__.py +0 -0
  158. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/experiment_rows.py +0 -0
  159. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/experiment_run_ids.py +0 -0
  160. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/experiment_toml.py +0 -0
  161. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/experiments_ddl.py +0 -0
  162. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/file_prefix.py +0 -0
  163. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/file_text.py +0 -0
  164. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/iso_time.py +0 -0
  165. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/list_experiment_runs.py +0 -0
  166. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/note_text.py +0 -0
  167. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/open_tables.py +0 -0
  168. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/path_glob.py +0 -0
  169. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/path_pattern.py +0 -0
  170. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_blocks.py +0 -0
  171. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_experiment.py +0 -0
  172. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_file.py +0 -0
  173. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_log.py +0 -0
  174. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_memory.py +0 -0
  175. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_run.py +0 -0
  176. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/read_run_file.py +0 -0
  177. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/runs_columns.py +0 -0
  178. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tables/summarize_experiments.py +0 -0
  179. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/base.html +0 -0
  180. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/editor_imports.html +0 -0
  181. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/experiment.html +0 -0
  182. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/index.html +0 -0
  183. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/note.html +0 -0
  184. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/templates/run.html +0 -0
  185. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/__init__.py +0 -0
  186. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/browse_app.py +0 -0
  187. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/browse_tui.py +0 -0
  188. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/check_view_response.py +0 -0
  189. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/config.py +0 -0
  190. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/dim.py +0 -0
  191. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/experiment_screen.py +0 -0
  192. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/experiments_screen.py +0 -0
  193. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/fill_table.py +0 -0
  194. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/format_cell.py +0 -0
  195. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/format_elapsed.py +0 -0
  196. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/guard_view.py +0 -0
  197. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/load_view.py +0 -0
  198. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/log_block.py +0 -0
  199. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/log_blocks.py +0 -0
  200. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/native_rows.py +0 -0
  201. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/reload_on_change.py +0 -0
  202. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/run_cells.py +0 -0
  203. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/run_output.py +0 -0
  204. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/run_panel.py +0 -0
  205. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/runs_table.py +0 -0
  206. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/runs_view.py +0 -0
  207. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/stopped_title.py +0 -0
  208. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/table_columns.py +0 -0
  209. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/table_rows.py +0 -0
  210. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/view_column.py +0 -0
  211. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/view_pane.py +0 -0
  212. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/view_process.py +0 -0
  213. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/tui/view_switcher.py +0 -0
  214. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/__init__.py +0 -0
  215. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/format_bytes.py +0 -0
  216. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/process_alive.py +0 -0
  217. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/run_usage.py +0 -0
  218. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/tree_rss.py +0 -0
  219. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/utils/tree_usage.py +0 -0
  220. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/__init__.py +0 -0
  221. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/answer.py +0 -0
  222. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/build.py +0 -0
  223. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/changed.py +0 -0
  224. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/experiment.py +0 -0
  225. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/respond.py +0 -0
  226. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view/serve.py +0 -0
  227. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/view_specs.py +0 -0
  228. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/wait_for_note.py +0 -0
  229. {labnotes-0.2.17 → labnotes-0.2.19}/src/labnotes/watch_rows.py +0 -0
  230. {labnotes-0.2.17 → labnotes-0.2.19}/testing-conventions.toml +0 -0
  231. {labnotes-0.2.17 → labnotes-0.2.19}/tests/conftest.py +0 -0
  232. {labnotes-0.2.17 → labnotes-0.2.19}/tests/e2e/conftest.py +0 -0
  233. {labnotes-0.2.17 → labnotes-0.2.19}/tests/integration/conftest.py +0 -0
  234. {labnotes-0.2.17 → labnotes-0.2.19}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.2.17
3
+ Version: 0.2.19
4
4
  Summary: Browse experiment designs, notes, and run data from a folder.
5
5
  Project-URL: Repository, https://github.com/thekevinscott/labnotes
6
6
  Author-email: Kevin Scott <me@thekevinscott.com>
@@ -61,6 +61,8 @@ class ExperimentDaemon:
61
61
  cast(str | None, options.get("teardown")),
62
62
  cast(str | None, options.get("command_line")),
63
63
  cast(str | None, options.get("run_id")),
64
+ cast(int | None, options.get("retries")),
65
+ bool(options.get("replace_capped")),
64
66
  )
65
67
  path = str(selected.run_state.path)
66
68
  invocation = Invocation()
@@ -7,7 +7,9 @@ import click
7
7
  from labnotes.command_line_group import COMMAND_LINE
8
8
  from labnotes.create_experiment import create_experiment
9
9
  from labnotes.daemon.command import command as daemon_command
10
+ from labnotes.daemon.ensure import ensure_daemon
10
11
  from labnotes.runner.start_experiment import start_experiment
12
+ from labnotes.runner.run_state import RunState
11
13
  from labnotes.utils.format_bytes import format_bytes
12
14
  from labnotes.load_config import load_config
13
15
  from labnotes.parse_params import parse_params
@@ -58,7 +60,23 @@ class PassthroughCommand(click.Command):
58
60
  help="With run: how many runs at once. Overrides experiment.toml and"
59
61
  " .labnotes.toml; 1 if none sets it.",
60
62
  )
61
- @click.option("--run-id", help="With run: restart only this failed run by id.")
63
+ @click.option(
64
+ "--run-id",
65
+ help="With run: restart only this failed run by id, even if out of retries.",
66
+ )
67
+ @click.option(
68
+ "--retries",
69
+ type=click.IntRange(min=0),
70
+ help="With run: how many times a failed run is tried again, on exit 75 or,"
71
+ " with continues, by a later run. Overrides experiment.toml and"
72
+ " .labnotes.toml; 3 if none sets it.",
73
+ )
74
+ @click.option(
75
+ "--replace-capped",
76
+ is_flag=True,
77
+ help="With run: start a fresh run for a cell whose run is out of retries."
78
+ " Unset, that cell stays short.",
79
+ )
62
80
  @click.option(
63
81
  "--headless",
64
82
  is_flag=True,
@@ -122,6 +140,8 @@ def experiments(
122
140
  output_format: str | None,
123
141
  concurrency: int | None,
124
142
  run_id: str | None,
143
+ retries: int | None,
144
+ replace_capped: bool,
125
145
  headless: bool,
126
146
  experiment_config: Path | None,
127
147
  timeout: int | None,
@@ -169,24 +189,61 @@ def experiments(
169
189
  only = parse_subset(ctx.args)
170
190
  except ValueError as error:
171
191
  raise click.UsageError(str(error)) from error
172
- start = start_experiment(
173
- config,
174
- name,
175
- experiment_config,
176
- concurrency,
177
- timeout,
178
- only,
179
- passthrough or None,
180
- setup,
181
- teardown,
182
- ctx.meta.get(COMMAND_LINE),
183
- run_id,
184
- )
185
192
  try:
186
193
  if not headless and sys.stdout.isatty() and sys.stderr.isatty():
187
- results = run_tui(start, name, config, experiment_config)
194
+ asyncio.run(ensure_daemon(config, name))
195
+ reply = daemon_command(
196
+ config,
197
+ name,
198
+ {
199
+ "command": "restart" if run_id else "start",
200
+ "options": {
201
+ "experiment_config": str(experiment_config)
202
+ if experiment_config
203
+ else None,
204
+ "concurrency": concurrency,
205
+ "timeout": timeout,
206
+ "only": only,
207
+ "args": passthrough or None,
208
+ "setup": setup,
209
+ "teardown": teardown,
210
+ "command_line": ctx.meta.get(COMMAND_LINE),
211
+ "run_id": run_id,
212
+ "retries": retries,
213
+ "replace_capped": replace_capped,
214
+ },
215
+ },
216
+ )
217
+ if not reply.get("ok"):
218
+ raise ValueError(
219
+ str(reply.get("error", "Daemon rejected start"))
220
+ )
221
+ run_tui(
222
+ None,
223
+ name,
224
+ config,
225
+ experiment_config,
226
+ daemon_journal=RunState(Path(str(reply["journal"]))),
227
+ )
228
+ return
188
229
  else:
189
- results = run_headless(start)
230
+ results = run_headless(
231
+ start_experiment(
232
+ config,
233
+ name,
234
+ experiment_config,
235
+ concurrency,
236
+ timeout,
237
+ only,
238
+ passthrough or None,
239
+ setup,
240
+ teardown,
241
+ ctx.meta.get(COMMAND_LINE),
242
+ run_id,
243
+ retries,
244
+ replace_capped,
245
+ )
246
+ )
190
247
  except ValueError as error:
191
248
  raise click.ClickException(str(error)) from error
192
249
  superseded = {r["retry_of"] for r in results if "retry_of" in r}
@@ -204,6 +261,8 @@ def experiments(
204
261
  output_format is not None
205
262
  or concurrency is not None
206
263
  or run_id is not None
264
+ or retries is not None
265
+ or replace_capped
207
266
  or experiment_config is not None
208
267
  or timeout is not None
209
268
  or setup is not None
@@ -226,6 +285,10 @@ def experiments(
226
285
  raise click.UsageError("-- ARGS needs run.")
227
286
  if run_id is not None:
228
287
  raise click.UsageError("--run-id needs run.")
288
+ if retries is not None:
289
+ raise click.UsageError("--retries needs run.")
290
+ if replace_capped:
291
+ raise click.UsageError("--replace-capped needs run.")
229
292
  if ctx.args:
230
293
  raise click.UsageError(f"Unexpected {ctx.args[0]}. --PARAM=VALUE needs run.")
231
294
  if action not in (None, "runs"):
@@ -65,3 +65,5 @@ class ExperimentConfig:
65
65
  teardown: str | None = None
66
66
  env: dict[str, str] = field(default_factory=dict)
67
67
  continues: bool = False
68
+ # A run option, not a config key: lets a run that used up `retries` free its slot.
69
+ replace_capped: bool = False
@@ -1,4 +1,4 @@
1
1
  # EX_TEMPFAIL from sysexits.h: the only exit labnotes retries on its own.
2
2
  RETRY_EXIT_CODE = 75
3
- DEFAULT_RETRIES = 2
3
+ DEFAULT_RETRIES = 3
4
4
  RETRY_SECONDS = 30.0
@@ -33,6 +33,10 @@ def describe_event(event: dict[str, object]) -> str:
33
33
  case "gave up":
34
34
  tried = f"after {event['retries']} retries"
35
35
  return f"{event['run']} gave up (exit {RETRY_EXIT_CODE}) {tried}."
36
+ case "capped":
37
+ cells = len(event["runs"]) # type: ignore[arg-type]
38
+ noun = "cell" if cells == 1 else "cells"
39
+ return f"{cells} {noun} gave up after {event['retries']} retries."
36
40
  case "retry cancelled":
37
41
  return f"{event['run']} won't retry."
38
42
  case "idle":
@@ -17,6 +17,8 @@ async def prepare_experiment(
17
17
  args: list[str] | None = None,
18
18
  setup: str | None = None,
19
19
  teardown: str | None = None,
20
+ retries: int | None = None,
21
+ replace_capped: bool = False,
20
22
  ) -> tuple[ExperimentConfig, int]:
21
23
  if config.experiment not in (None, name):
22
24
  raise ValueError(
@@ -39,6 +41,10 @@ async def prepare_experiment(
39
41
  experiment = dataclasses.replace(experiment, setup=setup)
40
42
  if teardown is not None:
41
43
  experiment = dataclasses.replace(experiment, teardown=teardown)
44
+ if retries is not None:
45
+ experiment = dataclasses.replace(experiment, retries=retries)
46
+ if replace_capped:
47
+ experiment = dataclasses.replace(experiment, replace_capped=True)
42
48
  if only:
43
49
  check_subset(experiment.params, only)
44
50
  if args:
@@ -84,6 +84,17 @@ async def run_experiment(
84
84
  for r in counted
85
85
  if r["counted"] or str(r.get("id")) in held_ids
86
86
  ]
87
+ resumable: list[Entry] = []
88
+ capped: list[Entry] = []
89
+ tries: dict[str, int] = {}
90
+ if experiment.continues:
91
+ for row in await list_resumable_runs(tables, name, experiment.counts):
92
+ entry = (str(row["id"]), json.loads(str(row["params"])))
93
+ tries[entry[0]] = int(str(row["invocations"]))
94
+ over = tries[entry[0]] > experiment.retries
95
+ (capped if over else resumable).append(entry)
96
+ if not experiment.replace_capped:
97
+ finished += [params for _, params in capped]
87
98
  planned: set[str] = set()
88
99
 
89
100
  def taken(run: str) -> bool:
@@ -112,12 +123,6 @@ async def run_experiment(
112
123
  def release(run: str) -> None:
113
124
  os.close(locks.pop(run))
114
125
 
115
- resumable: list[Entry] = []
116
- if experiment.continues:
117
- resumable = [
118
- (str(row["id"]), json.loads(str(row["params"])))
119
- for row in await list_resumable_runs(tables, name, experiment.counts)
120
- ]
121
126
  plan: list[Entry] = []
122
127
  target_folder: Path | None = None
123
128
  if run_id is not None:
@@ -155,6 +160,8 @@ async def run_experiment(
155
160
  run = new_stamped_run_id(taken) if resumed is None else resumed[0]
156
161
  planned.add(run)
157
162
  plan.append((run, params))
163
+ # A resumed run's earlier invocations spend the same retries as exit 75 does.
164
+ earlier = {run: tries[run] for run, _ in plan if run in tries and run != run_id}
158
165
  hooks = {
159
166
  "setup": experiment.setup,
160
167
  "teardown": experiment.teardown,
@@ -180,6 +187,9 @@ async def run_experiment(
180
187
  )
181
188
  for row in held:
182
189
  emit({"event": "held", "run": str(row["id"])})
190
+ gave_up = [run for run, params in capped if in_subset(params, only or {})]
191
+ if gave_up and run_id is None and not experiment.replace_capped:
192
+ emit({"event": "capped", "runs": gave_up, "retries": experiment.retries})
183
193
  running: dict[str, set[asyncio.subprocess.Process]] = {}
184
194
  trees: dict[str, set[psutil.Process]] = {}
185
195
  stopped: set[str] = set()
@@ -240,11 +250,11 @@ async def run_experiment(
240
250
  fill()
241
251
 
242
252
  def retry_later(run: str) -> None:
243
- tried = attempts.get(run, 0)
253
+ tried = attempts.get(run, earlier.get(run, 0))
244
254
  if tried >= experiment.retries:
245
255
  emit({"event": "gave up", "run": run, "retries": tried})
246
256
  return
247
- seconds = retry_seconds * 2**tried
257
+ seconds = retry_seconds * 2 ** (tried - earlier.get(run, 0))
248
258
 
249
259
  def due() -> None:
250
260
  del waiting[run]
@@ -32,12 +32,23 @@ def start_experiment(
32
32
  teardown: str | None,
33
33
  command_line: str | None = None,
34
34
  run_id: str | None = None,
35
+ retries: int | None = None,
36
+ replace_capped: bool = False,
35
37
  ) -> ExperimentStart:
36
38
  state = RunState.create(config, name)
37
39
 
38
40
  async def start(report: Report, listen: Listen) -> list[dict[str, object]]:
39
41
  experiment, limit = await prepare_experiment(
40
- config, name, experiment_config, concurrency, only, args, setup, teardown
42
+ config,
43
+ name,
44
+ experiment_config,
45
+ concurrency,
46
+ only,
47
+ args,
48
+ setup,
49
+ teardown,
50
+ retries,
51
+ replace_capped,
41
52
  )
42
53
  await finalize_lost_runs(config)
43
54
  with project_snapshot(config.root) as snapshot:
@@ -8,8 +8,13 @@ async def list_resumable_runs(
8
8
  tables: DirSQL, name: str, counts: str
9
9
  ) -> list[dict[str, object]]:
10
10
  # Ended badly: a nonzero or missing exit code, not merely uncounted.
11
+ # Invocations stopped by hand don't spend retries.
11
12
  return await tables.query(
12
- f"SELECT id, params FROM runs WHERE experiment = {quote_string(name)}"
13
+ "SELECT id, params, CASE WHEN json_type(run, '$.invocations') = 'array'"
14
+ " THEN (SELECT count(*) FROM json_each(run, '$.invocations')"
15
+ " WHERE json_extract(value, '$.ended_by') IS NOT 'stop')"
16
+ " ELSE json_extract(run, '$.ended_by') IS NOT 'stop' END AS invocations"
17
+ f" FROM runs WHERE experiment = {quote_string(name)}"
13
18
  f" AND NOT coalesce(({counts}), false) AND NOT {UNFINISHED}"
14
19
  " AND (exit_code IS NULL OR exit_code <> 0)"
15
20
  " AND NOT coalesce(run ->> 'excluded', false) ORDER BY created, id"
@@ -25,3 +25,21 @@ class LeavePrompt(ModalScreen[str | None]):
25
25
  "Stop them (enter or s) / cancel (esc).",
26
26
  id="prompt",
27
27
  )
28
+
29
+
30
+ class DetachedLeavePrompt(LeavePrompt):
31
+ """Choose explicitly whether a detached daemon should keep its cells."""
32
+
33
+ BINDINGS = [
34
+ Binding("s", "dismiss('stop')", "Stop runs"),
35
+ Binding("enter,l", "dismiss('leave')", "Leave running"),
36
+ Binding("escape", "dismiss", "Cancel"),
37
+ ]
38
+
39
+ def compose(self) -> ComposeResult:
40
+ runs = "run" if self.count == 1 else "runs"
41
+ yield Static(
42
+ f"{self.count} {runs} still going. "
43
+ "Stop them (s) / leave running (enter or l) / cancel (esc).",
44
+ id="prompt",
45
+ )
@@ -1,4 +1,7 @@
1
+ import asyncio
2
+ import json
1
3
  import signal
4
+ import socket
2
5
  import time
3
6
  import tomllib
4
7
  from collections.abc import Mapping, Sequence
@@ -16,18 +19,21 @@ from textual.widget import Widget
16
19
  from textual.widgets import ContentSwitcher, DataTable, Footer, Header, Input, Static
17
20
 
18
21
  from labnotes.compose_experiment import compose_experiment
22
+ from labnotes.daemon.command import command as daemon_command
23
+ from labnotes.daemon.client import daemon_socket
19
24
  from labnotes.load_experiment import load_experiment
20
25
  from labnotes.models import Controls, LabnotesConfig, Start
21
26
  from labnotes.retries import RETRY_EXIT_CODE
22
27
  from labnotes.runner.describe_event import describe_event
23
28
  from labnotes.runner.param_args import param_args
24
29
  from labnotes.runner.start_experiment import ExperimentStart
30
+ from labnotes.runner.run_state import RunState
25
31
  from labnotes.runner.stop_then_kill import GRACE_SECONDS, stop_then_kill
26
32
  from labnotes.tables.open_tables import open_tables
27
33
  from labnotes.tui.config import RUN_COLUMNS
28
34
  from labnotes.tui.dim import dim
29
35
  from labnotes.tui.format_elapsed import format_elapsed
30
- from labnotes.tui.leave_prompt import LeavePrompt
36
+ from labnotes.tui.leave_prompt import DetachedLeavePrompt, LeavePrompt
31
37
  from labnotes.tui.log_blocks import LogBlocks
32
38
  from labnotes.tui.native_rows import native_rows
33
39
  from labnotes.tui.reload_on_change import reload_on_change
@@ -55,6 +61,7 @@ class RunLiveView(Widget):
55
61
  """
56
62
  BINDINGS = [
57
63
  Binding("x", "kill_run", "Stop run"),
64
+ Binding("r", "restart_run", "Restart"),
58
65
  Binding("slash", "filter", "Filter", key_display="/"),
59
66
  Binding("escape", "leave", "Back"),
60
67
  Binding("plus,equals_sign", "concurrency(1)", "More at once", key_display="+"),
@@ -294,6 +301,10 @@ class RunLiveView(Widget):
294
301
  def action_concurrency(self, change: int) -> None:
295
302
  self.controls.set_concurrency(self.concurrency + change)
296
303
 
304
+ def action_restart_run(self) -> None:
305
+ if (run := self.cursor_run()) is not None and isinstance(self.app, RunApp):
306
+ self.app.restart_run(run)
307
+
297
308
  def action_filter(self) -> None:
298
309
  field = self.query_one(Input)
299
310
  field.display = True
@@ -354,11 +365,12 @@ class RunApp(ViewSwitcher, App[None]):
354
365
 
355
366
  def __init__(
356
367
  self,
357
- start: Start,
368
+ start: Start | None,
358
369
  title: str,
359
370
  columns: Sequence[str],
360
371
  config: LabnotesConfig | None = None,
361
372
  experiment_config: Path | None = None,
373
+ daemon_journal: RunState | None = None,
362
374
  ) -> None:
363
375
  super().__init__()
364
376
  self.start = start
@@ -377,8 +389,12 @@ class RunApp(ViewSwitcher, App[None]):
377
389
  self.shown: Experiment | None = None
378
390
  self.left: dict[str, Widget] = {}
379
391
  self.controls = self.body.controls
380
- self.run_state = start.run_state if isinstance(start, ExperimentStart) else None
392
+ self.daemon_journal = daemon_journal
393
+ self.run_state = daemon_journal or (
394
+ start.run_state if isinstance(start, ExperimentStart) else None
395
+ )
381
396
  self.watching_state = False
397
+ self.attached: socket.socket | None = None
382
398
 
383
399
  def compose(self) -> ComposeResult:
384
400
  yield Header()
@@ -412,7 +428,93 @@ class RunApp(ViewSwitcher, App[None]):
412
428
  exit_on_error=False,
413
429
  )
414
430
  self.run_worker(self.watch_files(tables), exit_on_error=False)
415
- self.run_worker(self.drive())
431
+ if self.daemon_journal is not None:
432
+ journal = self.daemon_journal
433
+ try:
434
+ self.attach_daemon()
435
+ except (OSError, ValueError) as error:
436
+ self.error = error
437
+ self.exit()
438
+ return
439
+ self.listen(
440
+ Controls(
441
+ lambda: self.queue_command({"command": "stop_all"}),
442
+ lambda count: self.queue_command(
443
+ {
444
+ "command": "set_concurrency",
445
+ "count": count,
446
+ "journal": str(journal.path),
447
+ }
448
+ ),
449
+ lambda run: self.queue_command({"command": "stop_one", "run": run}),
450
+ lambda: None,
451
+ )
452
+ )
453
+ self.set_interval(0.25, self.refresh_state)
454
+ self.refresh_state()
455
+ else:
456
+ self.run_worker(self.drive())
457
+
458
+ def attach_daemon(self) -> None:
459
+ assert self.config is not None
460
+ path = daemon_socket(self.config, self.experiment_name)
461
+ if path is None:
462
+ raise ValueError(f"No daemon is running for {self.experiment_name}.")
463
+ client = socket.socket(socket.AF_UNIX)
464
+ try:
465
+ client.settimeout(10)
466
+ client.connect(str(path))
467
+ client.sendall(b'{"command":"attach"}\n')
468
+ with client.makefile("r") as replies:
469
+ reply = json.loads(replies.readline())
470
+ if not reply.get("ok"):
471
+ raise ValueError(str(reply.get("error", "Daemon rejected attach")))
472
+ except Exception:
473
+ client.close()
474
+ raise
475
+ self.attached = client
476
+
477
+ def on_unmount(self) -> None:
478
+ if self.attached is not None:
479
+ self.attached.close()
480
+
481
+ def queue_command(self, message: dict[str, object]) -> None:
482
+ self.run_worker(self.send_command(message), exit_on_error=False)
483
+
484
+ async def send_command(self, message: dict[str, object]) -> None:
485
+ assert self.config is not None
486
+ try:
487
+ reply = await asyncio.to_thread(
488
+ daemon_command, self.config, self.experiment_name, message
489
+ )
490
+ if not reply.get("ok"):
491
+ raise ValueError(str(reply.get("error", "Daemon rejected command")))
492
+ except ValueError as error:
493
+ if self.body_ready:
494
+ self.body.show(str(error))
495
+
496
+ def restart_run(self, run: str) -> None:
497
+ if self.daemon_journal is None:
498
+ return
499
+ if run not in self.body.failed:
500
+ self.body.show(f"{run} has not failed.")
501
+ return
502
+ self.run_worker(self.restart_daemon_run(run), exit_on_error=False)
503
+
504
+ async def restart_daemon_run(self, run: str) -> None:
505
+ assert self.config is not None
506
+ try:
507
+ reply = await asyncio.to_thread(
508
+ daemon_command,
509
+ self.config,
510
+ self.experiment_name,
511
+ {"command": "restart", "options": {"run_id": run}},
512
+ )
513
+ if not reply.get("ok"):
514
+ raise ValueError(str(reply.get("error", "Daemon rejected restart")))
515
+ self.body.show(f"Restarted {run}.")
516
+ except ValueError as error:
517
+ self.body.show(f"Could not restart {run}: {error}")
416
518
 
417
519
  def configured_views(self) -> dict[str, str]:
418
520
  if self.config is None:
@@ -453,6 +555,7 @@ class RunApp(ViewSwitcher, App[None]):
453
555
  self.deliver(self.shown, frozenset({str(row["path"])}))
454
556
 
455
557
  async def drive(self) -> None:
558
+ assert self.start is not None
456
559
  try:
457
560
  self.results = await self.start(self.show_event, self.listen)
458
561
  except Exception as error:
@@ -463,8 +566,9 @@ class RunApp(ViewSwitcher, App[None]):
463
566
  self.controls = controls
464
567
  if self.body_ready:
465
568
  self.body.controls = controls
466
- stop_then_kill(self.stop_runs, signal.SIGTERM)
467
- stop_then_kill(self.stop_runs, signal.SIGHUP)
569
+ if self.daemon_journal is None:
570
+ stop_then_kill(self.stop_runs, signal.SIGTERM)
571
+ stop_then_kill(self.stop_runs, signal.SIGHUP)
468
572
 
469
573
  def show_event(self, event: dict[str, object]) -> None:
470
574
  if self.run_state is None:
@@ -488,6 +592,14 @@ class RunApp(ViewSwitcher, App[None]):
488
592
  self.controls.interrupt()
489
593
 
490
594
  def action_interrupt(self) -> None:
595
+ if self.daemon_journal is not None:
596
+ if self.body_ready and self.body.started:
597
+ self.push_screen(
598
+ DetachedLeavePrompt(len(self.body.started)), self.answer
599
+ )
600
+ else:
601
+ self.exit()
602
+ return
491
603
  if self.body_ready and self.body.started:
492
604
  self.push_screen(LeavePrompt(len(self.body.started)), self.answer)
493
605
  elif self.body_ready and self.body.idle:
@@ -500,8 +612,17 @@ class RunApp(ViewSwitcher, App[None]):
500
612
 
501
613
  def answer(self, choice: str | None) -> None:
502
614
  if choice == "stop":
503
- self.controls.interrupt()
504
- self.set_timer(GRACE_SECONDS, self.controls.interrupt)
615
+ if self.daemon_journal is None:
616
+ self.controls.interrupt()
617
+ self.set_timer(GRACE_SECONDS, self.controls.interrupt)
618
+ else:
619
+ self.run_worker(self.stop_daemon_and_exit(), exit_on_error=False)
620
+ elif choice == "leave" and self.daemon_journal is not None:
621
+ self.exit()
622
+
623
+ async def stop_daemon_and_exit(self) -> None:
624
+ await self.send_command({"command": "stop_all"})
625
+ self.exit()
505
626
 
506
627
  def action_leave(self) -> None:
507
628
  if self.query_one(ContentSwitcher).current != "view-1":
@@ -1,18 +1,25 @@
1
1
  from pathlib import Path
2
2
 
3
3
  from labnotes.models import LabnotesConfig, Start
4
+ from labnotes.runner.run_state import RunState
4
5
  from labnotes.tui.config import RUN_COLUMNS
5
6
  from labnotes.tui.run_app import RunApp
6
7
 
7
8
 
8
9
  def run_tui(
9
- start: Start,
10
+ start: Start | None,
10
11
  title: str,
11
12
  config: LabnotesConfig | None = None,
12
13
  experiment_config: Path | None = None,
14
+ daemon_journal: RunState | None = None,
13
15
  ) -> list[dict[str, object]]:
14
16
  app = RunApp(
15
- start, title, RUN_COLUMNS, config=config, experiment_config=experiment_config
17
+ start,
18
+ title,
19
+ RUN_COLUMNS,
20
+ config=config,
21
+ experiment_config=experiment_config,
22
+ daemon_journal=daemon_journal,
16
23
  )
17
24
  app.run()
18
25
  if app.error is not None:
@@ -1,6 +1,5 @@
1
1
  import asyncio
2
2
  import os
3
- import sys
4
3
  from collections.abc import Mapping
5
4
  from pathlib import Path
6
5
  from typing import Any, cast
@@ -11,6 +10,8 @@ from textual.widget import Widget
11
10
  from textual.widgets import DataTable, Static
12
11
 
13
12
  from labnotes.runs_view_command import runs_view_command
13
+ from labnotes.daemon.command import command as daemon_command
14
+ from labnotes.daemon.ensure import ensure_daemon
14
15
  from labnotes.models import LabnotesConfig
15
16
  from labnotes.runner.claim_run import claim_run
16
17
  from labnotes.tui.fill_table import fill_table
@@ -144,28 +145,21 @@ class RunsBrowser(Widget):
144
145
 
145
146
  async def restart(self, run_id: str) -> None:
146
147
  config = cast(LabnotesConfig, getattr(self.app, "config"))
147
- process = await asyncio.create_subprocess_exec(
148
- sys.executable,
149
- "-m",
150
- "labnotes",
151
- "-c",
152
- str(config.path),
153
- "experiments",
154
- self.experiment.name,
155
- "run",
156
- "--run-id",
157
- run_id,
158
- stdout=asyncio.subprocess.PIPE,
159
- stderr=asyncio.subprocess.PIPE,
160
- )
161
- stdout, stderr = await process.communicate()
162
- if process.returncode == 0:
148
+ try:
149
+ await ensure_daemon(config, self.experiment.name)
150
+ reply = await asyncio.to_thread(
151
+ daemon_command,
152
+ config,
153
+ self.experiment.name,
154
+ {"command": "restart", "options": {"run_id": run_id}},
155
+ )
156
+ if not reply.get("ok"):
157
+ raise ValueError(str(reply.get("error", "Daemon rejected restart")))
163
158
  self.restart_status = (
164
159
  f"Restarted {run_id}. Select the run to view its output."
165
160
  )
166
- else:
167
- detail = stderr.decode().strip() or stdout.decode().strip()
168
- self.restart_status = f"Could not restart {run_id}: {detail}"
161
+ except ValueError as error:
162
+ self.restart_status = f"Could not restart {run_id}: {error}"
169
163
  self.show_status()
170
164
 
171
165
  async def action_open_row(self) -> None:
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes