labnotes 0.0.76__tar.gz → 0.0.78__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 (145) hide show
  1. {labnotes-0.0.76 → labnotes-0.0.78}/PKG-INFO +1 -1
  2. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/context.md +3 -3
  3. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/create_app.py +30 -0
  4. labnotes-0.0.78/src/labnotes/daemon/socket_path.py +13 -0
  5. labnotes-0.0.78/src/labnotes/load_run.py +21 -0
  6. labnotes-0.0.78/src/labnotes/run_output.py +66 -0
  7. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/labnotes.css +13 -0
  8. labnotes-0.0.78/src/labnotes/static/row-link.js +8 -0
  9. labnotes-0.0.78/src/labnotes/static/run-output.js +24 -0
  10. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/open_tables.py +14 -0
  11. labnotes-0.0.78/src/labnotes/tables/read_log.py +20 -0
  12. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/templates/experiment.html +2 -1
  13. labnotes-0.0.78/src/labnotes/templates/run.html +19 -0
  14. labnotes-0.0.76/src/labnotes/daemon/socket_path.py +0 -7
  15. labnotes-0.0.76/src/labnotes/static/row-link.js +0 -9
  16. {labnotes-0.0.76 → labnotes-0.0.78}/.gitignore +0 -0
  17. {labnotes-0.0.76 → labnotes-0.0.78}/CHANGELOG.md +0 -0
  18. {labnotes-0.0.76 → labnotes-0.0.78}/MIGRATIONS.md +0 -0
  19. {labnotes-0.0.76 → labnotes-0.0.78}/README.md +0 -0
  20. {labnotes-0.0.76 → labnotes-0.0.78}/pyproject.toml +0 -0
  21. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/__init__.py +0 -0
  22. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/__main__.py +0 -0
  23. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/asset_url.py +0 -0
  24. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/check_counts.py +0 -0
  25. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/cli.py +0 -0
  26. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/context_guide.py +0 -0
  27. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/count_groups.py +0 -0
  28. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/count_runs.py +0 -0
  29. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/create_experiment.py +0 -0
  30. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/create_note.py +0 -0
  31. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/__init__.py +0 -0
  32. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/daemon_app.py +0 -0
  33. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/daemon_client.py +0 -0
  34. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/daemon_running.py +0 -0
  35. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/daemon_server.py +0 -0
  36. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/describe_status.py +0 -0
  37. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/ensure_daemon.py +0 -0
  38. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/exit_when_idle.py +0 -0
  39. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/remote_start.py +0 -0
  40. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/run_daemon.py +0 -0
  41. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/run_logs.py +0 -0
  42. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/serve_daemon.py +0 -0
  43. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/stop_runs.py +0 -0
  44. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/watch_daemon.py +0 -0
  45. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon/watch_status.py +0 -0
  46. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/daemon_command.py +0 -0
  47. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/data_html.py +0 -0
  48. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/default_config_text.py +0 -0
  49. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/default_counts.py +0 -0
  50. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/drain_watch.py +0 -0
  51. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/experiment_notes.py +0 -0
  52. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/experiment_runs.py +0 -0
  53. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/experiments.py +0 -0
  54. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/file_changes.py +0 -0
  55. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/format_duration.py +0 -0
  56. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/format_started.py +0 -0
  57. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/format_value.py +0 -0
  58. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/group_runs.py +0 -0
  59. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/init.py +0 -0
  60. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/invocation_started.py +0 -0
  61. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/is_positive_int.py +0 -0
  62. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/list_experiments.py +0 -0
  63. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/list_runs.py +0 -0
  64. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/load_config.py +0 -0
  65. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/load_experiment.py +0 -0
  66. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/models.py +0 -0
  67. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/modes.py +0 -0
  68. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/nests_runs.py +0 -0
  69. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/note_path.py +0 -0
  70. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/notebook_kernel.py +0 -0
  71. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/output_html.py +0 -0
  72. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/parse_run_view.py +0 -0
  73. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/print_rows.py +0 -0
  74. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/python_blocks.py +0 -0
  75. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/read_config.py +0 -0
  76. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/read_path.py +0 -0
  77. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/render_markdown.py +0 -0
  78. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/run_command.py +0 -0
  79. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/run_status.py +0 -0
  80. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/__init__.py +0 -0
  81. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/describe_event.py +0 -0
  82. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/execute_run.py +0 -0
  83. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/hold_folder.py +0 -0
  84. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/new_param_run_id.py +0 -0
  85. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/new_run_id.py +0 -0
  86. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/new_stamped_run_id.py +0 -0
  87. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/param_args.py +0 -0
  88. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/param_grid.py +0 -0
  89. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/param_run_name.py +0 -0
  90. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/plan_runs.py +0 -0
  91. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/prepare_experiment.py +0 -0
  92. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/read_experiment_config.py +0 -0
  93. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/run_experiment.py +0 -0
  94. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/run_folder.py +0 -0
  95. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/run_headless.py +0 -0
  96. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/runs_parent.py +0 -0
  97. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/shell_command.py +0 -0
  98. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/signal_tree.py +0 -0
  99. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/stop_then_kill.py +0 -0
  100. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/wait_for_tree.py +0 -0
  101. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/runner/write_run_json.py +0 -0
  102. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/save_conflict_copy.py +0 -0
  103. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/save_note.py +0 -0
  104. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/saved_outputs.py +0 -0
  105. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/serve_app.py +0 -0
  106. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/server.py +0 -0
  107. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/shared_params.py +0 -0
  108. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/sort_href.py +0 -0
  109. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/sql_quote.py +0 -0
  110. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/experiment.js +0 -0
  111. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/local-time.js +0 -0
  112. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/new-note.js +0 -0
  113. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/note-edit.js +0 -0
  114. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/note-editor.js +0 -0
  115. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/run-cell.js +0 -0
  116. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/static/view-toggle.js +0 -0
  117. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/summarize_config.py +0 -0
  118. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/__init__.py +0 -0
  119. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/experiment_rows.py +0 -0
  120. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/experiment_run_ids.py +0 -0
  121. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/file_prefix.py +0 -0
  122. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/file_text.py +0 -0
  123. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/iso_time.py +0 -0
  124. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/list_experiment_runs.py +0 -0
  125. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/note_text.py +0 -0
  126. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/path_glob.py +0 -0
  127. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/path_pattern.py +0 -0
  128. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/read_experiment.py +0 -0
  129. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/read_file.py +0 -0
  130. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/read_run.py +0 -0
  131. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tables/summarize_experiments.py +0 -0
  132. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/templates/base.html +0 -0
  133. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/templates/editor_imports.html +0 -0
  134. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/templates/index.html +0 -0
  135. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/templates/note.html +0 -0
  136. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/__init__.py +0 -0
  137. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/dim.py +0 -0
  138. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/format_elapsed.py +0 -0
  139. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/run_app.py +0 -0
  140. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/run_tui.py +0 -0
  141. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/tui/runs_table.py +0 -0
  142. {labnotes-0.0.76 → labnotes-0.0.78}/src/labnotes/wait_for_note.py +0 -0
  143. {labnotes-0.0.76 → labnotes-0.0.78}/testing-conventions.toml +0 -0
  144. {labnotes-0.0.76 → labnotes-0.0.78}/tests/integration/conftest.py +0 -0
  145. {labnotes-0.0.76 → labnotes-0.0.78}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: labnotes
3
- Version: 0.0.76
3
+ Version: 0.0.78
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>
@@ -113,8 +113,8 @@ going. Elsewhere, it prints a line per event instead.
113
113
  `.labnotes.toml`, then 1.
114
114
  - `-e PATH` reads another file instead of `experiment.toml`.
115
115
  - `run` hands its runs to the project's labnotes daemon, starting it if
116
- needed, and shows or waits for them. The daemon listens on `.labnotes.sock`
117
- in the project folder and exits once nothing is running and no client has
116
+ needed, and shows or waits for them. The daemon listens on a socket in
117
+ `/tmp/labnotes-<uid>/` and exits once nothing is running and no client has
118
118
  been connected for 15 seconds. Runs belong to the daemon, so quitting the
119
119
  live view, closing its terminal, or killing `run` outright leaves them going.
120
120
  - Ctrl-C stops the running scripts and records their end. A second Ctrl-C
@@ -129,4 +129,4 @@ going. Elsewhere, it prints a line per event instead.
129
129
  - A second `run` of the same experiment fails while the first is going.
130
130
  - It exits 0 even if runs fail; the summary line counts failures.
131
131
 
132
- `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file.
132
+ `uvx labnotes server` serves the experiments, their markdown files and their runs as a local site, read from the same `.labnotes.toml`. An experiment's page shows its `experiment.toml` on one line, then a Runs tab with a table of its runs and a Notebooks tab with one tab per markdown file. Clicking a run opens its `stdout.log` and `stderr.log` together, followed as they're written until the run ends.
@@ -28,11 +28,13 @@ from labnotes.format_started import format_started
28
28
  from labnotes.format_value import format_value
29
29
  from labnotes.list_experiments import list_experiments
30
30
  from labnotes.load_experiment import load_experiment
31
+ from labnotes.load_run import load_run
31
32
  from labnotes.models import LabnotesConfig
32
33
  from labnotes.note_path import note_path
33
34
  from labnotes.notebook_kernel import NotebookKernel
34
35
  from labnotes.python_blocks import python_blocks
35
36
  from labnotes.render_markdown import render_markdown
37
+ from labnotes.run_output import run_output
36
38
  from labnotes.save_conflict_copy import save_conflict_copy
37
39
  from labnotes.save_note import save_note
38
40
  from labnotes.saved_outputs import saved_outputs
@@ -84,6 +86,32 @@ def create_app(config: LabnotesConfig) -> Starlette:
84
86
  request, "experiment.html", {"experiment": loaded}
85
87
  )
86
88
 
89
+ async def find_run(request: Request) -> dict[str, object]:
90
+ found = await load_run(
91
+ request.app.state.tables,
92
+ request.path_params["name"],
93
+ request.path_params["run"],
94
+ )
95
+ if found is None:
96
+ raise HTTPException(404, "No such run")
97
+ return found
98
+
99
+ async def run(request: Request) -> Response:
100
+ found = await find_run(request)
101
+ return templates.TemplateResponse(request, "run.html", {"run": found})
102
+
103
+ async def output(request: Request) -> Response:
104
+ await find_run(request)
105
+ return StreamingResponse(
106
+ run_output(
107
+ request.app.state.tables,
108
+ config.root,
109
+ request.path_params["name"],
110
+ request.path_params["run"],
111
+ ),
112
+ media_type="text/event-stream",
113
+ )
114
+
87
115
  async def read_note(request: Request) -> dict[str, str]:
88
116
  name = request.path_params["name"]
89
117
  note = request.path_params["note"]
@@ -191,6 +219,8 @@ def create_app(config: LabnotesConfig) -> Starlette:
191
219
  Route("/", index),
192
220
  Route("/experiments/{name}", experiment),
193
221
  Route("/experiments/{name}/changes", changes),
222
+ Route("/experiments/{name}/runs/{run:path}", run),
223
+ Route("/experiments/{name}/output/{run:path}", output),
194
224
  Route("/experiments/{name}/notes", post_note, methods=["POST"]),
195
225
  Route("/experiments/{name}/notes/{note:path}", get_note),
196
226
  Route("/experiments/{name}/notes/{note:path}", put_note, methods=["PUT"]),
@@ -0,0 +1,13 @@
1
+ import hashlib
2
+ import os
3
+ from pathlib import Path
4
+
5
+
6
+ # Unix socket paths cap at 107 bytes, so the socket can't live in a deep project.
7
+ # A fixed folder, not XDG_RUNTIME_DIR or TMPDIR, so every shell finds the daemon.
8
+ def socket_path(root: Path) -> Path:
9
+ folder = Path("/tmp") / f"labnotes-{os.getuid()}"
10
+ folder.mkdir(exist_ok=True)
11
+ folder.chmod(0o700)
12
+ digest = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:16]
13
+ return folder / f"{digest}.sock"
@@ -0,0 +1,21 @@
1
+ import json
2
+
3
+ from dirsql import DirSQL
4
+
5
+ from labnotes.sql_quote import quote_string
6
+
7
+
8
+ async def load_run(
9
+ tables: DirSQL, experiment: str, run: str
10
+ ) -> dict[str, object] | None:
11
+ rows = await tables.query(
12
+ "SELECT runs.experiment, experiments.display_name, runs.id, runs.folder,"
13
+ " runs.created, runs.ended, runs.exit_code, runs.params"
14
+ " FROM runs JOIN experiments ON experiments.name = runs.experiment"
15
+ f" WHERE runs.experiment = {quote_string(experiment)}"
16
+ f" AND runs.id = {quote_string(run)}"
17
+ )
18
+ if not rows:
19
+ return None
20
+ [row] = rows
21
+ return {**row, "params": json.loads(str(row["params"]))}
@@ -0,0 +1,66 @@
1
+ import codecs
2
+ import json
3
+ from collections.abc import AsyncIterator, Iterable, Iterator
4
+ from pathlib import Path
5
+
6
+ from dirsql import DirSQL
7
+
8
+ from labnotes.load_run import load_run
9
+ from labnotes.tables.read_log import STREAMS
10
+
11
+
12
+ async def run_output(
13
+ tables: DirSQL, root: Path, experiment: str, run_id: str
14
+ ) -> AsyncIterator[str]:
15
+ # Watch before reading, so nothing written in between is missed.
16
+ events = tables.watch()
17
+ run = await load_run(tables, experiment, run_id)
18
+ if run is None:
19
+ return
20
+ folder = root / str(run["folder"])
21
+ offsets = dict.fromkeys(STREAMS, 0)
22
+ decoders = {
23
+ stream: codecs.getincrementaldecoder("utf-8")("replace") for stream in STREAMS
24
+ }
25
+ last: str | None = None
26
+ mid_line = False
27
+
28
+ def read(streams: Iterable[str]) -> Iterator[str]:
29
+ nonlocal last, mid_line
30
+ for stream in streams:
31
+ path = folder / f"{stream}.log"
32
+ if not path.exists():
33
+ continue
34
+ with path.open("rb") as log:
35
+ log.seek(offsets[stream])
36
+ data = log.read()
37
+ offsets[stream] += len(data)
38
+ text = decoders[stream].decode(data)
39
+ if not text:
40
+ continue
41
+ if mid_line and stream != last:
42
+ text = "\n" + text
43
+ last, mid_line = stream, not text.endswith("\n")
44
+ yield f"data: {json.dumps({'stream': stream, 'text': text})}\n\n"
45
+
46
+ for message in read(STREAMS):
47
+ yield message
48
+ exit_code = run["exit_code"]
49
+ if run["ended"] is None:
50
+ async for event in events:
51
+ row = event.row
52
+ if row is None or row["experiment"] != experiment:
53
+ continue
54
+ if event.table == "logs" and row["run"] == run_id:
55
+ for message in read([row["stream"]]):
56
+ yield message
57
+ elif (
58
+ event.table == "runs"
59
+ and row["id"] == run_id
60
+ and row["ended"] is not None
61
+ ):
62
+ exit_code = row["exit_code"]
63
+ break
64
+ for message in read(STREAMS):
65
+ yield message
66
+ yield f"event: end\ndata: {json.dumps(exit_code)}\n\n"
@@ -295,6 +295,19 @@ h3 {
295
295
  .group-toggle[aria-expanded="true"]::before { content: "▾"; }
296
296
  .group:not(.open) > :not(.group-row) { display: none; }
297
297
  .group.open .run-row td:nth-child(2) { padding-left: 2rem; }
298
+ .run-row { cursor: pointer; }
299
+ .run-link { text-decoration: none; }
300
+
301
+ /* Run output */
302
+ .run-state.good { color: var(--good); }
303
+ .run-state.bad { color: var(--bad); }
304
+ .run-log { background: var(--surface); border: 1px solid var(--rule); border-radius: 6px; }
305
+ .run-log header { display: flex; gap: 1rem; padding: 0.4rem 1.25rem; border-bottom: 1px solid var(--rule); font-family: var(--mono); font-size: 0.75rem; color: var(--ink-3); }
306
+ .run-log header span::before { content: "■ "; }
307
+ .run-output { margin: 0; padding: 1rem 1.25rem; max-height: 70vh; overflow: auto; font-size: 0.82rem; white-space: pre-wrap; overflow-wrap: anywhere; }
308
+ .run-output:empty::before { content: "No output yet."; color: var(--ink-3); }
309
+ .run-output .stderr, .run-log header .stderr::before { color: var(--bad); }
310
+ .run-log header .stdout::before { color: var(--ink-2); }
298
311
 
299
312
  /* Notes */
300
313
  .note {
@@ -0,0 +1,8 @@
1
+ // Safari ignores position: relative on <tr>, so a stretched ::after link covers the whole page.
2
+ // Delegated, so rows the runs filter swaps in stay clickable.
3
+ document.addEventListener("click", (event) => {
4
+ const link = event.target.closest(".ledger tbody tr, .run-row")?.querySelector(".title-link, .run-link");
5
+ if (!link || event.target.closest("a") || getSelection().toString()) return;
6
+ if (event.metaKey || event.ctrlKey) window.open(link.href, "_blank");
7
+ else location.href = link.href;
8
+ });
@@ -0,0 +1,24 @@
1
+ const output = document.querySelector(".run-output");
2
+ const state = document.querySelector(".run-state");
3
+ const source = new EventSource(output.dataset.url);
4
+
5
+ // The server resends the whole log on each connection, so a reconnect starts clean.
6
+ source.addEventListener("open", () => output.replaceChildren());
7
+
8
+ source.addEventListener("message", (event) => {
9
+ const { stream, text } = JSON.parse(event.data);
10
+ const following = output.scrollTop + output.clientHeight >= output.scrollHeight - 4;
11
+ const chunk = document.createElement("span");
12
+ chunk.className = stream;
13
+ chunk.textContent = text;
14
+ output.append(chunk);
15
+ if (following) output.scrollTop = output.scrollHeight;
16
+ });
17
+
18
+ source.addEventListener("end", (event) => {
19
+ source.close();
20
+ const code = JSON.parse(event.data);
21
+ state.textContent = code === null ? "ended" : `exit ${code}`;
22
+ state.classList.add(code === 0 ? "good" : "bad");
23
+ output.dataset.ended = "";
24
+ });
@@ -6,6 +6,7 @@ from labnotes.models import LabnotesConfig
6
6
  from labnotes.tables.path_glob import path_glob
7
7
  from labnotes.tables.read_experiment import read_experiment
8
8
  from labnotes.tables.read_file import read_file
9
+ from labnotes.tables.read_log import read_log
9
10
  from labnotes.tables.read_run import read_run
10
11
 
11
12
  # experiments is a view, so files and runs can't declare a foreign key to it.
@@ -40,6 +41,13 @@ CREATE TABLE runs (
40
41
  PRIMARY KEY (experiment, id)
41
42
  )
42
43
  """
44
+ # Sizes only: the server reads log text from disk, the row change is the signal.
45
+ LOGS = """
46
+ CREATE TABLE logs (
47
+ experiment TEXT, run TEXT, stream TEXT, size INTEGER,
48
+ PRIMARY KEY (experiment, run, stream)
49
+ )
50
+ """
43
51
 
44
52
 
45
53
  def open_tables(config: LabnotesConfig) -> DirSQL:
@@ -59,6 +67,12 @@ def open_tables(config: LabnotesConfig) -> DirSQL:
59
67
  glob=path_glob(f"{config.runs}/run.json"),
60
68
  on_file=partial(read_run, root, config.runs),
61
69
  ),
70
+ Table(
71
+ name="logs",
72
+ ddl=LOGS,
73
+ glob=path_glob(f"{config.runs}/std*.log"),
74
+ on_file=partial(read_log, root, config.runs),
75
+ ),
62
76
  Table(
63
77
  name="experiment_configs",
64
78
  ddl=EXPERIMENTS,
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+
3
+ from labnotes.tables.path_pattern import path_pattern
4
+
5
+ STREAMS = ("stdout", "stderr")
6
+
7
+
8
+ def read_log(root: Path, template: str, path: str) -> list[dict[str, object]]:
9
+ relative = Path(path).relative_to(root).as_posix()
10
+ found = path_pattern(f"{template}/{{stream}}.log").fullmatch(relative)
11
+ if found is None or found["stream"] not in STREAMS:
12
+ return []
13
+ return [
14
+ {
15
+ "experiment": found["experiment"],
16
+ "run": found["run"],
17
+ "stream": found["stream"],
18
+ "size": Path(path).stat().st_size,
19
+ }
20
+ ]
@@ -83,7 +83,7 @@
83
83
  {% macro run_rows(run) %}
84
84
  <tr class="run-row">
85
85
  <td><span class="status {{ 'good' if run.labnotes_completed else 'bad' }}" title="{{ 'complete' if run.labnotes_completed else 'not complete' }}">{{ '✓' if run.labnotes_completed else '✕' }}</span></td>
86
- {% for column in e.run_columns %}{% if column == 'id' and nested %}<td class="mono">{{ run.id.partition('/')[2] or run.id }}</td><td></td>{% else %}<td class="{{ 'mono' if loop.first }}{{ 'num' if column == 'duration' }}">{{ run[column] | duration if column == 'duration' else (run[column] | started if column == 'started' else run[column] | value) }}</td>{% endif %}{% endfor %}
86
+ {% for column in e.run_columns %}{% if column == 'id' %}<td class="mono"><a class="run-link" href="{{ url_for('run', name=e.name, run=run.id) }}">{{ run.id.partition('/')[2] or run.id if nested else run.id }}</a></td>{% if nested %}<td></td>{% endif %}{% else %}<td class="{{ 'mono' if loop.first }}{{ 'num' if column == 'duration' }}">{{ run[column] | duration if column == 'duration' else (run[column] | started if column == 'started' else run[column] | value) }}</td>{% endif %}{% endfor %}
87
87
  </tr>
88
88
  {% endmacro %}
89
89
  {% if nested %}
@@ -117,4 +117,5 @@
117
117
  {% endif %}
118
118
  <script src="{{ asset('experiment.js') }}" defer></script>
119
119
  <script src="{{ asset('local-time.js') }}" defer></script>
120
+ <script src="{{ asset('row-link.js') }}" defer></script>
120
121
  {% endblock %}
@@ -0,0 +1,19 @@
1
+ {% extends "base.html" %}
2
+ {% block title %}{{ run.id }} · {{ run.display_name }} · labnotes{% endblock %}
3
+ {% block crumbs %}<span class="crumb"><a href="{{ url_for('index') }}">Experiments</a></span><span class="crumb"><a href="{{ url_for('experiment', name=run.experiment) }}">{{ run.display_name }}</a></span><span class="crumb mono">{{ run.id }}</span>{% endblock %}
4
+ {% block main %}
5
+ <section class="intro">
6
+ <h1 class="mono">{{ run.id }}</h1>
7
+ <p class="config-summary">
8
+ {% if run.ended is none %}<span class="chip run-state">running</span>{% elif run.exit_code is none %}<span class="chip run-state bad">ended</span>{% else %}<span class="chip run-state {{ 'good' if run.exit_code == 0 else 'bad' }}">exit {{ run.exit_code }}</span>{% endif %}
9
+ {% if run.created %}<span class="setting">started <code>{{ run.created }}</code></span>{% endif %}
10
+ {% for key, value in run.params.items() %}<span class="setting">{{ key }} <code>{{ value | value }}</code></span>{% endfor %}
11
+ </p>
12
+ </section>
13
+
14
+ <section class="run-log">
15
+ <header><span class="stdout">stdout</span><span class="stderr">stderr</span></header>
16
+ <pre class="run-output" data-url="{{ url_for('output', name=run.experiment, run=run.id) }}"></pre>
17
+ </section>
18
+ <script src="{{ asset('run-output.js') }}" defer></script>
19
+ {% endblock %}
@@ -1,7 +0,0 @@
1
- from pathlib import Path
2
-
3
- SOCKET_NAME = ".labnotes.sock"
4
-
5
-
6
- def socket_path(root: Path) -> Path:
7
- return root / SOCKET_NAME
@@ -1,9 +0,0 @@
1
- // Safari ignores position: relative on <tr>, so a stretched ::after link covers the whole page.
2
- for (const row of document.querySelectorAll(".ledger tbody tr")) {
3
- const link = row.querySelector(".title-link");
4
- row.addEventListener("click", (event) => {
5
- if (event.target.closest("a") || getSelection().toString()) return;
6
- if (event.metaKey || event.ctrlKey) window.open(link.href, "_blank");
7
- else location.href = link.href;
8
- });
9
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes