runspool 0.2.0__tar.gz → 0.2.1__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 (156) hide show
  1. runspool-0.2.1/.github/workflows/publish.yml +40 -0
  2. {runspool-0.2.0 → runspool-0.2.1}/CHANGELOG.md +18 -1
  3. {runspool-0.2.0 → runspool-0.2.1}/PKG-INFO +1 -1
  4. {runspool-0.2.0 → runspool-0.2.1}/RELEASING.md +5 -1
  5. {runspool-0.2.0 → runspool-0.2.1}/docs/cli.md +2 -1
  6. {runspool-0.2.0 → runspool-0.2.1}/docs/plugins.md +25 -0
  7. {runspool-0.2.0 → runspool-0.2.1}/docs/workflows.md +4 -0
  8. {runspool-0.2.0 → runspool-0.2.1}/pyproject.toml +1 -1
  9. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/__init__.py +6 -1
  10. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/cli.py +17 -4
  11. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/config.py +7 -0
  12. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/doctor.py +9 -1
  13. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/runtime.py +10 -1
  14. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/daemon.py +36 -1
  15. {runspool-0.2.0 → runspool-0.2.1}/tests/test_cli.py +50 -1
  16. {runspool-0.2.0 → runspool-0.2.1}/tests/test_config.py +10 -0
  17. runspool-0.2.1/tests/test_daemon_tick.py +89 -0
  18. runspool-0.2.0/.github/workflows/publish.yml +0 -28
  19. {runspool-0.2.0 → runspool-0.2.1}/.env.example +0 -0
  20. {runspool-0.2.0 → runspool-0.2.1}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  21. {runspool-0.2.0 → runspool-0.2.1}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  22. {runspool-0.2.0 → runspool-0.2.1}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  23. {runspool-0.2.0 → runspool-0.2.1}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  24. {runspool-0.2.0 → runspool-0.2.1}/.github/workflows/ci.yml +0 -0
  25. {runspool-0.2.0 → runspool-0.2.1}/.gitignore +0 -0
  26. {runspool-0.2.0 → runspool-0.2.1}/AGENTS.md +0 -0
  27. {runspool-0.2.0 → runspool-0.2.1}/CODE_OF_CONDUCT.md +0 -0
  28. {runspool-0.2.0 → runspool-0.2.1}/CONTRIBUTING.md +0 -0
  29. {runspool-0.2.0 → runspool-0.2.1}/LICENSE +0 -0
  30. {runspool-0.2.0 → runspool-0.2.1}/README.md +0 -0
  31. {runspool-0.2.0 → runspool-0.2.1}/README.zh-CN.md +0 -0
  32. {runspool-0.2.0 → runspool-0.2.1}/THIRD_PARTY_NOTICES.md +0 -0
  33. {runspool-0.2.0 → runspool-0.2.1}/config.example.yaml +0 -0
  34. {runspool-0.2.0 → runspool-0.2.1}/docs/README.md +0 -0
  35. {runspool-0.2.0 → runspool-0.2.1}/docs/agent-json-output.md +0 -0
  36. {runspool-0.2.0 → runspool-0.2.1}/docs/architecture.md +0 -0
  37. {runspool-0.2.0 → runspool-0.2.1}/docs/concepts.md +0 -0
  38. {runspool-0.2.0 → runspool-0.2.1}/docs/design/0.2-plugin-architecture.md +0 -0
  39. {runspool-0.2.0 → runspool-0.2.1}/docs/design-decisions.md +0 -0
  40. {runspool-0.2.0 → runspool-0.2.1}/docs/examples.md +0 -0
  41. {runspool-0.2.0 → runspool-0.2.1}/docs/writing-steps.md +0 -0
  42. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/README.md +0 -0
  43. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/config.yaml +0 -0
  44. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/client_profile.md +0 -0
  45. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/competitors/acme.md +0 -0
  46. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/competitors/globex.md +0 -0
  47. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/customer_notes/call-2026-06-10.md +0 -0
  48. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/pricing_pages/acme-pricing.md +0 -0
  49. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/requirements.md +0 -0
  50. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/data/sources.yaml +0 -0
  51. {runspool-0.2.0 → runspool-0.2.1}/examples/client-intel-brief/steps/intel_steps.py +0 -0
  52. {runspool-0.2.0 → runspool-0.2.1}/examples/creator-publishing-pipeline/README.md +0 -0
  53. {runspool-0.2.0 → runspool-0.2.1}/examples/creator-publishing-pipeline/config.yaml +0 -0
  54. {runspool-0.2.0 → runspool-0.2.1}/examples/creator-publishing-pipeline/materials/assets/cover.txt +0 -0
  55. {runspool-0.2.0 → runspool-0.2.1}/examples/creator-publishing-pipeline/materials/notes.md +0 -0
  56. {runspool-0.2.0 → runspool-0.2.1}/examples/creator-publishing-pipeline/materials/source.md +0 -0
  57. {runspool-0.2.0 → runspool-0.2.1}/examples/local-file-pipeline/README.md +0 -0
  58. {runspool-0.2.0 → runspool-0.2.1}/examples/local-file-pipeline/config.yaml +0 -0
  59. {runspool-0.2.0 → runspool-0.2.1}/examples/local-file-pipeline/inbox/invoice.txt +0 -0
  60. {runspool-0.2.0 → runspool-0.2.1}/examples/local-file-pipeline/inbox/meeting-notes.txt +0 -0
  61. {runspool-0.2.0 → runspool-0.2.1}/examples/local-file-pipeline/inbox/support-ticket.txt +0 -0
  62. {runspool-0.2.0 → runspool-0.2.1}/sample-output/README.md +0 -0
  63. {runspool-0.2.0 → runspool-0.2.1}/sample-output/inspect-1.json +0 -0
  64. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/classification.json +0 -0
  65. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/metadata.json +0 -0
  66. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/normalized.md +0 -0
  67. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/source.txt +0 -0
  68. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/summary.json +0 -0
  69. {runspool-0.2.0 → runspool-0.2.1}/sample-output/ready/1/summary.md +0 -0
  70. {runspool-0.2.0 → runspool-0.2.1}/sample-output/status.json +0 -0
  71. {runspool-0.2.0 → runspool-0.2.1}/scripts/check-downstream.sh +0 -0
  72. {runspool-0.2.0 → runspool-0.2.1}/scripts/smoke_examples.sh +0 -0
  73. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/app.py +0 -0
  74. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/__init__.py +0 -0
  75. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/archive.py +0 -0
  76. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/file_intake.py +0 -0
  77. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/markdown_normalize.py +0 -0
  78. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/text_classify.py +0 -0
  79. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/text_summarize.py +0 -0
  80. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/builtin_steps/workspace.py +0 -0
  81. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/clock.py +0 -0
  82. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/commands.py +0 -0
  83. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/__init__.py +0 -0
  84. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/approval.py +0 -0
  85. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/boot.py +0 -0
  86. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/builtin.py +0 -0
  87. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/bundles.py +0 -0
  88. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/cli.py +0 -0
  89. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/config_steps.py +0 -0
  90. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/credentials.py +0 -0
  91. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/steps.py +0 -0
  92. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/store.py +0 -0
  93. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/tasks.py +0 -0
  94. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/core/workflows.py +0 -0
  95. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/display.py +0 -0
  96. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/doctor.py +0 -0
  97. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/__init__.py +0 -0
  98. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/coordinator.py +0 -0
  99. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/gate.py +0 -0
  100. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/registry.py +0 -0
  101. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/runner.py +0 -0
  102. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/step.py +0 -0
  103. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/engine/worker_pool.py +0 -0
  104. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/__init__.py +0 -0
  105. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/compose.py +0 -0
  106. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/errors.py +0 -0
  107. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/events.py +0 -0
  108. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/fiber.py +0 -0
  109. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/loader.py +0 -0
  110. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/kernel/plugin.py +0 -0
  111. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/models.py +0 -0
  112. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/__init__.py +0 -0
  113. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/connection.py +0 -0
  114. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/event_log.py +0 -0
  115. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/repository.py +0 -0
  116. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/schema.py +0 -0
  117. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/state_machine.py +0 -0
  118. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/persistence/step_run_log.py +0 -0
  119. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/registry_builder.py +0 -0
  120. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/runtime.py +0 -0
  121. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/testing/__init__.py +0 -0
  122. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/testing/store_contract.py +0 -0
  123. {runspool-0.2.0 → runspool-0.2.1}/src/runspool/views.py +0 -0
  124. {runspool-0.2.0 → runspool-0.2.1}/tests/__init__.py +0 -0
  125. {runspool-0.2.0 → runspool-0.2.1}/tests/conftest.py +0 -0
  126. {runspool-0.2.0 → runspool-0.2.1}/tests/core/__init__.py +0 -0
  127. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_approval.py +0 -0
  128. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_boot.py +0 -0
  129. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_cli_plugins.py +0 -0
  130. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_credentials.py +0 -0
  131. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_m4_review.py +0 -0
  132. {runspool-0.2.0 → runspool-0.2.1}/tests/core/test_store_contract.py +0 -0
  133. {runspool-0.2.0 → runspool-0.2.1}/tests/engine/__init__.py +0 -0
  134. {runspool-0.2.0 → runspool-0.2.1}/tests/engine/test_coordinator.py +0 -0
  135. {runspool-0.2.0 → runspool-0.2.1}/tests/engine/test_registry.py +0 -0
  136. {runspool-0.2.0 → runspool-0.2.1}/tests/engine/test_runner.py +0 -0
  137. {runspool-0.2.0 → runspool-0.2.1}/tests/engine/test_runner_contract.py +0 -0
  138. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/__init__.py +0 -0
  139. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/test_compose.py +0 -0
  140. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/test_events.py +0 -0
  141. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/test_fiber.py +0 -0
  142. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/test_loader.py +0 -0
  143. {runspool-0.2.0 → runspool-0.2.1}/tests/kernel/test_regressions.py +0 -0
  144. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/__init__.py +0 -0
  145. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_connection.py +0 -0
  146. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_migrations.py +0 -0
  147. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_repository.py +0 -0
  148. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_settle_races.py +0 -0
  149. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_state_machine.py +0 -0
  150. {runspool-0.2.0 → runspool-0.2.1}/tests/persistence/test_step_boundary.py +0 -0
  151. {runspool-0.2.0 → runspool-0.2.1}/tests/support.py +0 -0
  152. {runspool-0.2.0 → runspool-0.2.1}/tests/test_builtin_steps.py +0 -0
  153. {runspool-0.2.0 → runspool-0.2.1}/tests/test_display.py +0 -0
  154. {runspool-0.2.0 → runspool-0.2.1}/tests/test_models.py +0 -0
  155. {runspool-0.2.0 → runspool-0.2.1}/tests/test_registry_builder.py +0 -0
  156. {runspool-0.2.0 → runspool-0.2.1}/tests/test_views.py +0 -0
@@ -0,0 +1,40 @@
1
+ name: Publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ permissions:
12
+ contents: read
13
+ id-token: write
14
+
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v5
20
+
21
+ # skip-existing below must not hide a forgotten version bump: a release tag
22
+ # has to match the version being published.
23
+ - name: Check the tag matches the version
24
+ if: github.event_name == 'release'
25
+ run: |
26
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
27
+ test "v$version" = "${{ github.event.release.tag_name }}" || {
28
+ echo "release tag ${{ github.event.release.tag_name }} != pyproject version v$version"; exit 1; }
29
+
30
+ # RunSpool and its official plugin(s). Example plugins are not published.
31
+ - name: Build distributions
32
+ run: |
33
+ uv build --package runspool -o dist
34
+ uv build --package runspool-wechat -o dist
35
+
36
+ # A release may bump only some packages: versions already on PyPI are skipped.
37
+ - name: Publish to PyPI
38
+ uses: pypa/gh-action-pypi-publish@release/v1
39
+ with:
40
+ skip-existing: true
@@ -6,6 +6,22 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.1] - 2026-10-04
10
+
11
+ ### Added
12
+ - **`daemon/tick` event**: the daemon emits it after every scheduling round with a
13
+ `DaemonTick` (`round`, `now`), so plugins can do periodic maintenance (sweep a
14
+ cache, rotate files) without a process of their own. A listener that raises is
15
+ logged and skipped; scheduling carries on.
16
+ - **`default_workflow` setting**: the workflow `runspool add` uses without
17
+ `--workflow` (still `local_file` when unset). `runspool doctor` reports a
18
+ default that no workflow defines.
19
+
20
+ ### Fixed
21
+ - `runspool.__version__` said 0.1.0; it now reads the installed version.
22
+ - `runspool add` with an unknown workflow printed the error prefix twice
23
+ (`undefined workflow: undefined workflow: 'x'`).
24
+
9
25
  ## [0.2.0] - 2026-10-04
10
26
 
11
27
  RunSpool is now built from plugins on a small kernel. Everything the engine does —
@@ -151,6 +167,7 @@ Initial release.
151
167
  - Documentation (`docs/`), English + Simplified Chinese READMEs, and CI running
152
168
  ruff, pytest (Python 3.11/3.12/3.13), and an example smoke test.
153
169
 
154
- [Unreleased]: https://github.com/ethan-sun-dev/runspool/compare/v0.2.0...HEAD
170
+ [Unreleased]: https://github.com/ethan-sun-dev/runspool/compare/v0.2.1...HEAD
171
+ [0.2.1]: https://github.com/ethan-sun-dev/runspool/compare/v0.2.0...v0.2.1
155
172
  [0.2.0]: https://github.com/ethan-sun-dev/runspool/compare/v0.1.0...v0.2.0
156
173
  [0.1.0]: https://github.com/ethan-sun-dev/runspool/releases/tag/v0.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: runspool
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A local-first CLI workflow engine for reliable personal automation.
5
5
  Project-URL: Homepage, https://github.com/ethan-sun-dev/runspool
6
6
  Project-URL: Repository, https://github.com/ethan-sun-dev/runspool
@@ -6,7 +6,11 @@ Example plugins (`plugins/runspool-example-*`) are not published.
6
6
  ## 1. Prepare
7
7
 
8
8
  - Set the version in `pyproject.toml` (RunSpool) and in each published plugin's
9
- `pyproject.toml` (`plugins/runspool-wechat`). Plugins declare the RunSpool range
9
+ `pyproject.toml` (`plugins/runspool-wechat`). A plugin with no changes may keep its
10
+ version: the publish workflow skips files already on PyPI. That also means a
11
+ plugin changed *without* a version bump is silently not published: check
12
+ `git diff vPREVIOUS -- plugins/<name>` before keeping a version. The workflow
13
+ does fail when the release tag does not match RunSpool's own version. Plugins declare the RunSpool range
10
14
  they support, e.g. `runspool>=0.2,<0.3`; RunSpool refuses to load a plugin outside
11
15
  its range (a profile can exempt an exact version with `allow`).
12
16
  - Move the `[Unreleased]` notes in `CHANGELOG.md` under the new version and date,
@@ -46,7 +46,8 @@ runspool add ./chapter-2 --parent 1 --workflow translate
46
46
 
47
47
  - `<input>` — the task input (e.g. a file or directory path). An input that
48
48
  names an existing file or directory is stored as an absolute path.
49
- - `-w/--workflow NAME` — workflow to use (default `local_file`).
49
+ - `-w/--workflow NAME` — workflow to use (default: the profile's `default_workflow`,
50
+ else `local_file`).
50
51
  - `--name NAME` — human-readable label (defaults to a value derived by the first
51
52
  step, e.g. the file stem).
52
53
  - `--meta KEY=VALUE` — task metadata, repeatable. Values are strings; steps read
@@ -266,6 +266,7 @@ The events RunSpool itself dispatches:
266
266
  | --- | --- | --- |
267
267
  | `step/pre-execute` | waterfall | `(request, next)`: decides whether a claimed step may run |
268
268
  | `approval/request` | emit | `(task)`: a task has started waiting for approval |
269
+ | `daemon/tick` | emit | `(tick)`: the daemon finished a scheduling round (`DaemonTick`: `round`, `now`) |
269
270
  | `internal/status` | emit | `(fiber)`: a plugin's lifecycle state changed (kernel internal) |
270
271
 
271
272
  `step/pre-execute` and `approval/request` are dispatched on the worker thread
@@ -336,6 +337,30 @@ notification plugin listens to it:
336
337
  ctx.on("approval/request", lambda task: notify_owner(f"task {task['id']} awaits approval"))
337
338
  ```
338
339
 
340
+ ### `daemon/tick`
341
+
342
+ The resident daemon emits it once per round, after scheduling, with a
343
+ `runspool.daemon.DaemonTick` (`round`: 1, 2, ... for this daemon run; `now`:
344
+ `time.time()`). It is the place for periodic maintenance: sweeping a cache,
345
+ rotating files, reporting. `run` and `run_until_idle` do not emit it.
346
+
347
+ Listeners run on the daemon's thread between rounds, so keep them quick and
348
+ throttle them yourself; hand long work to a thread. One that raises is logged and
349
+ skipped. A daily sweep:
350
+
351
+ ```python
352
+ last_day = None
353
+
354
+ def sweep(tick):
355
+ global last_day
356
+ day = time.strftime("%Y-%m-%d", time.localtime(tick.now))
357
+ if day != last_day: # once a day, and once after each daemon start
358
+ last_day = day
359
+ remove_older_than(cache_dir, days=7)
360
+
361
+ ctx.on("daemon/tick", sweep)
362
+ ```
363
+
339
364
  ## Side effects and approvals
340
365
 
341
366
  Set `side_effect = True` on a step whose effects leave RunSpool: publishing,
@@ -42,6 +42,7 @@ concurrency: # per-step quota (default 1)
42
42
  ingest_file: 4
43
43
 
44
44
  first_task_id: 1000 # number new tasks from here (never lowers it)
45
+ default_workflow: local_file # what `runspool add` uses without --workflow
45
46
 
46
47
  workflows:
47
48
  local_file:
@@ -67,6 +68,9 @@ patch: [] # insert / modify / disable plugin entries
67
68
  (`ready/<id>/`). A relative path is resolved against the config file's
68
69
  directory, so the same profile means the same workspace whichever directory a
69
70
  command or the daemon starts in.
71
+ - **default_workflow** — the workflow `runspool add` uses when `--workflow` is not
72
+ given (default `local_file`). It may name a workflow a plugin contributes;
73
+ `runspool doctor` reports it if no such workflow is defined.
70
74
  - **scheduler.max_retries** — the retry budget stamped onto each new task. Set it
71
75
  to `0` to make the first failure terminal (it becomes `manual_required`
72
76
  immediately) — useful when failures mean "bad input", not "transient glitch".
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "runspool"
7
- version = "0.2.0"
7
+ version = "0.2.1"
8
8
  description = "A local-first CLI workflow engine for reliable personal automation."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -9,6 +9,11 @@ readable as JSON for scripts and AI agents.
9
9
 
10
10
  from __future__ import annotations
11
11
 
12
- __version__ = "0.1.0"
12
+ from importlib import metadata
13
+
14
+ try: # the installed distribution is the single source of the version
15
+ __version__ = metadata.version("runspool")
16
+ except metadata.PackageNotFoundError: # a bare source tree
17
+ __version__ = "0+unknown"
13
18
 
14
19
  __all__ = ["__version__"]
@@ -105,7 +105,12 @@ def init(
105
105
  @app.command()
106
106
  def add(
107
107
  input: str = typer.Argument(..., help="The task input (e.g. a file path)."),
108
- workflow: str = typer.Option("local_file", "--workflow", "-w", help="Workflow name."),
108
+ workflow: str = typer.Option(
109
+ None,
110
+ "--workflow",
111
+ "-w",
112
+ help="Workflow name (default: the profile's default_workflow, else local_file).",
113
+ ),
109
114
  name: str = typer.Option(None, "--name", help="Human-readable task name."),
110
115
  force: bool = typer.Option(False, "--force", help="Allow a duplicate active task."),
111
116
  parent: int = typer.Option(None, "--parent", help="Id of the task this one derives from."),
@@ -121,9 +126,15 @@ def add(
121
126
  typer.echo(f"--meta expects KEY=VALUE, got {item!r}")
122
127
  raise typer.Exit(1)
123
128
  metadata[key] = value
129
+ ctx = _ctx()
130
+ if workflow is None: # only when -w is absent: an empty -w "" is still an error
131
+ workflow = ctx.config.add_workflow()
132
+ if workflow not in ctx.config.workflows:
133
+ typer.echo(f"undefined workflow: {workflow}")
134
+ raise typer.Exit(1)
124
135
  try:
125
136
  task_id = commands.add_task(
126
- _ctx(),
137
+ ctx,
127
138
  input,
128
139
  workflow=workflow,
129
140
  name=name,
@@ -136,8 +147,10 @@ def add(
136
147
  raise typer.Exit(1) from exc
137
148
  except KeyError as exc:
138
149
  missing = exc.args[0] if exc.args else exc
139
- what = "parent task" if missing == parent else "workflow"
140
- typer.echo(f"undefined {what}: {missing}")
150
+ if parent is not None and missing == parent:
151
+ typer.echo(f"undefined parent task: {parent}")
152
+ else:
153
+ typer.echo(f"error: missing {missing!r}")
141
154
  raise typer.Exit(1) from exc
142
155
  typer.echo(f"Created task {task_id}")
143
156
 
@@ -73,6 +73,9 @@ class AppConfig(BaseModel):
73
73
  # Number new tasks from here on (never lowers the numbering). For a database that
74
74
  # continues an older system whose task ids are used as keys elsewhere.
75
75
  first_task_id: int | None = Field(default=None, ge=1)
76
+ # The workflow `runspool add` uses when no --workflow is given (default: local_file).
77
+ # Plugins may contribute it, so it is checked by doctor rather than at load time.
78
+ default_workflow: str | None = Field(default=None, min_length=1)
76
79
  steps: dict[str, StepPluginConfig] = Field(default_factory=dict)
77
80
 
78
81
  # Directory of the config file (set by load()); used to resolve plugin_paths.
@@ -112,6 +115,10 @@ class AppConfig(BaseModel):
112
115
  raise KeyError(f"undefined workflow: {name!r}")
113
116
  return WorkflowDef(name=name, steps=self.workflows[name].steps)
114
117
 
118
+ def add_workflow(self) -> str:
119
+ """The workflow a new task gets when none is named."""
120
+ return self.default_workflow or "local_file"
121
+
115
122
  def step_quota(self, step: str) -> int:
116
123
  return self.concurrency.get(step, 1)
117
124
 
@@ -72,7 +72,15 @@ class DoctorService:
72
72
  checks.append(Check("database", db_ok, db_detail))
73
73
 
74
74
  n_workflows = len(config.workflows)
75
- checks.append(Check("workflows", n_workflows > 0, f"{n_workflows} defined"))
75
+ detail = f"{n_workflows} defined"
76
+ ok = n_workflows > 0
77
+ if config.default_workflow is not None:
78
+ if config.default_workflow in config.workflows:
79
+ detail += f"; default: {config.default_workflow}"
80
+ else:
81
+ ok = False
82
+ detail += f"; default_workflow {config.default_workflow!r} is not defined"
83
+ checks.append(Check("workflows", ok, detail))
76
84
 
77
85
  # Every step referenced by a workflow resolves (built-in or plugin), and every
78
86
  # lazily registered step imports. This catches typos and broken plugin imports
@@ -90,6 +90,7 @@ class RuntimeService:
90
90
  self.store = store
91
91
  self.steps = steps
92
92
  self._startup = startup
93
+ self._ctx = ctx
93
94
  self.gate = Gate(ctx, steps) if ctx is not None else None
94
95
 
95
96
  def ensure_ready(self) -> None:
@@ -130,8 +131,16 @@ class RuntimeService:
130
131
  return Coordinator(store.repo, store.log, registry, runner, pool, self.config)
131
132
 
132
133
  def build_daemon(self) -> Daemon:
134
+ """The resident daemon. After each round it emits ``daemon/tick`` with a
135
+ :class:`~runspool.daemon.DaemonTick`, so plugins can do periodic maintenance
136
+ (sweep caches, rotate files). Listeners run on the daemon thread: keep them
137
+ quick, throttle them yourself (``tick.now``), and hand long work to a thread.
138
+ A listener that raises is logged and skipped. ``run`` / ``run_until_idle``
139
+ do not emit it: it belongs to the long-running process."""
133
140
  self.ensure_ready()
134
- return Daemon(self.build_coordinator(), self.config)
141
+ ctx = self._ctx
142
+ on_tick = (lambda tick: ctx.emit("daemon/tick", tick)) if ctx is not None else None
143
+ return Daemon(self.build_coordinator(), self.config, on_tick=on_tick)
135
144
 
136
145
  def recover(self) -> None:
137
146
  any_workflow = next(iter(self.config.workflows))
@@ -5,6 +5,9 @@ from __future__ import annotations
5
5
 
6
6
  import logging
7
7
  import threading
8
+ import time
9
+ from collections.abc import Callable
10
+ from dataclasses import dataclass
8
11
  from pathlib import Path
9
12
  from typing import Any
10
13
 
@@ -26,11 +29,32 @@ def read_pid(pid_file: Path) -> int | None:
26
29
  return None
27
30
 
28
31
 
32
+ @dataclass(frozen=True)
33
+ class DaemonTick:
34
+ """One daemon round, after its scheduling work: what ``daemon/tick`` carries.
35
+
36
+ ``round`` counts from 1 for each daemon run; ``now`` is ``time.time()``.
37
+ """
38
+
39
+ round: int
40
+ now: float
41
+
42
+
29
43
  class Daemon:
30
- def __init__(self, coordinator: Coordinator, config: Any) -> None:
44
+ def __init__(
45
+ self,
46
+ coordinator: Coordinator,
47
+ config: Any,
48
+ *,
49
+ on_tick: Callable[[DaemonTick], None] | None = None,
50
+ ) -> None:
31
51
  self.coordinator = coordinator
32
52
  self.config = config
53
+ # Called once per round after scheduling (maintenance hooks). It runs on the
54
+ # daemon's own thread, so it must return quickly.
55
+ self.on_tick = on_tick
33
56
  self._stop = threading.Event()
57
+ self._round = 0
34
58
 
35
59
  def recover(self) -> None:
36
60
  # Startup recovery: recover_interrupted only changes task_status / lock
@@ -53,6 +77,16 @@ class Daemon:
53
77
  self.coordinator.reclaim_stale(timeout)
54
78
  self.coordinator.tick()
55
79
 
80
+ def _tick(self) -> None:
81
+ if self.on_tick is None:
82
+ return
83
+ self._round += 1
84
+ # Maintenance must never stop scheduling: log and carry on.
85
+ try:
86
+ self.on_tick(DaemonTick(round=self._round, now=time.time()))
87
+ except Exception: # noqa: BLE001 - a broken hook must not kill the daemon
88
+ logger.exception("daemon maintenance hook failed; continuing")
89
+
56
90
  def request_stop(self) -> None:
57
91
  self._stop.set()
58
92
 
@@ -70,6 +104,7 @@ class Daemon:
70
104
  self.run_once()
71
105
  except Exception: # noqa: BLE001 - tolerate transient errors
72
106
  logger.exception("daemon tick failed; continuing")
107
+ self._tick()
73
108
  self._stop.wait(timeout=interval)
74
109
  finally:
75
110
  self.coordinator.pool.shutdown(wait=True)
@@ -186,7 +186,7 @@ def test_add_unknown_workflow(tmp_path):
186
186
  cfg = _init(tmp_path)
187
187
  result = _invoke(cfg, "add", "x", "--workflow", "nope")
188
188
  assert result.exit_code == 1
189
- assert "undefined workflow" in result.output
189
+ assert result.output.strip() == "undefined workflow: nope"
190
190
 
191
191
 
192
192
  def test_wake_runs_a_deferred_task_now(tmp_path):
@@ -262,3 +262,52 @@ def test_bad_engine_settings_are_reported_without_echoing_values(tmp_path):
262
262
  assert result.exit_code == 1
263
263
  assert "max_retries" in result.output and "sekrit" not in result.output
264
264
  assert "Traceback" not in result.output
265
+
266
+
267
+ def _profile_with_default(tmp_path, default):
268
+ cfg = tmp_path / "runspool.yaml"
269
+ cfg.write_text(
270
+ f"workspace_root: {tmp_path / 'ws'}\n"
271
+ f"default_workflow: {default}\n"
272
+ "workflows:\n notes:\n steps: [ingest_file, archive]\n",
273
+ encoding="utf-8",
274
+ )
275
+ return cfg
276
+
277
+
278
+ def test_add_uses_the_profile_default_workflow(tmp_path):
279
+ cfg = _profile_with_default(tmp_path, "notes")
280
+ src = tmp_path / "a.txt"
281
+ src.write_text("hi", encoding="utf-8")
282
+ assert _invoke(cfg, "add", str(src)).exit_code == 0
283
+ assert json.loads(_invoke(cfg, "status", "1", "--json").output)["workflow"] == "notes"
284
+ doctor = _invoke(cfg, "doctor")
285
+ assert "default: notes" in doctor.output
286
+
287
+
288
+ def test_an_undefined_default_workflow_is_reported(tmp_path):
289
+ cfg = _profile_with_default(tmp_path, "nope")
290
+ src = tmp_path / "a.txt"
291
+ src.write_text("hi", encoding="utf-8")
292
+ result = _invoke(cfg, "add", str(src))
293
+ assert result.exit_code == 1 and "undefined workflow: nope" in result.output
294
+ doctor = _invoke(cfg, "doctor")
295
+ assert doctor.exit_code == 1
296
+ assert "default_workflow 'nope' is not defined" in doctor.output
297
+
298
+
299
+ def test_without_a_default_add_still_uses_local_file(tmp_path):
300
+ cfg = _init(tmp_path)
301
+ src = tmp_path / "a.txt"
302
+ src.write_text("hi", encoding="utf-8")
303
+ assert _invoke(cfg, "add", str(src)).exit_code == 0
304
+ assert json.loads(_invoke(cfg, "status", "1", "--json").output)["workflow"] == "local_file"
305
+
306
+
307
+ def test_an_empty_workflow_option_is_an_error_not_the_default(tmp_path):
308
+ cfg = _profile_with_default(tmp_path, "notes")
309
+ src = tmp_path / "a.txt"
310
+ src.write_text("hi", encoding="utf-8")
311
+ result = _invoke(cfg, "add", str(src), "--workflow", "")
312
+ assert result.exit_code == 1
313
+ assert result.output.strip() == "undefined workflow:"
@@ -48,3 +48,13 @@ def test_plugin_import_alias_and_base_dir(tmp_path):
48
48
  )
49
49
  assert cfg.steps["custom"].import_target == "mod:Cls"
50
50
  assert cfg.resolved_plugin_paths() == [tmp_path.resolve() / "steps"]
51
+
52
+
53
+ def test_package_version_matches_the_project():
54
+ import tomllib
55
+ from pathlib import Path
56
+
57
+ import runspool
58
+
59
+ project = tomllib.loads((Path(__file__).parents[1] / "pyproject.toml").read_text("utf-8"))
60
+ assert runspool.__version__ == project["project"]["version"]
@@ -0,0 +1,89 @@
1
+ """The daemon's maintenance hook: ``daemon/tick`` once per round, never fatal."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import threading
7
+ from types import SimpleNamespace
8
+
9
+ from runspool.app import load_context
10
+ from runspool.daemon import Daemon, DaemonTick
11
+
12
+
13
+ class _Coordinator:
14
+ def __init__(self, *, fail: bool = False) -> None:
15
+ self.fail = fail
16
+ self.ticks = 0
17
+ self.pool = SimpleNamespace(shutdown=lambda wait=True: None)
18
+
19
+ def reclaim_stale(self, timeout):
20
+ pass
21
+
22
+ def tick(self):
23
+ self.ticks += 1
24
+ if self.fail:
25
+ raise RuntimeError("scheduling broke")
26
+
27
+
28
+ def _config():
29
+ return SimpleNamespace(
30
+ worker_pool=SimpleNamespace(heartbeat_timeout_seconds=60),
31
+ scheduler=SimpleNamespace(poll_interval_seconds=0),
32
+ )
33
+
34
+
35
+ def _run(daemon: Daemon, *, until) -> None:
36
+ daemon.recover = lambda: None # no store here
37
+ thread = threading.Thread(target=daemon.run, kwargs={"poll_interval_seconds": 0.001})
38
+ thread.start()
39
+ for _ in range(2000):
40
+ if until():
41
+ break
42
+ threading.Event().wait(0.001)
43
+ daemon.request_stop()
44
+ thread.join(timeout=5)
45
+ assert not thread.is_alive()
46
+
47
+
48
+ def test_each_round_ends_with_a_tick():
49
+ seen: list[DaemonTick] = []
50
+ coordinator = _Coordinator()
51
+ daemon = Daemon(coordinator, _config(), on_tick=seen.append)
52
+ _run(daemon, until=lambda: len(seen) >= 3)
53
+ assert [t.round for t in seen[:3]] == [1, 2, 3]
54
+ assert seen[0].now <= seen[2].now
55
+ assert coordinator.ticks >= 3
56
+
57
+
58
+ def test_a_failing_hook_or_round_does_not_stop_the_daemon(caplog):
59
+ calls = []
60
+
61
+ def hook(tick):
62
+ calls.append(tick.round)
63
+ raise ValueError("hook broke")
64
+
65
+ with caplog.at_level(logging.ERROR):
66
+ daemon = Daemon(_Coordinator(fail=True), _config(), on_tick=hook)
67
+ _run(daemon, until=lambda: len(calls) >= 3)
68
+ assert calls[:3] == [1, 2, 3] # the hook still runs after a failed round
69
+ assert "maintenance hook failed" in caplog.text
70
+ assert "daemon tick failed" in caplog.text
71
+
72
+
73
+ def test_the_runtime_daemon_emits_daemon_tick_to_plugins(tmp_path, caplog):
74
+ profile = tmp_path / "runspool.yaml"
75
+ profile.write_text(f"workspace_root: {tmp_path / 'ws'}\n", encoding="utf-8")
76
+ ctx = load_context(profile)
77
+ seen = []
78
+
79
+ def broken(tick):
80
+ raise RuntimeError("listener broke")
81
+
82
+ ctx.kernel.root.plugin(lambda c, config: c.on("daemon/tick", broken))
83
+ ctx.kernel.root.plugin(lambda c, config: c.on("daemon/tick", seen.append))
84
+ daemon = ctx.service("runtime").build_daemon()
85
+ tick = DaemonTick(round=1, now=0.0)
86
+ with caplog.at_level(logging.ERROR):
87
+ daemon.on_tick(tick)
88
+ assert seen == [tick] # one listener failing does not starve the others
89
+ assert "listener broke" in caplog.text
@@ -1,28 +0,0 @@
1
- name: Publish
2
-
3
- on:
4
- workflow_dispatch:
5
- release:
6
- types: [published]
7
-
8
- jobs:
9
- publish:
10
- runs-on: ubuntu-latest
11
- permissions:
12
- contents: read
13
- id-token: write
14
-
15
- steps:
16
- - uses: actions/checkout@v4
17
-
18
- - name: Install uv
19
- uses: astral-sh/setup-uv@v5
20
-
21
- # RunSpool and its official plugin(s). Example plugins are not published.
22
- - name: Build distributions
23
- run: |
24
- uv build --package runspool -o dist
25
- uv build --package runspool-wechat -o dist
26
-
27
- - name: Publish to PyPI
28
- uses: pypa/gh-action-pypi-publish@release/v1
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes