asgard 0.4.0 → 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 +80 -0
- data/CLAUDE.md +10 -1
- data/README.md +41 -1
- data/docs/api.md +21 -0
- data/docs/index.md +1 -0
- data/docs/schedule.md +6 -1
- 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/commands.rb +102 -36
- data/lib/asgard/schedule/declaration.rb +61 -0
- data/lib/asgard/schedule/launchd.rb +55 -0
- data/lib/asgard/schedule/systemd.rb +47 -0
- data/lib/asgard/schedule/table.rb +79 -0
- data/lib/asgard/schedule.rb +1 -0
- 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 +7 -2
- data/mkdocs.yml +1 -0
- metadata +65 -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,84 @@ 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
|
+
|
|
46
|
+
## [0.4.1] - 2026-10-02
|
|
47
|
+
|
|
48
|
+
### Added
|
|
49
|
+
|
|
50
|
+
- **`asgard schedule list --all` (`-a`)** — lists every project's scheduled
|
|
51
|
+
entries, one table per project under a
|
|
52
|
+
`Project: name (/full/path/to/project)` header. Other projects' `.loki`
|
|
53
|
+
files are not loaded: their COMMAND and SCHEDULE columns, and the project
|
|
54
|
+
path, are read back from the installed launchd plist or systemd unit files.
|
|
55
|
+
Outside any `.loki` tree, `asgard schedule list` shows every project
|
|
56
|
+
automatically instead of failing with "no .loki file found".
|
|
57
|
+
- **`stop`, `start`, `trigger` and `log` accept entries of other projects**, so
|
|
58
|
+
they work from any directory. A bare name matches this project's entry first,
|
|
59
|
+
then any other project's; when two other projects share a name, write it as
|
|
60
|
+
`project/name`.
|
|
61
|
+
- A project-scoped `list` with nothing installed now ends with
|
|
62
|
+
"Use `asgard schedule list --all` to see all currently scheduled tasks."
|
|
63
|
+
- Backend API: `installed_entries` (every project's entries),
|
|
64
|
+
`installed_columns(name)` and `installed_directory(name)` (read from the job
|
|
65
|
+
files) on both the launchd and systemd backends.
|
|
66
|
+
- Runtime dependency: `tty-table` (~> 0.12), which brings `pastel` and
|
|
67
|
+
`tty-screen`.
|
|
68
|
+
|
|
69
|
+
### Changed
|
|
70
|
+
|
|
71
|
+
- **`asgard schedule list` prints a table** — NAME, COMMAND, SCHEDULE, STATE and
|
|
72
|
+
LAST EXIT, with long cells wrapped to the terminal width, replacing one text
|
|
73
|
+
line per entry. A run of hourly times collapses
|
|
74
|
+
(`10:00-17:00 hourly weekdays`). Color is used on a terminal and turned off
|
|
75
|
+
by `NO_COLOR` or when piped.
|
|
76
|
+
- The not-installed messages from `stop`, `start`, `trigger` and `log` point at
|
|
77
|
+
`asgard schedule list --all`.
|
|
78
|
+
|
|
79
|
+
### Fixed
|
|
80
|
+
|
|
81
|
+
- Scheduled entries were invisible outside the directory tree of the project
|
|
82
|
+
that installed them: `list` and every per-entry command were scoped to the
|
|
83
|
+
project found from the current directory, so `launchctl list` showed jobs
|
|
84
|
+
that `asgard schedule list` did not.
|
|
85
|
+
|
|
8
86
|
## [0.4.0] - 2026-09-26
|
|
9
87
|
|
|
10
88
|
### Added
|
|
@@ -291,6 +369,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
291
369
|
- 100% test coverage enforced via SimpleCov (95% minimum threshold)
|
|
292
370
|
- Quality task in `.loki` runs flog after tests
|
|
293
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
|
|
294
374
|
[0.4.0]: https://github.com/MadBomber/asgard/compare/v0.3.3...v0.4.0
|
|
295
375
|
[0.3.3]: https://github.com/MadBomber/asgard/compare/v0.2.0...v0.3.3
|
|
296
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>
|
|
@@ -626,7 +627,7 @@ Then manage them with the built-in `schedule` command:
|
|
|
626
627
|
```bash
|
|
627
628
|
asgard schedule preview # job files install would write
|
|
628
629
|
asgard schedule install # load declared entries; drop undeclared ones
|
|
629
|
-
asgard schedule list #
|
|
630
|
+
asgard schedule list # this project's entries, state, last exit (--all: every project's)
|
|
630
631
|
asgard schedule stop NAME # stop one entry (persists across reboots and installs)
|
|
631
632
|
asgard schedule start NAME # start a stopped entry, or install just this one
|
|
632
633
|
asgard schedule trigger NAME # run an installed entry now
|
|
@@ -634,11 +635,50 @@ asgard schedule log NAME [-f] # print (or follow) the entry's log
|
|
|
634
635
|
asgard schedule remove # remove all of this project's entries
|
|
635
636
|
```
|
|
636
637
|
|
|
638
|
+
`stop`, `start`, `trigger` and `log` also take an entry of another project
|
|
639
|
+
(`list --all` shows them); write `project/name` when two projects share a name.
|
|
640
|
+
|
|
637
641
|
Jobs run `asgard <task> [options]` from the directory holding `.loki`, with
|
|
638
642
|
the `PATH` captured at install time; a project `.envrc` is loaded at run time
|
|
639
643
|
through `direnv exec`. See [Scheduled Tasks](https://madbomber.github.io/asgard/schedule/)
|
|
640
644
|
for every keyword, entry naming, and file locations.
|
|
641
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
|
+
|
|
642
682
|
## `method_option` types reference
|
|
643
683
|
|
|
644
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/schedule.md
CHANGED
|
@@ -65,7 +65,7 @@ end
|
|
|
65
65
|
```bash
|
|
66
66
|
asgard schedule preview # print the job files install would write
|
|
67
67
|
asgard schedule install # load declared entries; drop entries no longer declared
|
|
68
|
-
asgard schedule list #
|
|
68
|
+
asgard schedule list # this project's entries, state, last exit (--all: every project's)
|
|
69
69
|
asgard schedule stop NAME # stop one entry (stays stopped across reboots and installs)
|
|
70
70
|
asgard schedule start NAME # start a stopped entry, or install just this one
|
|
71
71
|
asgard schedule trigger NAME # run an installed entry now, under the scheduler
|
|
@@ -73,6 +73,11 @@ asgard schedule log NAME [-f] # print the entry's log (-f keeps following it)
|
|
|
73
73
|
asgard schedule remove # unload and delete all of this project's entries
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
+
`stop`, `start`, `trigger` and `log` also accept an entry of another project, so
|
|
77
|
+
they work from any directory. Use the name shown by `list --all`; if two projects
|
|
78
|
+
have an entry with the same name, write it as `project/name` (this project's own
|
|
79
|
+
entry wins a bare name).
|
|
80
|
+
|
|
76
81
|
Re-run `asgard schedule install` after changing declarations or your `PATH`.
|
|
77
82
|
|
|
78
83
|
---
|
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
|