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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/.loki +3 -0
  3. data/.reek.yml +12 -0
  4. data/CHANGELOG.md +80 -0
  5. data/CLAUDE.md +10 -1
  6. data/README.md +41 -1
  7. data/docs/api.md +21 -0
  8. data/docs/index.md +1 -0
  9. data/docs/schedule.md +6 -1
  10. data/docs/watch.md +123 -0
  11. data/examples/.envrc +3 -0
  12. data/examples/watcher_examples/.envrc +3 -0
  13. data/examples/watcher_examples/.loki +54 -0
  14. data/examples/watcher_examples/README.md +112 -0
  15. data/examples/watcher_examples/bin/hello +3 -0
  16. data/examples/watcher_examples/data/records.json +1 -0
  17. data/examples/watcher_examples/docs/drafts/ideas.md +5 -0
  18. data/examples/watcher_examples/docs/index.md +4 -0
  19. data/examples/watcher_examples/lib/greeter.rb +7 -0
  20. data/lib/asgard/kernel_methods.rb +6 -0
  21. data/lib/asgard/schedule/commands.rb +102 -36
  22. data/lib/asgard/schedule/declaration.rb +61 -0
  23. data/lib/asgard/schedule/launchd.rb +55 -0
  24. data/lib/asgard/schedule/systemd.rb +47 -0
  25. data/lib/asgard/schedule/table.rb +79 -0
  26. data/lib/asgard/schedule.rb +1 -0
  27. data/lib/asgard/tasks.rb +9 -0
  28. data/lib/asgard/version.rb +1 -1
  29. data/lib/asgard/watch/changes.rb +52 -0
  30. data/lib/asgard/watch/commands.rb +211 -0
  31. data/lib/asgard/watch/daemon.rb +123 -0
  32. data/lib/asgard/watch/declaration.rb +173 -0
  33. data/lib/asgard/watch/dispatcher.rb +55 -0
  34. data/lib/asgard/watch/reporter.rb +110 -0
  35. data/lib/asgard/watch/spawn.rb +75 -0
  36. data/lib/asgard/watch/state.rb +104 -0
  37. data/lib/asgard/watch/worker.rb +75 -0
  38. data/lib/asgard/watch.rb +51 -0
  39. data/lib/asgard.rb +7 -2
  40. data/mkdocs.yml +1 -0
  41. metadata +65 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0fd4fe5903f93e062c0f515b3f6a49b859671c679b066003c04f2012eaf7176c
4
- data.tar.gz: 5af1e1b65fc854258a68ec98a220d8260720474d9d84a7644cecec2fc054889f
3
+ metadata.gz: e81b8d0d4ce7904a7483a06348d488730f258171af1d0d6897a99fc058829d91
4
+ data.tar.gz: a25c7c2dccc05df8ac533a17a118f93361aa8eabbb6ad8fde36af65088107cb2
5
5
  SHA512:
6
- metadata.gz: eb251bfc200af2b94dc66e6247475f2a4c0d2a474d0f1d4b165caf05250c8dbb55bd31295b2c0b7e963aeace88f2b8f2c2ecb8751271a522ea5c7a99486bccfe
7
- data.tar.gz: c2540f86218ca5fe471ca276081d23224b2bf4f5784862915bf69f21c83acf664ef57f051bb4587a7104d0bb0bf82e768f28acc9b5f3e2cc32d15eac902dbf94
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 # installed entries, state, last exit status
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 # installed entries, schedule, state, last exit status
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,3 @@
1
+ # asgard/examples/.envrc
2
+
3
+ source_up
@@ -0,0 +1,3 @@
1
+ # asgard/examples/watcher_examples/.envrc
2
+
3
+ source_up
@@ -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