automatic 14.12.2 → 26.09

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 (177) hide show
  1. checksums.yaml +5 -5
  2. data/README.md +669 -83
  3. data/VERSION +1 -1
  4. data/assets/siteinfo/items_all.json +60300 -52138
  5. data/automatic.gemspec +110 -248
  6. data/bin/automatic +20 -139
  7. data/config/feed2console.yml +10 -4
  8. data/config/feed2markdown.yml +41 -0
  9. data/doc/AI_TUTORIAL.md +542 -0
  10. data/doc/BASIC_DESIGN.md +547 -0
  11. data/doc/COPYING.LESSER +165 -0
  12. data/doc/DEPLOYMENT.md +830 -0
  13. data/doc/LICENSE.md +14 -0
  14. data/doc/PLUGINS.md +2015 -0
  15. data/doc/PLUGIN_DEVELOPMENT.md +86 -0
  16. data/doc/POLICY.md +962 -0
  17. data/doc/QUICKSTART.md +260 -0
  18. data/doc/RELEASING.md +394 -0
  19. data/doc/REQUIREMENTS.md +556 -0
  20. data/doc/VERSIONS +266 -0
  21. data/lib/automatic/cli.rb +270 -0
  22. data/lib/automatic/environment.rb +31 -5
  23. data/lib/automatic/feed_maker.rb +65 -25
  24. data/lib/automatic/feed_parser.rb +51 -35
  25. data/lib/automatic/http.rb +107 -0
  26. data/lib/automatic/log.rb +49 -18
  27. data/lib/automatic/opml.rb +3 -1
  28. data/lib/automatic/pipeline.rb +73 -32
  29. data/lib/automatic/recipe.rb +100 -17
  30. data/lib/automatic/version.rb +14 -1
  31. data/lib/automatic.rb +93 -20
  32. data/plugins/custom_feed/svn_log.rb +73 -32
  33. data/plugins/custom_feed/web.rb +348 -0
  34. data/plugins/filter/absolute_uri.rb +43 -27
  35. data/plugins/filter/accept.rb +38 -45
  36. data/plugins/filter/batch.rb +97 -0
  37. data/plugins/filter/claude.rb +217 -0
  38. data/plugins/filter/clear.rb +12 -8
  39. data/plugins/filter/description_link.rb +66 -51
  40. data/plugins/filter/full_feed.rb +170 -52
  41. data/plugins/filter/gemini.rb +216 -0
  42. data/plugins/filter/github_feed.rb +38 -26
  43. data/plugins/filter/ignore.rb +33 -44
  44. data/plugins/filter/image.rb +36 -25
  45. data/plugins/filter/image_source.rb +75 -52
  46. data/plugins/filter/join.rb +105 -0
  47. data/plugins/filter/kimi.rb +216 -0
  48. data/plugins/filter/limit.rb +57 -0
  49. data/plugins/filter/one.rb +19 -26
  50. data/plugins/filter/open_ai.rb +198 -0
  51. data/plugins/filter/present.rb +75 -0
  52. data/plugins/filter/rand.rb +16 -17
  53. data/plugins/filter/sakura_ai.rb +205 -0
  54. data/plugins/filter/sanitize.rb +29 -34
  55. data/plugins/filter/sort.rb +20 -27
  56. data/plugins/filter/tumblr_resize.rb +31 -23
  57. data/plugins/notify/ikachan.rb +86 -48
  58. data/plugins/provide/fluentd.rb +43 -24
  59. data/plugins/publish/amazon_s3.rb +79 -40
  60. data/plugins/publish/console.rb +19 -16
  61. data/plugins/publish/console_link.rb +20 -17
  62. data/plugins/publish/eject.rb +48 -26
  63. data/plugins/publish/fluentd.rb +50 -30
  64. data/plugins/publish/hatena_bookmark.rb +88 -71
  65. data/plugins/publish/instapaper.rb +69 -59
  66. data/plugins/publish/markdown.rb +278 -0
  67. data/plugins/publish/memcached.rb +35 -29
  68. data/plugins/store/database.rb +50 -48
  69. data/plugins/store/digest.rb +212 -0
  70. data/plugins/store/file.rb +107 -68
  71. data/plugins/store/full_text.rb +31 -28
  72. data/plugins/store/permalink.rb +18 -22
  73. data/plugins/subscription/feed.rb +34 -21
  74. data/plugins/subscription/link.rb +31 -32
  75. data/plugins/subscription/text.rb +40 -45
  76. data/plugins/subscription/tumblr.rb +55 -44
  77. data/plugins/subscription/xml.rb +40 -36
  78. metadata +112 -321
  79. data/Gemfile +0 -38
  80. data/Rakefile +0 -59
  81. data/doc/ChangeLog +0 -303
  82. data/doc/PLUGINS +0 -750
  83. data/doc/PLUGINS.ja +0 -753
  84. data/doc/README +0 -511
  85. data/doc/README.ja +0 -519
  86. data/plugins/filter/google_news.rb +0 -50
  87. data/plugins/publish/google_calendar.rb +0 -84
  88. data/plugins/publish/hipchat.rb +0 -46
  89. data/plugins/publish/pocket.rb +0 -45
  90. data/plugins/publish/twitter.rb +0 -58
  91. data/plugins/subscription/chan_toru.rb +0 -57
  92. data/plugins/subscription/g_guide.rb +0 -57
  93. data/plugins/subscription/pocket.rb +0 -51
  94. data/plugins/subscription/twitter.rb +0 -69
  95. data/plugins/subscription/twitter_search.rb +0 -50
  96. data/plugins/subscription/weather.rb +0 -33
  97. data/script/build +0 -84
  98. data/spec/fixtures/sampleFeeds.tsv +0 -1
  99. data/spec/fixtures/sampleFeeds2.tsv +0 -2
  100. data/spec/fixtures/sampleRecipe.yml +0 -24
  101. data/spec/lib/automatic/log_spec.rb +0 -32
  102. data/spec/lib/automatic/pipeline_spec.rb +0 -68
  103. data/spec/lib/automatic/recipe_spec.rb +0 -40
  104. data/spec/lib/automatic_spec.rb +0 -99
  105. data/spec/plugins/custom_feed/svn_log_spec.rb +0 -31
  106. data/spec/plugins/filter/absolute_uri_spec.rb +0 -61
  107. data/spec/plugins/filter/accept_spec.rb +0 -331
  108. data/spec/plugins/filter/clear_spec.rb +0 -49
  109. data/spec/plugins/filter/description_link_spec.rb +0 -138
  110. data/spec/plugins/filter/full_feed_spec.rb +0 -129
  111. data/spec/plugins/filter/github_feed_spec.rb +0 -55
  112. data/spec/plugins/filter/google_news_spec.rb +0 -69
  113. data/spec/plugins/filter/ignore_spec.rb +0 -328
  114. data/spec/plugins/filter/image_source_spec.rb +0 -89
  115. data/spec/plugins/filter/image_spec.rb +0 -65
  116. data/spec/plugins/filter/one_spec.rb +0 -71
  117. data/spec/plugins/filter/rand_spec.rb +0 -52
  118. data/spec/plugins/filter/sanitize_spec.rb +0 -153
  119. data/spec/plugins/filter/sort_spec.rb +0 -189
  120. data/spec/plugins/filter/tumblr_resize_spec.rb +0 -109
  121. data/spec/plugins/notify/ikachan_spec.rb +0 -58
  122. data/spec/plugins/provide/fluentd_spec.rb +0 -49
  123. data/spec/plugins/publish/amazon_s3_spec.rb +0 -40
  124. data/spec/plugins/publish/console_spec.rb +0 -30
  125. data/spec/plugins/publish/eject_spec.rb +0 -40
  126. data/spec/plugins/publish/fluentd_spec.rb +0 -40
  127. data/spec/plugins/publish/google_calendar_spec.rb +0 -83
  128. data/spec/plugins/publish/hatena_bookmark_spec.rb +0 -134
  129. data/spec/plugins/publish/hipchat_spec.rb +0 -69
  130. data/spec/plugins/publish/instapaper_spec.rb +0 -82
  131. data/spec/plugins/publish/memcached_spec.rb +0 -63
  132. data/spec/plugins/publish/pocket_spec.rb +0 -51
  133. data/spec/plugins/publish/twitter_spec.rb +0 -73
  134. data/spec/plugins/store/file_spec.rb +0 -58
  135. data/spec/plugins/store/full_text_spec.rb +0 -152
  136. data/spec/plugins/store/permalink_spec.rb +0 -206
  137. data/spec/plugins/subscription/chan_toru_spec.rb +0 -56
  138. data/spec/plugins/subscription/feed_spec.rb +0 -71
  139. data/spec/plugins/subscription/g_guide_spec.rb +0 -82
  140. data/spec/plugins/subscription/link_spec.rb +0 -72
  141. data/spec/plugins/subscription/pocket_spec.rb +0 -57
  142. data/spec/plugins/subscription/text_spec.rb +0 -84
  143. data/spec/plugins/subscription/tumblr_spec.rb +0 -74
  144. data/spec/plugins/subscription/twitter_search_spec.rb +0 -57
  145. data/spec/plugins/subscription/twitter_spec.rb +0 -73
  146. data/spec/plugins/subscription/weather_spec.rb +0 -44
  147. data/spec/plugins/subscription/xml_spec.rb +0 -84
  148. data/spec/spec_helper.rb +0 -106
  149. data/spec/user_dir/plugins/store/mock.rb +0 -16
  150. data/test/fixtures/sampleOPML.xml +0 -11
  151. data/test/integration/test_absoluteurl.yml +0 -25
  152. data/test/integration/test_activerecord.yml +0 -24
  153. data/test/integration/test_add_pocket.yml +0 -26
  154. data/test/integration/test_chan_toru.yml +0 -21
  155. data/test/integration/test_descriptionlink.yml +0 -21
  156. data/test/integration/test_fluentd.yml +0 -22
  157. data/test/integration/test_fulltext.yml +0 -30
  158. data/test/integration/test_google_news.yml +0 -21
  159. data/test/integration/test_googlealert.yml +0 -21
  160. data/test/integration/test_hatenabookmark.yml +0 -30
  161. data/test/integration/test_ignore.yml +0 -25
  162. data/test/integration/test_ignore2.yml +0 -22
  163. data/test/integration/test_image2local.yml +0 -33
  164. data/test/integration/test_instapaper.yml +0 -26
  165. data/test/integration/test_link2local.yml +0 -34
  166. data/test/integration/test_one.yml +0 -23
  167. data/test/integration/test_pocket.yml +0 -22
  168. data/test/integration/test_rand.yml +0 -21
  169. data/test/integration/test_sanitize.yml +0 -23
  170. data/test/integration/test_sort.yml +0 -36
  171. data/test/integration/test_svnlog.yml +0 -15
  172. data/test/integration/test_text2feed.yml +0 -36
  173. data/test/integration/test_tumblr2local.yml +0 -43
  174. data/test/integration/test_twitter_search.yml +0 -22
  175. data/test/integration/test_weather.yml +0 -19
  176. data/test/integration/test_xml2fluentd.yml +0 -21
  177. data/vendor/.gitkeep +0 -0
@@ -0,0 +1,556 @@
1
+ # Requirements: a plugin pipeline for unattended processing
2
+
3
+ ## 1. Purpose of this document
4
+
5
+ This document states what Automatic Ruby is for, what it accepts, what it
6
+ produces, and where its responsibility ends. It does not describe how any of it
7
+ is built; that belongs to [`BASIC_DESIGN.md`](BASIC_DESIGN.md), and how a change
8
+ to it is carried out belongs to [`POLICY.md`](POLICY.md). The two public
9
+ interfaces it names — the Recipe and the plugin contract — are specified in
10
+ [`PLUGINS.md`](PLUGINS.md).
11
+
12
+ It stands on its own. Nothing in it is completed by a document kept in another
13
+ repository.
14
+
15
+ Where this document says the software *does* something, that is a statement
16
+ about the present implementation, reconstructed by reading it. Where it says the
17
+ software *shall* do something, that is a requirement a change is held to.
18
+
19
+ ## 2. Name
20
+
21
+ `automaticruby`, published as the RubyGem `automatic` and invoked as the command
22
+ `automatic`. The repository name carries the language because the framework is
23
+ the product: a Ruby program whose behaviour is written in Ruby plugins and
24
+ assembled in a configuration file.
25
+
26
+ ## 3. Purpose
27
+
28
+ A person has a recurring job that consists of acquiring information, processing
29
+ what is useful, optionally persisting a record so it is not handled twice, and
30
+ publishing a portable result. Tomorrow they have another job
31
+ of the same shape with different endpoints. Writing each as a script means
32
+ writing the fetching, the filtering, the de-duplication and the retrying again
33
+ every time.
34
+
35
+ Automatic Ruby exists so that those jobs are *assembled* rather than written. A
36
+ step is a plugin — a small Ruby class with one method. A job is a Recipe — a
37
+ YAML file naming the plugins in order and their settings. Running the job is one
38
+ command, which is safe to put in `cron`.
39
+
40
+ The framework's contribution is exactly three things:
41
+
42
+ - a uniform contract every step obeys, so that steps compose,
43
+ - a value passed from step to step, so that they have something to compose over,
44
+ - a loader that finds a step by name, so that a Recipe can name it.
45
+
46
+ Everything else — what is fetched, what is filtered, what is published — belongs
47
+ to a plugin, and plugins are expected to come and go.
48
+
49
+ ## 4. What it is not
50
+
51
+ - **Not an application.** It has no behaviour of its own. With no Recipe it does
52
+ nothing, and every useful thing it does is a plugin's doing.
53
+ - **Not host infrastructure.** It is not a common system baseline installed to
54
+ provide the same foundational behaviour to every host. It is operator tooling
55
+ for assembling jobs, and the jobs, dependencies and effects are chosen by each
56
+ Recipe.
57
+ - **Not a substitute for a purpose-built application.** The fact that many jobs
58
+ can be expressed as plugin pipelines does not make every problem a framework
59
+ problem. A concrete purpose that needs branching, complex shared state,
60
+ transactions, interactive behaviour, multi-user permissions or tightly
61
+ coupled domain logic belongs in an application rather than in a larger
62
+ Automatic Ruby core.
63
+ - **Not a daemon or a scheduler.** One invocation runs one Recipe once and
64
+ exits. Repetition is `cron`'s job, and periodic running is deliberately left
65
+ outside; see section 15.
66
+ - **Not a web application.** It serves nothing and listens on nothing.
67
+ - **Not a feed reader.** Feeds are the shape the pipeline value happens to take
68
+ (section 8), not the purpose. A Recipe that never touches a feed is a normal
69
+ use of it.
70
+ - **Not a general workflow engine.** There are no branches, no loops, no
71
+ conditions and no fan-out. A Recipe is a straight line, and it stays one.
72
+ - **Not a hosted or multi-tenant system.** It runs as one person, on one
73
+ machine, against that person's own accounts and files.
74
+ - **Not a sandbox.** A Recipe names Ruby classes and those classes run with the
75
+ full privileges of the invoking user. See section 17.
76
+
77
+ ## 5. Who uses it
78
+
79
+ One person, on their own machine or their own server, running their own Recipes
80
+ against their own accounts. They are able to read and write Ruby, because
81
+ writing a plugin is the intended way to extend the system, and the plugin
82
+ contract is small enough that this is a reasonable expectation rather than a
83
+ burden.
84
+
85
+ There is no notion of a second user, no account, no permission and no
86
+ separation. Two people sharing a Recipe share the credentials in it.
87
+
88
+ ## 6. Where it runs
89
+
90
+ - A Unix-like system: GNU/Linux and macOS are what it is used on.
91
+ - A maintained Ruby (section 20), installed either from RubyGems or from a
92
+ source checkout.
93
+ - Unattended, from `cron`, as often as the operator chooses.
94
+
95
+ Windows is not supported. Nothing is known to be deliberately incompatible with
96
+ it, but no plugin, no path handling and no test is written with it in mind, and
97
+ several plugins shell out to Unix commands.
98
+
99
+ ## 7. The two public interfaces
100
+
101
+ Two public contracts define how external Recipes and plugins compose with the
102
+ framework, and they receive the strongest compatibility protection in this
103
+ project. That protection is deliberately narrower than treating every
104
+ implementation detail or every shipped plugin as permanent. The Recipe format
105
+ and the plugin contract are stable composition contracts; framework internals
106
+ may improve within those contracts, and plugins remain replaceable components.
107
+
108
+ ### 7.1 The Recipe
109
+
110
+ A Recipe is a YAML file listing the plugins of one job, in order, each with its
111
+ settings. Its structure is specified in [`PLUGINS.md`](PLUGINS.md) section 2.
112
+
113
+ A Recipe written for an earlier release shall keep working. Recipes live outside
114
+ this repository, in `~/.automatic/config` and in operators' `cron` entries, and
115
+ this repository cannot see them, cannot migrate them and shall not silently
116
+ change what they mean. Removing a key, renaming a key, changing a default so
117
+ that an unchanged Recipe does something else, and changing how a value is
118
+ interpreted are all breaking changes, are made only deliberately, and are
119
+ recorded in [`VERSIONS`](VERSIONS).
120
+
121
+ Adding an optional key with a default that preserves current behaviour is not a
122
+ breaking change.
123
+
124
+ ### 7.2 The plugin contract
125
+
126
+ The contract a plugin class obeys — where it lives, how it is named, how it is
127
+ constructed, what it receives and what it must return — is specified in
128
+ [`PLUGINS.md`](PLUGINS.md) section 3.
129
+
130
+ It is an interface because plugins exist outside this repository, under
131
+ `~/.automatic/plugins`, and the framework loads them by the same rules it loads
132
+ its own. Changing the contract breaks code this repository has never seen.
133
+
134
+ ## 8. The pipeline value
135
+
136
+ Every plugin receives one value and returns one value. That value is the
137
+ pipeline, and it is:
138
+
139
+ > an `Array` of feed objects, where a feed object responds to `#items`, and each
140
+ > item responds to `#title`, `#link`, `#description`, `#date`, `#author`,
141
+ > `#comments`, `#source`, `#enclosure` and `#content_encoded`.
142
+
143
+ In practice the elements are RSS objects produced by Ruby's `RSS::Maker` or by
144
+ `RSS::Parser`. `Automatic::FeedMaker` builds them from plain values, so a plugin
145
+ that acquires something which is not a feed — a row of a TSV file, an API
146
+ response, a weather report — converts it into this shape and the rest of the
147
+ pipeline is unaffected.
148
+
149
+ The single pipeline shape is part of the framework's composition model. It is
150
+ protected for the same reason as the plugin contract: changing it would not
151
+ merely refactor an implementation, but would change what existing plugins can
152
+ compose with. Composability here is an architectural invariant, not an
153
+ optimization preference.
154
+
155
+ This is the framework's one substantive constraint on plugins, and it is what
156
+ makes them compose. Three consequences are requirements:
157
+
158
+ - A plugin shall accept this shape and return this shape. A plugin that returns
159
+ something else ends the pipeline for every plugin after it.
160
+ - `link` may be `nil`. Several filters signal "not applicable" by setting it,
161
+ and a plugin that dereferences it without checking is at fault.
162
+ - The names above are RSS names, and they are used for values that are not RSS.
163
+ `title` may hold a weather condition and `description` may hold an arbitrary
164
+ string. This is a known cost of having one shape, and it is accepted.
165
+
166
+ The value has no schema beyond that, no validation and no version. A plugin that
167
+ needs a field the previous plugin never set gets `nil`.
168
+
169
+ This is an **internal** representation, and it is not a statement about what a
170
+ run produces. A plugin that writes the pipeline out in some other form — a
171
+ Markdown document, a row in a database, a request body — serializes it at the
172
+ moment it leaves the pipeline, and the value the next plugin receives is
173
+ unchanged. An output format is therefore never a second pipeline
174
+ representation, and shall not become one; see section 10.2.
175
+
176
+ ## 9. Input
177
+
178
+ The framework itself reads:
179
+
180
+ - **the Recipe**, named by `-c` (section 11),
181
+ - **the user directory**, `~/.automatic` (section 13),
182
+ - **plugin files**, from the user directory and from the installed package.
183
+
184
+ Everything else is read by a plugin, on its own account. Feeds, web pages, APIs,
185
+ TSV files, XML documents, a Subversion repository and a SQLite database are all
186
+ plugin inputs, not framework inputs.
187
+
188
+ The framework passes no ambient input to plugins. A plugin's entire input is the
189
+ `config` it is given and the pipeline it receives.
190
+
191
+ ## 10. Output
192
+
193
+ ### 10.1 What the framework writes
194
+
195
+ The framework itself writes:
196
+
197
+ - **a log**, to standard output (section 14),
198
+ - **the user directory**, when `scaffold` is asked for.
199
+
200
+ Everything a run actually accomplishes is written by a plugin: a file, a row in
201
+ SQLite, a request to a remote service, a line on the terminal. The framework
202
+ does not know what any of it is and does not verify it.
203
+
204
+ The final pipeline value is discarded. A Recipe whose last plugin only
205
+ transforms the pipeline has done nothing observable, and that is not an error.
206
+
207
+ ### 10.2 What a run publishes
208
+
209
+ Where the collected information ends up is decided by the Recipe, and the
210
+ plugins that decide it are the `Publish` category. Two kinds of destination have
211
+ always existed side by side: a remote service, reached with a credential over
212
+ the network, and something local — the terminal, a file, a database. Neither is
213
+ the system's purpose. Automatic Ruby is not a means of feeding one particular
214
+ reader, one particular service or one particular protocol, and a requirement
215
+ written as though it were would be wrong about what the system is for.
216
+
217
+ Leaving the collected information behind as a **document that outlives the run**
218
+ is as much a use of the system as sending it somewhere. That document is read by
219
+ a person, kept in version control, searched with ordinary Unix tools, and given
220
+ to a program — including a large language model or an agent — as input to
221
+ summarize, classify or reorganize. These are the same document and the same
222
+ requirement, not two.
223
+
224
+ **Markdown is the standard publication format** for that purpose, in the sense
225
+ that it is what a Recipe publishes when it has no reason to publish to a
226
+ particular service. Its properties are the reason, and they are technical ones:
227
+
228
+ - a person reads it as it is, with no tool and no rendering step;
229
+ - it diffs and merges, so a growing document belongs in Git;
230
+ - `grep`, `sed` and the rest operate on it without a parser;
231
+ - a program consumes it as text, which is the form a language model or an agent
232
+ takes its input in;
233
+ - it needs no service, no account, no credential and no network;
234
+ - it is plain text, so it stays readable for as long as the filesystem does.
235
+
236
+ The requirements that follow:
237
+
238
+ - **A publication format shall be available that needs nothing outside the
239
+ machine.** A Recipe that collects, filters and de-duplicates shall be able to
240
+ finish by writing what it has, without an account anywhere.
241
+ - **Markdown output is an ordinary Publish plugin**, subject to the plugin
242
+ contract of section 7.2 and to nothing else. It is not a framework feature,
243
+ the framework gains no knowledge of Markdown, and section 23 applies to it as
244
+ to anything else.
245
+ - **Nothing is published implicitly.** A Recipe publishes what it names and
246
+ nothing more. The framework shall not append a publishing step to a Recipe
247
+ that does not ask for one, and a Recipe written before Markdown output existed
248
+ shall behave exactly as it did.
249
+ - **The output format is not the pipeline value.** Serializing to Markdown
250
+ happens at the boundary, in the publishing plugin, and leaves section 8's
251
+ value untouched. No second representation is introduced, and the plugins
252
+ before and after are unaffected.
253
+ - **RSS and Atom remain input formats.** `SubscriptionFeed` and the rest are
254
+ unaffected by any of this: a feed is still one of the ordinary ways to acquire
255
+ something, and none of it is deprecated. That the pipeline value has the shape
256
+ of a feed is an internal matter (section 8); it neither obliges a run to
257
+ publish a feed nor makes feeds less useful to read.
258
+
259
+ ## 11. The command line
260
+
261
+ One executable, `automatic`, with two modes.
262
+
263
+ **Running a Recipe.** `automatic -c RECIPE` loads the Recipe and runs its
264
+ pipeline. `RECIPE` is either a path, or a bare name resolved inside
265
+ `~/.automatic/config`.
266
+
267
+ **Subcommands.** Auxiliary tools that do not involve a Recipe:
268
+
269
+ | Subcommand | What it is for |
270
+ | --- | --- |
271
+ | `scaffold` | Create the user directory (section 13) |
272
+ | `unscaffold` | Remove the user directory |
273
+ | `autodiscovery <url>` | Print the feed URLs advertised by a page |
274
+ | `feedparser <url>` | Parse a feed and print the result |
275
+ | `inspect <url>` | Discover a page's feeds, then parse the first |
276
+ | `opmlparser <path>` | Print the feed URLs in an OPML file |
277
+ | `log <level> <message>` | Emit one line in the framework's log format |
278
+
279
+ The last five exist to answer "will this work as a Recipe input?" before writing
280
+ the Recipe. They are diagnostic and they may print freely.
281
+
282
+ Requirements on the command line:
283
+
284
+ - `--help` and `--version` shall be available, shall print to standard output
285
+ and shall exit `0`.
286
+ - Exit status shall distinguish success from failure: `0` when the requested
287
+ work was done, `1` when it was not, `2` when the command line itself was
288
+ rejected. A run that fails shall not exit `0`.
289
+ - Diagnostics shall go to standard error, so that the output of `feedparser` and
290
+ friends can be redirected without collecting them.
291
+ - The set of subcommands and the meaning of `-c` are part of the compatibility
292
+ promise of section 7.1.
293
+
294
+ ## 12. Execution and failure
295
+
296
+ A run is: load the Recipe, then for each plugin entry in order, load the class,
297
+ construct it with its `config` and the current pipeline, call `run`, and take
298
+ the result as the pipeline for the next entry.
299
+
300
+ The requirements on failure:
301
+
302
+ - **A pipeline is not partially rerunnable.** There is no checkpoint and no
303
+ resume. A failed run is rerun from the start, so plugins that must not repeat
304
+ their effect are responsible for saying so; `StorePermalink` exists for this.
305
+ - **A plugin that raises ends the run.** The framework does not catch exceptions
306
+ from plugins. The Recipe is a sequence in which each step consumes the
307
+ previous one's output, so continuing past a failed step would run the
308
+ remaining steps on a value their author never intended.
309
+ - **Retrying belongs to the plugin.** Plugins that reach the network take
310
+ `retry` and `interval` and handle their own transient failures. The framework
311
+ offers no retry, and shall not acquire one that changes what a Recipe means.
312
+ - **A failure shall be visible.** Whatever a plugin decides to do about an
313
+ error, it logs it, and the process exit status reflects whether the run
314
+ completed. Silently returning an empty pipeline is a defect.
315
+ - **A Recipe naming a plugin that does not exist fails immediately**, before any
316
+ plugin runs, with a message naming the plugin.
317
+
318
+ These execution rules describe the straight-line composition model Automatic
319
+ Ruby supports. They are not an incomplete version of a richer workflow engine.
320
+ A use case that fundamentally requires branching, resume checkpoints,
321
+ transactional coordination or cross-step state ownership should not cause the
322
+ framework to grow those features by default; it should first be judged as a
323
+ candidate for a purpose-built application.
324
+
325
+ ## 13. The user directory
326
+
327
+ `~/.automatic` is where an installation's own material lives, so that it
328
+ survives upgrading or reinstalling the gem.
329
+
330
+ | Path | Holds |
331
+ | --- | --- |
332
+ | `~/.automatic/config` | Recipes. A bare `-c` name is resolved here. |
333
+ | `~/.automatic/plugins` | The operator's own plugins, in category subdirectories. |
334
+ | `~/.automatic/db` | SQLite databases written by the store plugins. |
335
+ | `~/.automatic/assets` | Data files plugins need, such as the fulltext siteinfo. |
336
+
337
+ Requirements:
338
+
339
+ - The user directory takes precedence over the installed package. A plugin under
340
+ `~/.automatic/plugins` shadows a plugin of the same name shipped in the gem.
341
+ Overriding a shipped plugin without editing the installation is the point.
342
+ - Each part is optional. Where a directory is absent the framework falls back to
343
+ the corresponding directory inside the installation, and a Recipe that needs
344
+ none of them runs without a user directory at all.
345
+ - `scaffold` creates the directory and seeds it: the category subdirectories, the
346
+ example Recipes, and the shipped assets. It shall not overwrite what is
347
+ already there.
348
+ - `unscaffold` removes the whole directory, including Recipes and databases the
349
+ operator put there. It is destructive by design and says so.
350
+
351
+ ## 14. Logging
352
+
353
+ One log, to standard output, one line per event, through Ruby's `Logger`.
354
+
355
+ - Four levels, in order: `info`, `warn`, `error`, `none`. `none` silences
356
+ everything.
357
+ - The level is set per Recipe, by `global.log.level`. It is the one framework
358
+ setting a Recipe carries.
359
+ - The framework logs which Recipe was loaded and which database was opened.
360
+ Plugins log what they fetched, what they skipped and what failed.
361
+ - The log is what the operator has after an unattended run. Where a plugin
362
+ decides not to fail, the log is the only record that anything went wrong, so
363
+ a swallowed error is logged at `warn` or `error`, never at `info` and never
364
+ not at all.
365
+
366
+ ## 15. Scheduling
367
+
368
+ Out of scope, deliberately. The system provides no scheduler, no daemon, no lock
369
+ file and no "run every N minutes". `automatic -c recipe.yml` in a `cron` entry
370
+ is the intended deployment, and `cron` keeps the responsibility for when.
371
+
372
+ Two consequences the operator owns: overlapping runs are possible if a run
373
+ outlasts its interval, and a Recipe that reaches a rate-limited service is
374
+ scheduled with that in mind.
375
+
376
+ ## 16. External services
377
+
378
+ The framework reaches nothing. Plugins reach the network, and where they do:
379
+
380
+ - The endpoint, the protocol, the authentication and the failure handling belong
381
+ entirely to that plugin.
382
+ - Credentials are given to the plugin in its Recipe `config` (section 17).
383
+ - TLS certificates shall be verified. A plugin shall not disable verification.
384
+ - Nothing is fetched on the framework's own initiative: no update check, no
385
+ telemetry, no phone-home.
386
+
387
+ Services shut down and APIs are replaced, and whether a given plugin still works
388
+ is a fact about the outside world rather than about this repository. It is
389
+ recorded per plugin in [`PLUGINS.md`](PLUGINS.md) section 6 and kept current
390
+ there. Three requirements follow:
391
+
392
+ - **A shipped plugin shall be practically usable on the supported Ruby
393
+ versions.** Shipping it is a statement that it does what its entry says,
394
+ given what that entry says the operator must provide. A plugin for which that
395
+ statement can no longer be made shall be corrected or removed; it shall not
396
+ be carried indefinitely as a record of what once existed, which is what the
397
+ version control history is for.
398
+ - **A plugin that cannot work shall say so** — in the catalogue and, where it
399
+ runs at all, in its log output. It shall not be quietly left to fail at
400
+ runtime.
401
+ - **A plugin shall never be made to pass a test by simulating a service that no
402
+ longer exists.** Deleting the test, or marking it as requiring a service that
403
+ is gone, is correct; a stub that makes a dead integration look alive is not.
404
+
405
+ ## 17. Trust and credentials
406
+
407
+ **A Recipe is trusted local configuration**, equivalent to a shell script the
408
+ operator wrote. This is the trust boundary, and everything below follows from
409
+ it.
410
+
411
+ A Recipe names Ruby classes and the framework loads and runs them. A Recipe also
412
+ names plugin *files*, indirectly, through the loader's search path. Anyone who
413
+ can write a Recipe, or write a file under `~/.automatic/plugins`, can execute
414
+ arbitrary code as the operator. Therefore:
415
+
416
+ - Recipes and plugin directories are the operator's own, protected by file
417
+ permissions. There is no supported use in which they come from elsewhere.
418
+ - A Recipe from an untrusted source is not to be run, and the system offers no
419
+ mode in which doing so would be safe. This is not a limitation to be lifted by
420
+ hardening the YAML parser.
421
+ - Even so, the Recipe parser shall not be a second, avoidable path to code
422
+ execution: YAML shall be loaded so that the document cannot name arbitrary
423
+ Ruby classes to instantiate. Refusing that costs nothing, since no Recipe
424
+ needs it.
425
+
426
+ Credentials — API tokens, passwords, keys — are values in the Recipe's `config`,
427
+ which means a Recipe holding them is a secret file, and the operator restricts
428
+ its permissions. This is a weakness inherited from the original design, and it
429
+ is recorded rather than glossed:
430
+
431
+ - No credential shall be committed to this repository, in a Recipe, an example,
432
+ a fixture or a test.
433
+ - No credential shall be written to the log. A plugin that logs its own settings
434
+ is a defect.
435
+ - The example Recipes shipped in `config/` shall need no credential.
436
+
437
+ ## 18. Persistence
438
+
439
+ Two kinds, both a plugin's business rather than the framework's.
440
+
441
+ **SQLite, through ActiveRecord.** The store plugins keep a table of what has
442
+ been seen so that the next run skips it: `StorePermalink` on the link,
443
+ `StoreFullText` on the link and title with the article body. The database file
444
+ is named in the Recipe and lives in `~/.automatic/db`, or in the installation's
445
+ `db/` when there is no user directory. The table is created on first use from
446
+ the plugin's own column definition; there are no migrations and no schema
447
+ version.
448
+
449
+ Requirements: ActiveRecord is used as a library and this is not a Rails
450
+ application; nothing shall introduce one. Equally, ActiveRecord shall not be
451
+ replaced by a hand-written database layer merely because it is a large
452
+ dependency — it is what the existing databases were written by, and operators
453
+ have those files. It is the store plugins' dependency and not the framework's:
454
+ `activerecord` and `sqlite3` shall be installed by the operator who uses those
455
+ plugins, and a Recipe that stores nothing shall run without either.
456
+
457
+ **The filesystem.** `StoreFile` downloads what the pipeline points at and
458
+ rewrites the item's link to a `file://` URI, which is how a later publishing
459
+ plugin knows to upload a local file rather than a remote one.
460
+
461
+ ## 19. Packaging and distribution
462
+
463
+ - Distributed as the RubyGem `automatic`, installable with `gem install
464
+ automatic`, providing the `automatic` executable.
465
+ - Also usable from a source checkout, through `bundle exec bin/automatic`.
466
+ - Dependencies are declared, versioned and resolved by Bundler and RubyGems.
467
+ Nothing is vendored into this repository.
468
+ - **The core install shall be small.** A dependency needed by one plugin shall
469
+ not be required to install the framework or to run a Recipe that does not use
470
+ that plugin; see [`POLICY.md`](POLICY.md) section 9. Requiring an operator who
471
+ publishes to a console to install an AWS SDK is a defect.
472
+ - Gem sources shall be HTTPS and shall be currently operating.
473
+
474
+ ## 20. Supported Ruby
475
+
476
+ Two statements are made here, and they are deliberately different.
477
+
478
+ **The supported range** is **Ruby 3.3 through Ruby 4.0**.
479
+
480
+ - The floor is **Ruby 3.3**: the oldest maintained release the dependency set is
481
+ resolved and tested against. Nothing older is tested or supported.
482
+ - The code shall be written against APIs the whole range shares. Where a Ruby
483
+ release deprecates or removes one, the replacement that works on the whole
484
+ range is used, rather than a `RUBY_VERSION` branch; see
485
+ [`POLICY.md`](POLICY.md) section 2.4.
486
+ - The gemspec's `required_ruby_version` is a lower bound and not an upper one,
487
+ so a Ruby newer than the range is permitted rather than refused. Refusing one
488
+ would need a new release of this gem to lift.
489
+ - The floor shall not be lowered to accommodate an unmaintained Ruby, and shall
490
+ not be raised to the newest release for its own sake. It moves when a
491
+ dependency the project needs moves it, or when the version drops out of the
492
+ distributions the project is used on.
493
+
494
+ **The continuously validated versions** are the versions in the matrix of
495
+ [`.github/workflows/ci.yml`](../.github/workflows/ci.yml). That workflow is
496
+ authoritative for the set checked on every commit.
497
+
498
+ - CI runs representative versions rather than every intermediate release. The
499
+ cost of a matrix entry is paid on every commit, and a third entry between two
500
+ that pass says little about a range whose code shares one set of APIs.
501
+ - **A version's absence from the matrix is not a statement that it fails.** It
502
+ is a statement that it is not verified on every commit. Nothing is written to
503
+ be deliberately incompatible with a supported Ruby that the matrix omits.
504
+ - Adding a released Ruby to the matrix is how support for it becomes continuous,
505
+ and is a small change.
506
+
507
+ `automatic.gemspec` is authoritative for the supported Ruby requirement, and
508
+ `.github/workflows/ci.yml` is authoritative for the continuously validated
509
+ set. User-facing documents state the supported range where readers need it
510
+ and refer to the matrix rather than duplicating its current membership.
511
+
512
+ ## 21. Portability
513
+
514
+ - No compiled extension of this repository's own. Extensions come only from
515
+ dependencies.
516
+ - No absolute path outside `~/.automatic` and the installation directory.
517
+ - Nothing tied to a particular distribution, filesystem layout or init system.
518
+ - Shelling out is a plugin's business, and a plugin that does it is expected to
519
+ fail cleanly where the command is absent.
520
+
521
+ ## 22. Testability
522
+
523
+ - The framework's own units — the Recipe loader, the plugin loader, the
524
+ pipeline, the log — shall be testable without a network, without credentials
525
+ and without a user directory.
526
+ - **The default test suite shall reach no network and require no credential.**
527
+ A test that needs either is not part of it.
528
+ - A plugin's test constructs the plugin with a `config` and a pipeline, calls
529
+ `run`, and asserts on the returned pipeline. The framework provides the means
530
+ to build a pipeline for this.
531
+ - Where a plugin's dependency cannot be installed or its service no longer
532
+ exists, its test is excluded from the default suite by that fact and not by a
533
+ simulation of the service. See section 16.
534
+ - The integration Recipes under `test/integration` are run by hand against real
535
+ services, and are not part of the default suite or of CI.
536
+
537
+ ## 23. Simplicity
538
+
539
+ The framework is meant to remain the small fixed part that plugins are written
540
+ against. Its responsibility boundary, rather than a duplicated current line
541
+ count, is the maintained constraint; see
542
+ [`BASIC_DESIGN.md`](BASIC_DESIGN.md).
543
+
544
+ - A capability that can live in a plugin lives in a plugin.
545
+ - A framework feature that only one plugin would use does not belong to the
546
+ framework.
547
+ - Long-standing behaviour is left alone unless there is a reason beyond taste. A
548
+ pipeline that has been in `cron` for a decade has earned the benefit of the
549
+ doubt.
550
+
551
+ ## 24. Licence
552
+
553
+ Automatic Ruby is dual-licensed under the GNU General Public License, version 3,
554
+ or the GNU Lesser General Public License, version 3. A user may choose either
555
+ license at their discretion. See [`LICENSE.md`](LICENSE.md),
556
+ [`COPYING`](COPYING) and [`COPYING.LESSER`](COPYING.LESSER).