asgard 0.4.1 → 0.5.0
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.
- checksums.yaml +4 -4
- data/.loki +3 -0
- data/.reek.yml +12 -0
- data/CHANGELOG.md +40 -0
- data/CLAUDE.md +10 -1
- data/README.md +37 -0
- data/docs/api.md +21 -0
- data/docs/index.md +1 -0
- data/docs/watch.md +123 -0
- data/examples/.envrc +3 -0
- data/examples/watcher_examples/.envrc +3 -0
- data/examples/watcher_examples/.loki +54 -0
- data/examples/watcher_examples/README.md +112 -0
- data/examples/watcher_examples/bin/hello +3 -0
- data/examples/watcher_examples/data/records.json +1 -0
- data/examples/watcher_examples/docs/drafts/ideas.md +5 -0
- data/examples/watcher_examples/docs/index.md +4 -0
- data/examples/watcher_examples/lib/greeter.rb +7 -0
- data/lib/asgard/kernel_methods.rb +6 -0
- data/lib/asgard/schedule/table.rb +9 -8
- data/lib/asgard/tasks.rb +9 -0
- data/lib/asgard/version.rb +1 -1
- data/lib/asgard/watch/changes.rb +52 -0
- data/lib/asgard/watch/commands.rb +211 -0
- data/lib/asgard/watch/daemon.rb +123 -0
- data/lib/asgard/watch/declaration.rb +173 -0
- data/lib/asgard/watch/dispatcher.rb +55 -0
- data/lib/asgard/watch/reporter.rb +110 -0
- data/lib/asgard/watch/spawn.rb +75 -0
- data/lib/asgard/watch/state.rb +104 -0
- data/lib/asgard/watch/worker.rb +75 -0
- data/lib/asgard/watch.rb +51 -0
- data/lib/asgard.rb +3 -3
- data/mkdocs.yml +1 -0
- metadata +50 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e81b8d0d4ce7904a7483a06348d488730f258171af1d0d6897a99fc058829d91
|
|
4
|
+
data.tar.gz: a25c7c2dccc05df8ac533a17a118f93361aa8eabbb6ad8fde36af65088107cb2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ee9be83865e0fa5a50e5fac52e6d720e7d76b3da4f56790d8badb708a93d603d30628c5a960f1aca209f030f89b34c726f151caa3b6c337be4e69b1c00c15fb1
|
|
7
|
+
data.tar.gz: 9741a4dcac57f091e00a5f6b84f1cd70d5c5e9c04b2059a8483edbb17166fca17762d38d6161eea6fd294e09fcc129394fce88c58c53a7d240d1eb664ca5d750
|
data/.loki
CHANGED
|
@@ -19,6 +19,9 @@ class Tasks
|
|
|
19
19
|
@@project ||= "asgard".freeze
|
|
20
20
|
@@project_desc ||= "CLI-task runner"
|
|
21
21
|
|
|
22
|
+
# Dogfood: `asgard watcher start` reruns the tests whenever a Ruby file changes.
|
|
23
|
+
watch :test_check, paths: %w[lib test], only: /\.rb\z/
|
|
24
|
+
|
|
22
25
|
desc "xyzzy"
|
|
23
26
|
def xyzzy = puts "everything in Ruby metaprogramming is magic"
|
|
24
27
|
|
data/.reek.yml
CHANGED
|
@@ -21,6 +21,8 @@ detectors:
|
|
|
21
21
|
# Default is 4 — Doctor legitimately tracks several pieces of diagnostic state.
|
|
22
22
|
TooManyInstanceVariables:
|
|
23
23
|
max_instance_variables: 6
|
|
24
|
+
exclude:
|
|
25
|
+
- Asgard::Watch::Daemon # wires reporter, dispatcher, listeners, inbox and the stop flag for one run
|
|
24
26
|
|
|
25
27
|
# Accept standard Ruby short-name conventions for limited-scope variables.
|
|
26
28
|
UncommunicativeVariableName:
|
|
@@ -57,6 +59,7 @@ detectors:
|
|
|
57
59
|
- Asgard::Shell#sh
|
|
58
60
|
- Asgard::Shell#shebang
|
|
59
61
|
- Asgard::Schedule#entry_name # as: is the user's explicit override of the derived name
|
|
62
|
+
- Asgard::Watch#normalize # same: as: overrides the task name as the entry name
|
|
60
63
|
|
|
61
64
|
DuplicateMethodCall:
|
|
62
65
|
exclude:
|
|
@@ -114,9 +117,11 @@ detectors:
|
|
|
114
117
|
- Asgard::Base::Dispatch#with_dep_results # same: stashes results on Thread.current, not self
|
|
115
118
|
- Asgard::Shell#shell_argv # extracted pure on purpose so it's testable without invoking system/exec
|
|
116
119
|
- Asgard::Schedule::DSL#schedule # class-level DSL delegating to the Schedule registry
|
|
120
|
+
- Asgard::Watch::DSL#watch # same, for the Watch registry
|
|
117
121
|
- Asgard::Schedule::Commands#schedules # Thor no_commands helpers: reads of the registry / CWD shared by the subcommands
|
|
118
122
|
- Asgard::Schedule::Commands#schedule_root
|
|
119
123
|
- Asgard::Schedule::Commands#schedule_summary
|
|
124
|
+
- Asgard::Watch::Commands#watches # same: the Watch registry read by every subcommand
|
|
120
125
|
|
|
121
126
|
# Asgard::Schedule: the backends implement a fixed API (see
|
|
122
127
|
# lib/asgard/schedule/declaration.rb) plus pure, separately tested helpers,
|
|
@@ -125,16 +130,21 @@ detectors:
|
|
|
125
130
|
DataClump:
|
|
126
131
|
exclude:
|
|
127
132
|
- Asgard::Schedule # at:/on: and task/args are the declaration's vocabulary
|
|
133
|
+
- Asgard::Watch::Spawn # spec + context are the two inputs of every spawn function
|
|
128
134
|
|
|
129
135
|
LongParameterList:
|
|
130
136
|
exclude:
|
|
131
137
|
- Asgard::Schedule#normalize # the `schedule` DSL keywords
|
|
138
|
+
- Asgard::Watch#normalize # the `watch` DSL keywords
|
|
132
139
|
- Asgard::Schedule#describe
|
|
133
140
|
- Asgard::Schedule#program_arguments
|
|
141
|
+
- Asgard::Schedule::Table#self.draw # rows plus the title, io and headers the caller chooses
|
|
134
142
|
- Asgard::Schedule::Launchd#self.plist # one keyword per plist field
|
|
135
143
|
- Asgard::Schedule::Systemd#self.service_unit # one keyword per unit field
|
|
136
144
|
- Asgard::Schedule::Systemd#self.timer_unit
|
|
137
145
|
- Asgard::Schedule::Systemd#initialize # injectable home/runner/env/user for tests
|
|
146
|
+
- Asgard::Watch::Dispatcher#initialize # injectable worker starter and clock for tests
|
|
147
|
+
- Asgard::Watch::Daemon#initialize # injectable listen/exec/out for tests
|
|
138
148
|
|
|
139
149
|
RepeatedConditional:
|
|
140
150
|
exclude:
|
|
@@ -143,8 +153,10 @@ detectors:
|
|
|
143
153
|
TooManyMethods:
|
|
144
154
|
exclude:
|
|
145
155
|
- Asgard::Schedule::Commands # one method per subcommand plus its helpers
|
|
156
|
+
- Asgard::Watch::Commands # same
|
|
146
157
|
- Asgard::Schedule::Launchd # backend API + pure helpers
|
|
147
158
|
- Asgard::Schedule::Systemd
|
|
159
|
+
- Asgard::Watch::State # one reader/writer pair per file it keeps
|
|
148
160
|
|
|
149
161
|
|
|
150
162
|
exclude_paths:
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.5.0] - Unreleased
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **Watched files, built in** — `watch :task, paths: "lib/**/*.rb"` at class
|
|
13
|
+
level in a `.loki` declares that a task runs when matching files change,
|
|
14
|
+
with `only:`, `ignore:`, `events:`, `debounce:`, `env:` and `as:` keywords.
|
|
15
|
+
`asgard watcher start|stop|list|preview|log|run` manages the watcher: one
|
|
16
|
+
background process per project that runs each trigger as `asgard <task>`
|
|
17
|
+
from the project root, with a fixed environment (a whitelist of the
|
|
18
|
+
daemon's variables, the entry's `env:`, `.envrc` through `direnv exec`) plus
|
|
19
|
+
the changed paths in `ASGARD_MODIFIED`, `ASGARD_ADDED` and `ASGARD_REMOVED`.
|
|
20
|
+
Different tasks run concurrently; the same task never overlaps itself, and a
|
|
21
|
+
trigger that arrives while its task runs is queued behind it. Saving a
|
|
22
|
+
`.loki` file restarts the watcher with the new declarations. `stop` lets a
|
|
23
|
+
running task finish. `list` shows each entry's last run; `--all` covers
|
|
24
|
+
every project. State and logs live under `~/.local/state/asgard/watch/`.
|
|
25
|
+
The command is registered as the gem-owned `_watcher` and mapped to
|
|
26
|
+
`watcher`. The daemon and every trigger run the same `asgard` executable
|
|
27
|
+
that started the watcher, by absolute path, even under `direnv exec`.
|
|
28
|
+
See [Watched Files](https://madbomber.github.io/asgard/watch/).
|
|
29
|
+
- **`changes`** Kernel method — inside a task, the `Asgard::Changes` the
|
|
30
|
+
watcher triggered it with (`modified`, `added`, `removed`, `all`,
|
|
31
|
+
`empty?`); every list is empty when the task is run by hand.
|
|
32
|
+
- **`Asgard::Watch::Error`** (subclass of `Asgard::Error`) — raised when a
|
|
33
|
+
`watch` names a task the project does not define.
|
|
34
|
+
|
|
35
|
+
### Changed
|
|
36
|
+
|
|
37
|
+
- New runtime dependencies: `listen` (file events) and `ractor_queue` (the
|
|
38
|
+
queue between the daemon and its worker Ractors). `ractor_queue` is a
|
|
39
|
+
native extension, so `gem install asgard` now compiles and needs a C++17
|
|
40
|
+
compiler; Asgard was pure Ruby before.
|
|
41
|
+
- `Asgard::Schedule::Table.draw` takes `headers:` so `watcher list` can
|
|
42
|
+
reuse it; a `running` state renders green.
|
|
43
|
+
- `Asgard.machine_wide?` is true for `watcher list` as well as
|
|
44
|
+
`schedule list`, so both work from a directory with no `.loki` above it.
|
|
45
|
+
|
|
8
46
|
## [0.4.1] - 2026-10-02
|
|
9
47
|
|
|
10
48
|
### Added
|
|
@@ -331,6 +369,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
331
369
|
- 100% test coverage enforced via SimpleCov (95% minimum threshold)
|
|
332
370
|
- Quality task in `.loki` runs flog after tests
|
|
333
371
|
|
|
372
|
+
[0.5.0]: https://github.com/MadBomber/asgard/compare/v0.4.1...v0.5.0
|
|
373
|
+
[0.4.1]: https://github.com/MadBomber/asgard/compare/v0.4.0...v0.4.1
|
|
334
374
|
[0.4.0]: https://github.com/MadBomber/asgard/compare/v0.3.3...v0.4.0
|
|
335
375
|
[0.3.3]: https://github.com/MadBomber/asgard/compare/v0.2.0...v0.3.3
|
|
336
376
|
[0.2.0]: https://github.com/MadBomber/asgard/compare/v0.1.2...v0.2.0
|
data/CLAUDE.md
CHANGED
|
@@ -47,6 +47,14 @@ Single file: `ruby -Ilib:test test/test_asgard.rb` (a single file alone will fal
|
|
|
47
47
|
| `lib/asgard/schedule/declaration.rb` | Pure declaration validation + the backend API contract |
|
|
48
48
|
| `lib/asgard/schedule/launchd.rb`, `systemd.rb` | Platform backends; shell out through an injectable runner |
|
|
49
49
|
| `lib/asgard/schedule/commands.rb` | `asgard schedule ...` subcommands (registered as `_schedule`, mapped to `schedule`) |
|
|
50
|
+
| `lib/asgard/watch.rb` | `Asgard::Watch` — the `watch` DSL registry; requires the files below |
|
|
51
|
+
| `lib/asgard/watch/declaration.rb` | Pure declaration validation: globs to directories plus `only:` Regexps, events, ignores |
|
|
52
|
+
| `lib/asgard/watch/changes.rb` | `Asgard::Changes` (the trigger's path lists) and `Watch.filter_events` |
|
|
53
|
+
| `lib/asgard/watch/spawn.rb` | Pure: the frozen, Ractor-shareable description of one task run (argv, whitelisted env) |
|
|
54
|
+
| `lib/asgard/watch/worker.rb` | One Ractor per task; runs its triggers serially and reports each spawn and exit |
|
|
55
|
+
| `lib/asgard/watch/reporter.rb`, `dispatcher.rb` | Single writer of log and entry records; batch routing to worker queues |
|
|
56
|
+
| `lib/asgard/watch/state.rb`, `daemon.rb` | Pidfile, log and records under XDG state; the foreground `watcher run` process |
|
|
57
|
+
| `lib/asgard/watch/commands.rb` | `asgard watcher ...` subcommands (registered as `_watcher`, mapped to `watcher`) |
|
|
50
58
|
|
|
51
59
|
### Naming Convention for Gem-Owned Methods
|
|
52
60
|
|
|
@@ -98,6 +106,7 @@ Asgard adds the following `module_function` methods to `Kernel`, making them ava
|
|
|
98
106
|
| Method | Description |
|
|
99
107
|
|--------|-------------|
|
|
100
108
|
| `env(name, default = nil)` | Fetch a system environment variable by symbol or string; name is upcased automatically. Raises `KeyError` when missing and no default given. |
|
|
109
|
+
| `changes` | The `Asgard::Changes` a watcher trigger passed in (`modified`, `added`, `removed`, `all`, `empty?`); every list is empty when the task is run by hand. |
|
|
101
110
|
| `loki_up(name = ".loki")` | Walk CWD and ancestors for a file by name; returns absolute path or `nil`. |
|
|
102
111
|
| `import(path)` | Load a `.loki` file or glob of `.loki` files, idempotently. |
|
|
103
112
|
| `import_up(name = ".loki")` | Combine `loki_up` and `import` — find and load in one call. |
|
|
@@ -106,7 +115,7 @@ Asgard adds the following `module_function` methods to `Kernel`, making them ava
|
|
|
106
115
|
|
|
107
116
|
## Testing
|
|
108
117
|
|
|
109
|
-
Engine tests are in `test/test_asgard.rb`; scheduling tests are in `test/test_schedule.rb`, which uses a `FakeRunner` (records launchctl/systemctl argv) so both backends test on any platform. SimpleCov minimum is 95%; the Rakefile configures this with a prelude that loads coverage before the library.
|
|
118
|
+
Engine tests are in `test/test_asgard.rb`; scheduling tests are in `test/test_schedule.rb`, which uses a `FakeRunner` (records launchctl/systemctl argv) so both backends test on any platform. Watcher tests are in `test/test_watcher.rb`: pure functions directly, the worker and reporter loops in threads with recording stand-ins, and a few real-process tests (a real Ractor worker, a real daemon with a fake Listen, one real Listen run) with no mocks. SimpleCov minimum is 95%; the Rakefile configures this with a prelude that loads coverage before the library.
|
|
110
119
|
|
|
111
120
|
Key test patterns: tests frequently subclass `Asgard::Base` directly (not `Tasks`) to test the engine in isolation, and use `capture_io` for output assertions.
|
|
112
121
|
|
data/README.md
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- <strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories<br>
|
|
25
25
|
- <strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files, loaded via <code>import</code> from your <code>.loki</code><br>
|
|
26
26
|
- <strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code><br>
|
|
27
|
+
- <strong>Watched Files</strong> — run any task when files change with <code>watch</code> and <code>asgard watcher start</code>; the task reads what changed through <code>changes</code><br>
|
|
27
28
|
- <strong>Built-in Flags</strong> — <code>--debug</code>, <code>--verbose</code>, <code>--version</code>, and <code>--doctor</code> built-in class options; header/footer DSL for static help text<br>
|
|
28
29
|
</td>
|
|
29
30
|
</tr>
|
|
@@ -642,6 +643,42 @@ the `PATH` captured at install time; a project `.envrc` is loaded at run time
|
|
|
642
643
|
through `direnv exec`. See [Scheduled Tasks](https://madbomber.github.io/asgard/schedule/)
|
|
643
644
|
for every keyword, entry naming, and file locations.
|
|
644
645
|
|
|
646
|
+
## Watched files
|
|
647
|
+
|
|
648
|
+
Any task can run when files change. Declare what each task watches at class
|
|
649
|
+
level in `.loki`:
|
|
650
|
+
|
|
651
|
+
```ruby
|
|
652
|
+
class Tasks
|
|
653
|
+
watch :lint, paths: "lib/**/*.rb"
|
|
654
|
+
watch :test, paths: %w[lib test], only: /\.rb\z/
|
|
655
|
+
watch :reindex, paths: "data/**/*.json", events: %i[added removed]
|
|
656
|
+
|
|
657
|
+
desc "lint", "Lint whatever changed"
|
|
658
|
+
def lint
|
|
659
|
+
files = changes.modified + changes.added # empty when run by hand
|
|
660
|
+
sh "rubocop #{files.shelljoin}" unless files.empty?
|
|
661
|
+
end
|
|
662
|
+
end
|
|
663
|
+
```
|
|
664
|
+
|
|
665
|
+
Then manage the watcher with the built-in `watcher` command:
|
|
666
|
+
|
|
667
|
+
```bash
|
|
668
|
+
asgard watcher start # watch this project in the background
|
|
669
|
+
asgard watcher stop # stop it; a task already running finishes first
|
|
670
|
+
asgard watcher list # watches, daemon state, each entry's last run (--all: every project's)
|
|
671
|
+
asgard watcher preview # the command and environment each watch would run
|
|
672
|
+
asgard watcher log [-f] # print (or follow) the watcher's log
|
|
673
|
+
```
|
|
674
|
+
|
|
675
|
+
Each trigger runs `asgard <task>` from the project root with a fixed
|
|
676
|
+
environment plus the changed paths in `ASGARD_MODIFIED`, `ASGARD_ADDED` and
|
|
677
|
+
`ASGARD_REMOVED`. Different tasks run concurrently; the same task never
|
|
678
|
+
overlaps itself. Saving a `.loki` file restarts the watcher with the new
|
|
679
|
+
declarations. See [Watched Files](https://madbomber.github.io/asgard/watch/)
|
|
680
|
+
for every keyword, the environment, and file locations.
|
|
681
|
+
|
|
645
682
|
## `method_option` types reference
|
|
646
683
|
|
|
647
684
|
| Type | CLI example | Ruby value |
|
data/docs/api.md
CHANGED
|
@@ -40,6 +40,7 @@ These methods are defined as `module_function` on `Kernel` and are therefore ava
|
|
|
40
40
|
| `loki_up` | `loki_up(name = ".loki") → Pathname, nil` | `Pathname` or `nil` | Searches `Dir.pwd` and each ancestor directory for a file named `name`. Returns a `Pathname` for the first match, or `nil` if not found. Exact filenames only — does not expand globs. |
|
|
41
41
|
| `import` | `import(path) → true, false` | `true` if any file newly loaded | Loads one `.loki` file or a glob of `.loki` files. Relative paths resolve relative to the caller's file (like `require_relative`). Idempotent via `$LOADED_FEATURES`. Raises `ArgumentError` if `path` does not end with `.loki`. Raises `LoadError` if a non-glob path does not exist. |
|
|
42
42
|
| `import_up` | `import_up(name = ".loki") → true, false` | `true` if any file newly loaded | Combines `loki_up` and `import`. Walks ancestors to find the file or glob match, then loads it. Returns `false` if nothing is found. |
|
|
43
|
+
| `changes` | `changes → Asgard::Changes` | `Asgard::Changes` | The files the watcher saw change when it triggered this task: `modified`, `added`, `removed`, `all`, `empty?`. Read once per process from `ASGARD_MODIFIED`, `ASGARD_ADDED` and `ASGARD_REMOVED`; every list is empty when the task is run by hand. See [`Asgard::Watch`](#asgardwatch). |
|
|
43
44
|
| `debug?` | `debug? → true, false` | `$DEBUG` | Returns the current value of `$DEBUG`. Set to `true` by `--debug` on the CLI or directly via `$DEBUG = true`. |
|
|
44
45
|
| `verbose?` | `verbose? → true, false` | `$VERBOSE` | Returns the current value of `$VERBOSE`. Set to `true` by `--verbose` on the CLI or directly via `$VERBOSE = true`. |
|
|
45
46
|
| `env` | `env(name, default = nil) → String, nil` | `ENV` value or default | Fetches an environment variable by symbol or string name. The name is upcased automatically. Raises `KeyError` when the variable is missing and no default is given. |
|
|
@@ -202,6 +203,8 @@ See [Dynamic Dependencies](dependencies.md#dynamic-dependencies-proc-block-form)
|
|
|
202
203
|
| `verbose?` | Kernel module function | Returns `$VERBOSE`. Available everywhere via `Kernel`. |
|
|
203
204
|
| `schedule` | class method (DSL) | `schedule :task, at:/every:, ...` — declares a scheduled entry. Extended from `Asgard::Schedule::DSL`; see [`Asgard::Schedule`](#asgardschedule). |
|
|
204
205
|
| `_schedule` | subcommand | `asgard schedule SUBCOMMAND`, backed by `Asgard::Schedule::Commands`. `map "schedule" => :_schedule` keeps the typed name `schedule`, and a user task named `schedule` cannot shadow it. |
|
|
206
|
+
| `watch` | class method (DSL) | `watch :task, paths: ...` — declares a watched entry. Extended from `Asgard::Watch::DSL`; see [`Asgard::Watch`](#asgardwatch). |
|
|
207
|
+
| `_watcher` | subcommand | `asgard watcher SUBCOMMAND`, backed by `Asgard::Watch::Commands`. `map "watcher" => :_watcher` keeps the typed name `watcher`. |
|
|
205
208
|
|
|
206
209
|
---
|
|
207
210
|
|
|
@@ -287,6 +290,23 @@ Both backends, `Asgard::Schedule::Launchd` and `Asgard::Schedule::Systemd`, impl
|
|
|
287
290
|
|
|
288
291
|
---
|
|
289
292
|
|
|
293
|
+
## `Asgard::Watch`
|
|
294
|
+
|
|
295
|
+
Watched files. The user-facing guide is [Watched Files](watch.md); this section covers the Ruby API.
|
|
296
|
+
|
|
297
|
+
| Method | Signature | Description |
|
|
298
|
+
|---|---|---|
|
|
299
|
+
| `declare` | `Asgard::Watch.declare(task, **settings) → Hash` | What `Tasks.watch` calls. Validates via `normalize` and records the entry. Redeclaring an identical entry is a no-op; a different entry under the same name raises `ArgumentError`. |
|
|
300
|
+
| `declarations` | `Asgard::Watch.declarations → Hash` | Declared entries for this run, keyed by entry name. |
|
|
301
|
+
| `normalize` | `Asgard::Watch.normalize(task, paths:, only: nil, ignore: nil, events: :all, debounce: 0.5, env: {}, as: nil) → Hash` | Validates one declaration and returns `{ name:, task:, paths:, dirs:, only:, ignore:, events:, debounce:, env: }`: the directories listen watches and one `only:` Regexp (or nil) built from the globs and the declared filter. Pure, as are its helpers `split_glob`, `glob_regexp`, `combine_only` and `normalize_events`. |
|
|
302
|
+
| `filter_events` | `Asgard::Watch.filter_events(batch, events) → Asgard::Changes, nil` | A listener batch narrowed to the entry's `events:`; `nil` when nothing is left. |
|
|
303
|
+
|
|
304
|
+
`Asgard::Changes` is a `Data` class with `modified`, `added` and `removed` path lists, `all`, `empty?`, `to_env` (the three trigger variables) and `Changes.from_env(env = ENV)`. `Changes.current` is the per-process memo behind the `changes` Kernel method; `Changes.forget` clears it.
|
|
305
|
+
|
|
306
|
+
The daemon is assembled from small classes, each testable alone: `Spawn` builds the frozen, Ractor-shareable description of one run (argv, whitelisted environment, root, log, changes); `Worker` runs one task's triggers serially in its own Ractor and reports every spawn and exit; `Dispatcher` routes listener batches to the worker queues; `Reporter` is the single writer of the log and the per-entry records and tracks in-flight pids; `State` owns the pidfile, log, recorded declarations and entry records under the XDG state directory; `Daemon` wires them to listen and handles signals and reloads.
|
|
307
|
+
|
|
308
|
+
---
|
|
309
|
+
|
|
290
310
|
## `Asgard::Base` Internal Class Methods
|
|
291
311
|
|
|
292
312
|
These are implementation details exposed for extensibility. Prefer the DSL methods above in normal use.
|
|
@@ -319,6 +339,7 @@ These are implementation details exposed for extensibility. Prefer the DSL metho
|
|
|
319
339
|
|---|---|---|
|
|
320
340
|
| `Asgard::Error` | `StandardError` | Base error class for all Asgard errors. |
|
|
321
341
|
| `Asgard::Schedule::Error` | `Asgard::Error` | Raised when a `launchctl`/`systemctl` command fails, or on an unsupported platform. `run!` reports it as `asgard: <message>`. |
|
|
342
|
+
| `Asgard::Watch::Error` | `Asgard::Error` | Raised when the watcher cannot run: a `watch` names a task the project does not define. `run!` reports it as `asgard: <message>`. |
|
|
322
343
|
| `Asgard::CircularDependencyError` | `Asgard::Error` | Raised by `validate_deps!` when a cycle is detected in the dependency graph. `run!` catches this and calls `abort` with a clean message. |
|
|
323
344
|
|
|
324
345
|
```ruby
|
data/docs/index.md
CHANGED
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
<li><strong>Auto-Discovery</strong> — <code>.loki</code> root marker searched from CWD upward through parent directories</li>
|
|
20
20
|
<li><strong>Multi-File Tasks</strong> — split tasks across <code>*.loki</code> files loaded via <code>import</code></li>
|
|
21
21
|
<li><strong>Scheduled Tasks</strong> — run any task periodically under launchd (macOS) or systemd timers (Linux) with <code>schedule</code> and <code>asgard schedule install</code></li>
|
|
22
|
+
<li><strong>Watched Files</strong> — run any task when files change with <code>watch</code> and <code>asgard watcher start</code>; the task reads what changed through <code>changes</code></li>
|
|
22
23
|
<li><strong>Built-in Flags</strong> — <code>--version</code>, <code>--debug</code>, <code>--verbose</code>, and <code>--doctor</code> available on every task</li>
|
|
23
24
|
</ul>
|
|
24
25
|
</td>
|
data/docs/watch.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Watched Files
|
|
2
|
+
|
|
3
|
+
Asgard can run a task whenever files change. You declare what each task watches in the project's own `.loki`, and `asgard watcher start` runs a background process that listens for changes and runs the matching tasks, handing each one the list of changed files in a known, reproducible environment.
|
|
4
|
+
|
|
5
|
+
Watching is built into the gem, so there is nothing to import or require. File events come from the [listen](https://github.com/guard/listen) gem: native adapters on macOS and Linux, polling elsewhere.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Declaring Watches
|
|
10
|
+
|
|
11
|
+
Call `watch` at class level inside `Tasks`:
|
|
12
|
+
|
|
13
|
+
```ruby
|
|
14
|
+
# .loki
|
|
15
|
+
class Tasks
|
|
16
|
+
desc "lint", "Lint whatever changed"
|
|
17
|
+
def lint
|
|
18
|
+
files = changes.modified + changes.added
|
|
19
|
+
sh "rubocop #{files.shelljoin}" unless files.empty?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
watch :lint, paths: "lib/**/*.rb"
|
|
23
|
+
watch :test, paths: %w[lib test], only: /\.rb\z/, ignore: %r{\Afixtures/}
|
|
24
|
+
watch :docs, paths: "docs", env: { BUILD_ENV: "dev" }
|
|
25
|
+
watch :reindex, paths: "data/**/*.json", events: %i[added removed]
|
|
26
|
+
watch :lint, paths: "bin/**/*", as: "lint-bin"
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Keyword | Meaning |
|
|
31
|
+
|---------|---------|
|
|
32
|
+
| `paths:` | A directory, a file, or a glob relative to the project root, or an Array of them |
|
|
33
|
+
| `only:` | Regexp or Array of Regexps a changed path must match (relative to the watched directory) |
|
|
34
|
+
| `ignore:` | Regexp or Array of Regexps that silence a path, added to the defaults below |
|
|
35
|
+
| `events:` | Which kinds of change count: `:changed`, `:added`, `:removed`, or `:all` (default); `:modified` means `:changed` |
|
|
36
|
+
| `debounce:` | Seconds to wait after the last event before the task runs (default 0.5) |
|
|
37
|
+
| `env:` | `{ "KEY" => "value" }`: environment variables added to the task |
|
|
38
|
+
| `as:` | The entry's name (letters, digits, `_ . -`) |
|
|
39
|
+
|
|
40
|
+
### Entry Names
|
|
41
|
+
|
|
42
|
+
Every entry has a name, shown by `list` and in the log. It defaults to the task name. To watch one task from two declarations, give the second an `as:` name; declaring two *different* entries with the same name raises an error.
|
|
43
|
+
|
|
44
|
+
### Paths, Patterns and Filters
|
|
45
|
+
|
|
46
|
+
A glob such as `lib/**/*.rb` watches `lib` and keeps the paths that match the pattern, the way a shell would: `**` spans directories, `*` and `?` stay within one, `{a,b}` is an alternative. A plain file watches its directory and keeps just that name, so a file that does not exist yet can still be watched. A bare directory takes everything beneath it.
|
|
47
|
+
|
|
48
|
+
`only:` combines with the patterns: a path must match one of the patterns *and* one of the `only:` expressions. Several patterns in one declaration are alternatives, and because the filter applies to every directory the declaration watches, `%w[lib/**/*.rb docs/**/*.md]` also accepts a `.md` file under `lib`.
|
|
49
|
+
|
|
50
|
+
A bare directory cannot share a declaration with a pattern or a file, because the pattern would silently filter the directory too. Write the directory as `dir/**/*`, or declare it separately.
|
|
51
|
+
|
|
52
|
+
### Default Ignores
|
|
53
|
+
|
|
54
|
+
Every watch ignores `.git/`, `coverage/`, `pkg/`, `tmp/`, `log/` and `node_modules/` at any depth, plus listen's own defaults, so a task that writes its output inside the project never retriggers itself.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## Managing the Watcher
|
|
59
|
+
|
|
60
|
+
`watcher` is also a command with subcommands:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
asgard watcher start # start the watcher for this project in the background
|
|
64
|
+
asgard watcher stop # stop it; a task already running finishes first
|
|
65
|
+
asgard watcher list # this project's watches, daemon state, each entry's last run (--all: every project's)
|
|
66
|
+
asgard watcher preview # the command and environment each watch would run
|
|
67
|
+
asgard watcher log [-f] # print (or follow) the watcher's log
|
|
68
|
+
asgard watcher run # watch in the foreground (what start runs detached)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The watcher watches its own `.loki` files too. Save one, and the running watcher restarts itself with the new declarations.
|
|
72
|
+
|
|
73
|
+
`stop` never signals a running task. It stops listening, drops triggers that have not started, waits for in-flight tasks to exit (naming them while it waits), then exits. A task that hangs keeps `stop` waiting; kill the task and `stop` completes.
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## How Tasks Run
|
|
78
|
+
|
|
79
|
+
A trigger runs `asgard <task>` from the project root as a separate process, using the very `asgard` executable that started the watcher, by absolute path, so a different version installed elsewhere on `PATH` is never picked up. Different tasks run concurrently. The same task never overlaps itself: a trigger that arrives while its task is still running waits, and the log says `queued behind pid N`. Save three times during a long test run and three more runs follow, one at a time.
|
|
80
|
+
|
|
81
|
+
Two declarations for the same task that both match a file run that task twice, back to back. A task's own `depends_on` runs inside its process as usual; the watcher does not collapse dependencies across entries.
|
|
82
|
+
|
|
83
|
+
### The Environment
|
|
84
|
+
|
|
85
|
+
The task sees the same environment whether the watcher was started from an interactive shell or from a service. Nothing else leaks in from the daemon.
|
|
86
|
+
|
|
87
|
+
| Layer | Contents |
|
|
88
|
+
|---|---|
|
|
89
|
+
| Base | `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `LANG`, `LC_*`, `TMPDIR`, `SSH_AUTH_SOCK`, captured when the watcher starts |
|
|
90
|
+
| Declared | The entry's `env:`; a project `.envrc` through `direnv exec` when direnv is on `PATH`; a `.env` through your `.loki`'s own `dotenv` call |
|
|
91
|
+
| Trigger | `ASGARD_WATCH` (the entry name), `ASGARD_WATCH_ROOT`, and the changed paths in `ASGARD_MODIFIED`, `ASGARD_ADDED` and `ASGARD_REMOVED`, one path per line, relative to the root |
|
|
92
|
+
|
|
93
|
+
### The `changes` Accessor
|
|
94
|
+
|
|
95
|
+
Inside a task, `changes` returns what triggered it:
|
|
96
|
+
|
|
97
|
+
```ruby
|
|
98
|
+
changes.modified # => ["lib/asgard/base.rb"]
|
|
99
|
+
changes.added # => []
|
|
100
|
+
changes.removed # => []
|
|
101
|
+
changes.all # => every path once, in that order
|
|
102
|
+
changes.empty? # => true when the task was run by hand
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Every list is empty when the task is run from the command line, so one task body serves both.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
109
|
+
## Files and Logs
|
|
110
|
+
|
|
111
|
+
The watcher keeps its state outside the repository under `~/.local/state/asgard/watch/<project>/` (honors `XDG_STATE_HOME`): the pidfile, `watcher.log` with one line per trigger, spawn and exit plus every task's output, the declarations recorded at `start`, and one JSON record per entry with its last run. `asgard watcher list --all` reads those records for every project without loading any `.loki`.
|
|
112
|
+
|
|
113
|
+
Running the watcher as a launchd or systemd service is not yet built in; `asgard watcher run` is the foreground process a service would keep alive.
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Requirements
|
|
118
|
+
|
|
119
|
+
Watching needs Ruby 3.2 or later. The gem depends on `listen` and on `ractor_queue`, a native extension, so `gem install asgard` compiles it and needs a C++17 compiler on the machine.
|
|
120
|
+
|
|
121
|
+
## Name Collisions
|
|
122
|
+
|
|
123
|
+
The built-in command is registered as `_watcher` and mapped to `watcher`, following Asgard's `_` convention for gem-owned tasks. If your `.loki` defines its own `watcher` task, `asgard watcher` still dispatches to the built-in command.
|
data/examples/.envrc
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# examples/watcher_examples/.loki
|
|
2
|
+
# A small project whose tasks run when its files change.
|
|
3
|
+
#
|
|
4
|
+
# `watch :task, paths: ...` at class level says which files a task cares
|
|
5
|
+
# about. `asgard watcher start` runs one background process for this
|
|
6
|
+
# project that listens for changes and runs the matching tasks, each as a
|
|
7
|
+
# separate `asgard <task>` process started from this directory. The task
|
|
8
|
+
# reads what changed through `changes`.
|
|
9
|
+
#
|
|
10
|
+
# Run with (from this directory):
|
|
11
|
+
# asgard watcher start # background; log and state under ~/.local/state/asgard/watch/
|
|
12
|
+
# asgard watcher list # the four entries, daemon state, each entry's last run
|
|
13
|
+
# # ... edit lib/greeter.rb, docs/index.md, add or remove a data/*.json ...
|
|
14
|
+
# asgard watcher log # one line per trigger, spawn and exit, plus each task's output
|
|
15
|
+
# asgard watcher stop # a task still running finishes first
|
|
16
|
+
#
|
|
17
|
+
# Each task also runs by hand (`asgard lint`), when `changes` is empty.
|
|
18
|
+
# See README.md here for a walkthrough with expected output.
|
|
19
|
+
|
|
20
|
+
class Tasks
|
|
21
|
+
# A glob: watches lib/ and keeps the paths that match, like a shell would.
|
|
22
|
+
watch :lint, paths: "lib/**/*.rb"
|
|
23
|
+
|
|
24
|
+
# A directory with filters. only: narrows to Markdown; ignore: silences
|
|
25
|
+
# the drafts folder; env: adds a variable the task can read with env().
|
|
26
|
+
watch :docs, paths: "docs", only: /\.md\z/, ignore: %r{\Adrafts/}, env: { BUILD_ENV: "demo" }
|
|
27
|
+
|
|
28
|
+
# events: picks which kinds of change count. Editing a data file does
|
|
29
|
+
# nothing; creating or deleting one triggers a reindex. debounce: waits
|
|
30
|
+
# a full second after the last event, so a batch of files lands at once.
|
|
31
|
+
watch :reindex, paths: "data/**/*.json", events: %i[added removed], debounce: 1
|
|
32
|
+
|
|
33
|
+
# The same task watched from a second declaration needs its own entry
|
|
34
|
+
# name. Both entries share one worker, so lint never overlaps itself.
|
|
35
|
+
watch :lint, paths: "bin/**/*", as: "lint-bin"
|
|
36
|
+
|
|
37
|
+
desc "lint", "Report the Ruby files that changed"
|
|
38
|
+
def lint
|
|
39
|
+
files = changes.modified + changes.added
|
|
40
|
+
return puts "lint: nothing changed (run by hand), so linting everything under lib/" if files.empty?
|
|
41
|
+
|
|
42
|
+
files.each { |file| puts "lint: #{file} (#{File.foreach(file).count} lines) triggered by #{env(:asgard_watch, 'nothing')}" }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
desc "docs", "Rebuild the documentation"
|
|
46
|
+
def docs
|
|
47
|
+
puts "docs: building for #{env(:build_env, 'development')}; changed: #{changes.all.join(', ')}"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
desc "reindex", "Rebuild the data index"
|
|
51
|
+
def reindex
|
|
52
|
+
puts "reindex: #{changes.added.size} added, #{changes.removed.size} removed; #{Dir['data/**/*.json'].size} data file(s) now"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# Watcher example
|
|
2
|
+
|
|
3
|
+
A small project whose tasks run when its files change. The `.loki` here
|
|
4
|
+
declares four watches; `lib/`, `bin/`, `docs/` and `data/` hold one
|
|
5
|
+
placeholder file each so the watcher has something to watch right away.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
The watcher runs its daemon and every triggered task as separate `asgard`
|
|
10
|
+
processes. If you are running asgard from a git checkout of this
|
|
11
|
+
repository rather than an installed gem, install the checkout as the gem
|
|
12
|
+
first, from the repository root:
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
asgard install # builds pkg/asgard-<version>.gem and gem-installs it
|
|
16
|
+
gem list asgard # the checkout's version should be listed
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Otherwise an older asgard gem already on the machine can be the one the
|
|
20
|
+
spawned processes run, and it will not understand `watch`.
|
|
21
|
+
|
|
22
|
+
## Walkthrough
|
|
23
|
+
|
|
24
|
+
From this directory:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
asgard watcher start
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
```
|
|
31
|
+
started watcher for watcher_examples (pid 12345); log: ~/.local/state/asgard/watch/watcher-examples/watcher.log
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Now make some changes and watch the log fill up:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
echo '# a change' >> lib/greeter.rb # lint runs, with the path in changes.modified
|
|
38
|
+
echo 'more' >> docs/index.md # docs runs; BUILD_ENV=demo comes from env:
|
|
39
|
+
echo 'more' >> docs/drafts/ideas.md # nothing: drafts/ is ignored
|
|
40
|
+
echo '{}' > data/new.json # reindex runs: a file was added
|
|
41
|
+
echo '{"edited": true}' > data/new.json # nothing: events: is added and removed only
|
|
42
|
+
rm data/new.json # reindex runs again: a file was removed
|
|
43
|
+
asgard watcher log
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
```
|
|
47
|
+
2026-10-18 23:40:01 trigger lint task=lint lib/greeter.rb
|
|
48
|
+
2026-10-18 23:40:01 spawn lint task=lint pid=12351 lib/greeter.rb
|
|
49
|
+
lint: lib/greeter.rb (7 lines) triggered by lint
|
|
50
|
+
2026-10-18 23:40:02 exit lint task=lint pid=12351 status=0 seconds=0.41
|
|
51
|
+
2026-10-18 23:40:05 trigger docs task=docs docs/index.md
|
|
52
|
+
2026-10-18 23:40:05 spawn docs task=docs pid=12360 docs/index.md
|
|
53
|
+
docs: building for demo; changed: docs/index.md
|
|
54
|
+
2026-10-18 23:40:06 exit docs task=docs pid=12360 status=0 seconds=0.40
|
|
55
|
+
2026-10-18 23:40:09 trigger reindex task=reindex data/new.json
|
|
56
|
+
2026-10-18 23:40:09 spawn reindex task=reindex pid=12371 data/new.json
|
|
57
|
+
reindex: 1 added, 0 removed; 2 data file(s) now
|
|
58
|
+
2026-10-18 23:40:10 exit reindex task=reindex pid=12371 status=0 seconds=0.40
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Each trigger line names the entry and the changed paths. Each spawn and
|
|
63
|
+
exit line carries the task's pid, so the lines of overlapping tasks can be
|
|
64
|
+
told apart; a task's own output sits between its spawn and its exit. Save
|
|
65
|
+
`lib/greeter.rb` twice in quick succession and the second trigger says
|
|
66
|
+
`(queued behind pid N)`: the same task never overlaps itself, while
|
|
67
|
+
different tasks run at the same time.
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
asgard watcher list
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
┌──────────┬────────────────┬──────────────────────────────────────┬──────────┬────────────────────────────────┐
|
|
75
|
+
│ NAME │ TASK │ WATCHES │ DAEMON │ LAST RUN │
|
|
76
|
+
├──────────┼────────────────┼──────────────────────────────────────┼──────────┼────────────────────────────────┤
|
|
77
|
+
│ lint │ asgard lint │ lib/**/*.rb │ running │ 0 (0.4s, 2026-10-18 23:40:02) │
|
|
78
|
+
│ docs │ asgard docs │ docs │ running │ 0 (0.4s, 2026-10-18 23:40:06) │
|
|
79
|
+
│ reindex │ asgard reindex │ data/**/*.json [added, removed] │ running │ 0 (0.4s, 2026-10-18 23:40:14) │
|
|
80
|
+
│ lint-bin │ asgard lint │ bin/**/* │ running │ never │
|
|
81
|
+
└──────────┴────────────────┴──────────────────────────────────────┴──────────┴────────────────────────────────┘
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
asgard watcher stop
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
A task that is still running finishes first; `stop` names it while it
|
|
89
|
+
waits.
|
|
90
|
+
|
|
91
|
+
## Running the tasks by hand
|
|
92
|
+
|
|
93
|
+
Every task works without the watcher too. `changes` is then empty, which
|
|
94
|
+
`lint` turns into "lint everything":
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
asgard lint
|
|
98
|
+
asgard docs
|
|
99
|
+
asgard reindex
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## What the watcher restricts
|
|
103
|
+
|
|
104
|
+
A triggered task does not inherit the shell the watcher was started from.
|
|
105
|
+
It sees `PATH`, `HOME`, `USER`, `LANG` and a few other basics, the entry's
|
|
106
|
+
`env:`, a project `.envrc` through `direnv`, and the trigger variables
|
|
107
|
+
`ASGARD_WATCH`, `ASGARD_WATCH_ROOT`, `ASGARD_MODIFIED`, `ASGARD_ADDED` and
|
|
108
|
+
`ASGARD_REMOVED`. `asgard watcher preview` prints the exact command and
|
|
109
|
+
environment each entry would run.
|
|
110
|
+
|
|
111
|
+
Edit `.loki` while the watcher runs and it restarts itself with the new
|
|
112
|
+
declarations.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{ "records": ["one", "two"] }
|
|
@@ -16,6 +16,12 @@ module Kernel
|
|
|
16
16
|
end
|
|
17
17
|
module_function :env
|
|
18
18
|
|
|
19
|
+
# The files the watcher saw change when it triggered this task, as an
|
|
20
|
+
# Asgard::Changes (modified, added, removed, all). Every list is empty
|
|
21
|
+
# when the task is run by hand.
|
|
22
|
+
def changes = Asgard::Changes.current
|
|
23
|
+
module_function :changes
|
|
24
|
+
|
|
19
25
|
def loki_up(name = ".loki")
|
|
20
26
|
dir = Pathname.new(Dir.pwd)
|
|
21
27
|
loop do
|