wexample-wex-addon-process 2.0.0__tar.gz → 3.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/PKG-INFO +126 -43
  2. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/README.md +123 -40
  3. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/pyproject.toml +3 -3
  4. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/commands/run/execute.py +5 -0
  5. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/const/filestate.py +21 -0
  6. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/const/process.py +69 -0
  7. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/exception/run_cancelled_exception.py +9 -0
  8. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/helper/git_commit.py +83 -0
  9. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_type/family/file_state_process_type_family.py +6 -3
  10. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py +202 -0
  11. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_type/filestate/file_state_report_process_type.py +2 -4
  12. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type/process_context.py +114 -0
  13. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type/selection.py +148 -0
  14. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/record/record_file.py +7 -16
  15. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/runner/process_runner.py +671 -0
  16. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/selection/__init__.py +0 -0
  17. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/selection/abstract_code_from_directory_selection.py +84 -0
  18. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/selection/abstract_process_selection.py +50 -0
  19. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/selection/selection_registry.py +32 -0
  20. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/service/__init__.py +0 -0
  21. wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/service/process_run_service.py +145 -0
  22. wexample_wex_addon_process-3.0.0/tests/.gitkeep +0 -0
  23. wexample_wex_addon_process-3.0.0/tests/unit/__init__.py +0 -0
  24. wexample_wex_addon_process-3.0.0/tests/unit/test_artifacts.py +97 -0
  25. wexample_wex_addon_process-3.0.0/tests/unit/test_long_runs.py +219 -0
  26. wexample_wex_addon_process-3.0.0/tests/unit/test_run_overrides.py +208 -0
  27. wexample_wex_addon_process-3.0.0/tests/unit/test_selection.py +80 -0
  28. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/const/process.py +0 -34
  29. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py +0 -70
  30. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type/process_context.py +0 -43
  31. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type/selection.py +0 -86
  32. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/runner/process_runner.py +0 -257
  33. wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/service/process_run_service.py +0 -77
  34. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/__init__.py +0 -0
  35. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/commands/__init__.py +0 -0
  36. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/commands/run/__init__.py +0 -0
  37. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/commands/worker/__init__.py +0 -0
  38. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/commands/worker/start.py +0 -0
  39. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/const/__init__.py +0 -0
  40. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/exception}/__init__.py +0 -0
  41. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type/family → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/helper}/__init__.py +0 -0
  42. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_addon_manager.py +0 -0
  43. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/process_type/filestate → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type}/__init__.py +0 -0
  44. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_type/abstract_process_type.py +0 -0
  45. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/record → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type/family}/__init__.py +0 -0
  46. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_type/family/abstract_process_type_family.py +0 -0
  47. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/runner → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/process_type/filestate}/__init__.py +0 -0
  48. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/process_type/process_type_registry.py +0 -0
  49. {wexample_wex_addon_process-2.0.0 → wexample_wex_addon_process-3.0.0}/src/wexample_wex_addon_process/py.typed +0 -0
  50. {wexample_wex_addon_process-2.0.0/src/wexample_wex_addon_process/service → wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/record}/__init__.py +0 -0
  51. /wexample_wex_addon_process-2.0.0/tests/.gitkeep → /wexample_wex_addon_process-3.0.0/src/wexample_wex_addon_process/runner/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: wexample-wex-addon-process
3
- Version: 2.0.0
3
+ Version: 3.0.0
4
4
  Summary: Executes process runs defined on disk—either directly or by consuming them from a queue as a worker—against filestate selections
5
5
  Author-Email: weeger <contact@wexample.com>
6
6
  License: MIT
@@ -9,8 +9,8 @@ Classifier: License :: OSI Approved :: MIT License
9
9
  Classifier: Operating System :: OS Independent
10
10
  Requires-Python: >=3.10
11
11
  Requires-Dist: wexample-file>=9.2.0
12
- Requires-Dist: wexample-queue>=1.1.0
13
- Requires-Dist: wexample-wex-addon-app>=31.0.0
12
+ Requires-Dist: wexample-queue>=2.0.0
13
+ Requires-Dist: wexample-wex-addon-app>=32.0.0
14
14
  Provides-Extra: dev
15
15
  Requires-Dist: pytest; extra == "dev"
16
16
  Requires-Dist: pytest-cov; extra == "dev"
@@ -18,7 +18,7 @@ Description-Content-Type: text/markdown
18
18
 
19
19
  # wex-addon-process
20
20
 
21
- Version: 2.0.0
21
+ Version: 3.0.0
22
22
 
23
23
  `wexample-wex-addon-process` runs the treatments a board declares. A *process* says what to run and on which selection of files; a *run* is one execution of it, asked for by writing a record and picked up by a worker.
24
24
 
@@ -41,7 +41,7 @@ wex process::worker/start
41
41
  ```
42
42
 
43
43
  Blocks, consuming the `process_run` queue, and rings back on `process_run_event`
44
- every time a run moves — on each state change, and at most once a second while
44
+ every time a run moves — on each state change, and at most four times a second while
45
45
  it advances. This is the process a worker container runs, and there may be as
46
46
  many as the parallelism asked for.
47
47
 
@@ -65,17 +65,27 @@ filestate:managed_blocks filestate:class filestate:on_bad_format
65
65
  Nothing is written per treatment: an option added to filestate is a type
66
66
  available here the same day, under the very name filestate gives it.
67
67
 
68
- The process's `options` block is the configuration handed to each file, and has
68
+ The process's `options` object is the configuration handed to each file, and has
69
69
  to carry the option the type is named after:
70
70
 
71
- ```yaml
72
- # a process of type filestate:mode
73
- mode: '600'
74
- dry_run: true
71
+ ```json
72
+ // the options of a process of type filestate:mode
73
+ {"mode": "600", "action": "dry_run"}
75
74
  ```
76
75
 
77
- `dry_run` is read by the family and handed to nobody: it runs the pass without
78
- applying it, so what a treatment *would* do is readable before it does it.
76
+ `action` is read by the family and handed to nobody. It picks which of
77
+ filestate's three verbs is asked, the same three the command line offers on an
78
+ app's declared state:
79
+
80
+ - `check`, the default, says what is wrong with each selected file and changes
81
+ nothing. Its `data` counts the files `checked` and `failing`, and lists under
82
+ `files` only those at fault, each with its `code`, `message`, `parameters` and
83
+ `remedy`, the name of the operation that would fix it or null when nothing can.
84
+ - `dry_run` lists the operations one pass would apply.
85
+ - `fix` applies them, then lists what is still wrong, the way `check` does.
86
+
87
+ A rule that only observes, such as `max_lines`, is a type like the others: it has
88
+ no remedy, so a `fix` changes nothing and reports it again.
79
89
 
80
90
  ## Writing a type
81
91
 
@@ -87,12 +97,17 @@ class ReviewProcessType(AbstractProcessType):
87
97
  label = "Review"
88
98
 
89
99
  def run(self, context: ProcessContext) -> dict[str, Any]:
90
- for path in context.files:
91
- context.advance()
100
+ for path in context.each():
101
+ ...
92
102
 
93
103
  return {"files": len(context.files)}
94
104
  ```
95
105
 
106
+ `context.each()` hands the files over one at a time and keeps the run up to
107
+ date: the file being worked on is the run's `current`, and it is counted once
108
+ the loop comes back for the next one. A file whose treatment raises is never
109
+ counted and stays `current`, so a failed run says where it failed.
110
+
96
111
  What `run()` returns goes into the run's `data`. Raising is how a type says it
97
112
  could not go on — the failure is written down for it, so nothing in a type has
98
113
  to know what a failed run looks like.
@@ -108,6 +123,9 @@ exist.
108
123
  - [The types on offer](#the-types-on-offer)
109
124
  - [Writing a type](#writing-a-type)
110
125
  - [Installation](#installation)
126
+ - [What a run may ask for itself](#what-a-run-may-ask-for-itself)
127
+ - [Runs that last a night](#runs-that-last-a-night)
128
+ - [An item that fails, a run that is cancelled](#an-item-that-fails-a-run-that-is-cancelled)
111
129
  - [Tests](#tests)
112
130
  - [Architecture](#architecture)
113
131
  - [Integration in the Suite](#integration-in-the-suite)
@@ -128,28 +146,35 @@ pip install wexample-wex-addon-process
128
146
 
129
147
  Requires Python >=3.10.
130
148
 
131
- A process and a selection are records, so a treatment can be declared without a board:
149
+ A process and a selection are records, so a treatment can be declared without a board.
150
+ Records are JSON, written and read by programs on both sides:
132
151
 
133
- ```yaml
134
- # .wex/data/selection/<uuid>.yml
135
- title: Markdowns
136
- patterns: "*.md\n!.wex/**"
152
+ ```json
153
+ // .wex/data/selection/<uuid>.json
154
+ {
155
+ "title": "Markdowns",
156
+ "patterns": "*.md\n!.wex/**"
157
+ }
137
158
  ```
138
159
 
139
- ```yaml
140
- # .wex/data/process/<uuid>.yml
141
- title: What the Markdowns take
142
- type: 'filestate:report'
143
- selection_id: <the selection's uuid>
144
- options: ''
160
+ ```json
161
+ // .wex/data/process/<uuid>.json
162
+ {
163
+ "title": "What the Markdowns take",
164
+ "type": "filestate:report",
165
+ "selection_id": "<the selection's uuid>",
166
+ "options": {}
167
+ }
145
168
  ```
146
169
 
147
170
  Asking for a run is writing a third record:
148
171
 
149
- ```yaml
150
- # .wex/data/process_run/<uuid>.yml
151
- process_id: <the process's uuid>
152
- state: pending
172
+ ```json
173
+ // .wex/data/process_run/<uuid>.json
174
+ {
175
+ "process_id": "<the process's uuid>",
176
+ "state": "pending"
177
+ }
153
178
  ```
154
179
 
155
180
  Then:
@@ -160,18 +185,76 @@ wex process::run/execute --run <the run's uuid>
160
185
 
161
186
  The same file is read back afterwards, holding what happened:
162
187
 
163
- ```yaml
164
- process_id: ...
165
- state: complete
166
- items_total: 3
167
- items_done: 3
168
- data: |
169
- files: 3
170
- bytes: 16
171
- by_extension:
172
- .md: 3
188
+ ```json
189
+ {
190
+ "process_id": "...",
191
+ "state": "complete",
192
+ "items_total": 3,
193
+ "items_done": 3,
194
+ "data": {
195
+ "files": 3,
196
+ "bytes": 16,
197
+ "by_extension": {".md": 3}
198
+ }
199
+ }
200
+ ```
201
+
202
+ ## What a run may ask for itself
203
+
204
+ A run record may carry three keys of its own, written when it is created:
205
+
206
+ - `action` — `check`, `dry_run` or `fix`, in place of the process's own, for this run.
207
+ - `items` — paths relative to the app, taken in place of the selection. A path that is
208
+ gone is listed under `items_missing` rather than failing the run; a path outside the
209
+ app fails it.
210
+ - `commit: true` — once the run completes, commit what it wrote and nothing else. The
211
+ result replaces the key: `{"sha": ..., "files": [...]}`, `{"sha": null, "files": []}`
212
+ when nothing changed, or `{"error": ..., "files": [...]}` when git refused — the
213
+ changes stay either way.
214
+
215
+ ```json
216
+ {"process_id": "...", "state": "pending", "action": "fix", "items": ["docs/a.md"], "commit": true}
173
217
  ```
174
218
 
219
+ What a run wrote is what its type declared through `context.touch(path)`: the operations
220
+ of a filestate fix, the items an agent changed and their engrams.
221
+
222
+ ## Runs that last a night
223
+
224
+ A worker takes one message at a time from the queue and acknowledges it at once: the
225
+ run's record, not the broker, says what became of it. The run is worked on in a thread
226
+ of its own while the worker keeps the broker connection alive, so a run of several
227
+ hours needs no RabbitMQ setting — neither `consumer_timeout` nor the heartbeat can cut
228
+ it. A connection lost meanwhile is opened again once the run ends; the board is told of
229
+ the run's moves on a best-effort basis and reads the record again anyway.
230
+
231
+ A running run writes `date_alive` into its record every minute. One still `running`
232
+ whose last sign of life is too old was interrupted — worker killed, machine stopped —
233
+ and is said `failed`, never run again: by the next run of the same app, and by any
234
+ worker that served that app, within a minute of finding its queue empty.
235
+
236
+ | Variable | Default | What it sets |
237
+ |---|---|---|
238
+ | `PROCESS_RUN_STALE_AFTER` | `600` | Seconds without a sign of life after which a running run is taken for dead |
239
+ | `RABBITMQ_HEARTBEAT` | the broker's | Seconds between two heartbeats on the worker's connection |
240
+
241
+ There is no limit on how long a run may take.
242
+
243
+ ## An item that fails, a run that is cancelled
244
+
245
+ A type that cannot deal with one item says so with `context.fail(path, reason)` and
246
+ moves on: the run still completes, and lists what it could not do under its data.
247
+
248
+ ```json
249
+ "data": {"errors": [{"path": "docs/a.pdf", "reason": "docling timed out"}]}
250
+ ```
251
+
252
+ To stop a run, write `"cancel": true` into its record. The runner checks it before every
253
+ item (`context.each()`, `context.advance()`, or `context.check_cancel()` in a type's own
254
+ loop) and ends the run `cancelled`, with `items_done` where it got to and the errors and
255
+ artifacts collected so far. A run cancelled before it was taken ends `cancelled` without
256
+ starting.
257
+
175
258
  ## Tests
176
259
 
177
260
  This project uses `pytest` for testing and `pytest-cov` for code coverage analysis.
@@ -229,7 +312,7 @@ The addon is one runner, a few types, and two ways to reach it.
229
312
 
230
313
  src/wexample_wex_addon_process/runner/process_runner.py is where a run is taken from `pending` to `complete` or to `failed`. It reads the run, the process it names, the selection that process names, resolves the type, walks the files, and writes back at every state change.
231
314
 
232
- Three decisions are worth reading. A run in any state but `pending` is left where it is, because a queue redelivers and a run is done once. The record is written on every file dealt with, being local and being the truth, while the ring is throttled to once a second — ten thousand files must not be ten thousand messages saying the same thing. And the type never sees the run: it is handed a src/wexample_wex_addon_process/process_type/process_context.py holding the files, the options and a way to say how far it has got, so what a run is *in* is decided in one place.
315
+ Three decisions are worth reading. A run in any state but `pending` is left where it is, because a queue redelivers and a run is done once. Progress — the files done and the file being worked on, `current` — is kept in memory and written down at most four times a second, each write followed by a ring: the other end reads the record when rung, and ten thousand files must not be ten thousand rewrites saying the same thing. A state change is never held back, and a failed run keeps `current` on the file it failed on. And the type never sees the run: it is handed a src/wexample_wex_addon_process/process_type/process_context.py holding the files, the options and a way to say how far it has got, so what a run is *in* is decided in one place.
233
316
 
234
317
  ### Reading a selection
235
318
 
@@ -247,7 +330,7 @@ A type's name carries its family: `filestate:mode`, `agent:review`. The prefix i
247
330
 
248
331
  src/wexample_wex_addon_process/process_type/family/file_state_process_type_family.py declares none of its types by hand. It walks filestate's `DefaultOptionsProvider` and keeps the options that override `create_required_operation` — the precise line between a treatment and a piece of structure, asked of the option itself rather than listed here. `mode` is a treatment, `children` is not. Thirteen types come out of it, and an option added to filestate is a fourteenth the same day.
249
332
 
250
- Each of those is a src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py, which holds no logic either: the process's `options` become the configuration asked of every selected file, and filestate turns the difference with the disk into operations that can be described, applied and undone. `dry_run` in the options runs the pass without applying it.
333
+ Each of those is a src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py, which holds no logic either: the process's `options` become the configuration asked of every selected file, and filestate turns the difference with the disk into operations that can be described, applied and undone. `action` in the options picks the verb: `check` returns verdicts per file, `dry_run` the operations a pass would apply, `fix` applies them.
251
334
 
252
335
  src/wexample_wex_addon_process/process_type/filestate/file_state_report_process_type.py is in that family and yet emits no operation, on filestate's own rule: a scope names something observable on disk, and a report changes nothing observable. An operation with no scope would be an operation nothing could ever run.
253
336
 
@@ -274,8 +357,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
274
357
  ## Dependencies
275
358
 
276
359
  - wexample-file: >=9.2.0
277
- - wexample-queue: >=1.1.0
278
- - wexample-wex-addon-app: >=31.0.0
360
+ - wexample-queue: >=2.0.0
361
+ - wexample-wex-addon-app: >=32.0.0
279
362
 
280
363
  ## Versioning & Compatibility Policy
281
364
 
@@ -1,6 +1,6 @@
1
1
  # wex-addon-process
2
2
 
3
- Version: 2.0.0
3
+ Version: 3.0.0
4
4
 
5
5
  `wexample-wex-addon-process` runs the treatments a board declares. A *process* says what to run and on which selection of files; a *run* is one execution of it, asked for by writing a record and picked up by a worker.
6
6
 
@@ -23,7 +23,7 @@ wex process::worker/start
23
23
  ```
24
24
 
25
25
  Blocks, consuming the `process_run` queue, and rings back on `process_run_event`
26
- every time a run moves — on each state change, and at most once a second while
26
+ every time a run moves — on each state change, and at most four times a second while
27
27
  it advances. This is the process a worker container runs, and there may be as
28
28
  many as the parallelism asked for.
29
29
 
@@ -47,17 +47,27 @@ filestate:managed_blocks filestate:class filestate:on_bad_format
47
47
  Nothing is written per treatment: an option added to filestate is a type
48
48
  available here the same day, under the very name filestate gives it.
49
49
 
50
- The process's `options` block is the configuration handed to each file, and has
50
+ The process's `options` object is the configuration handed to each file, and has
51
51
  to carry the option the type is named after:
52
52
 
53
- ```yaml
54
- # a process of type filestate:mode
55
- mode: '600'
56
- dry_run: true
53
+ ```json
54
+ // the options of a process of type filestate:mode
55
+ {"mode": "600", "action": "dry_run"}
57
56
  ```
58
57
 
59
- `dry_run` is read by the family and handed to nobody: it runs the pass without
60
- applying it, so what a treatment *would* do is readable before it does it.
58
+ `action` is read by the family and handed to nobody. It picks which of
59
+ filestate's three verbs is asked, the same three the command line offers on an
60
+ app's declared state:
61
+
62
+ - `check`, the default, says what is wrong with each selected file and changes
63
+ nothing. Its `data` counts the files `checked` and `failing`, and lists under
64
+ `files` only those at fault, each with its `code`, `message`, `parameters` and
65
+ `remedy`, the name of the operation that would fix it or null when nothing can.
66
+ - `dry_run` lists the operations one pass would apply.
67
+ - `fix` applies them, then lists what is still wrong, the way `check` does.
68
+
69
+ A rule that only observes, such as `max_lines`, is a type like the others: it has
70
+ no remedy, so a `fix` changes nothing and reports it again.
61
71
 
62
72
  ## Writing a type
63
73
 
@@ -69,12 +79,17 @@ class ReviewProcessType(AbstractProcessType):
69
79
  label = "Review"
70
80
 
71
81
  def run(self, context: ProcessContext) -> dict[str, Any]:
72
- for path in context.files:
73
- context.advance()
82
+ for path in context.each():
83
+ ...
74
84
 
75
85
  return {"files": len(context.files)}
76
86
  ```
77
87
 
88
+ `context.each()` hands the files over one at a time and keeps the run up to
89
+ date: the file being worked on is the run's `current`, and it is counted once
90
+ the loop comes back for the next one. A file whose treatment raises is never
91
+ counted and stays `current`, so a failed run says where it failed.
92
+
78
93
  What `run()` returns goes into the run's `data`. Raising is how a type says it
79
94
  could not go on — the failure is written down for it, so nothing in a type has
80
95
  to know what a failed run looks like.
@@ -90,6 +105,9 @@ exist.
90
105
  - [The types on offer](#the-types-on-offer)
91
106
  - [Writing a type](#writing-a-type)
92
107
  - [Installation](#installation)
108
+ - [What a run may ask for itself](#what-a-run-may-ask-for-itself)
109
+ - [Runs that last a night](#runs-that-last-a-night)
110
+ - [An item that fails, a run that is cancelled](#an-item-that-fails-a-run-that-is-cancelled)
93
111
  - [Tests](#tests)
94
112
  - [Architecture](#architecture)
95
113
  - [Integration in the Suite](#integration-in-the-suite)
@@ -110,28 +128,35 @@ pip install wexample-wex-addon-process
110
128
 
111
129
  Requires Python >=3.10.
112
130
 
113
- A process and a selection are records, so a treatment can be declared without a board:
131
+ A process and a selection are records, so a treatment can be declared without a board.
132
+ Records are JSON, written and read by programs on both sides:
114
133
 
115
- ```yaml
116
- # .wex/data/selection/<uuid>.yml
117
- title: Markdowns
118
- patterns: "*.md\n!.wex/**"
134
+ ```json
135
+ // .wex/data/selection/<uuid>.json
136
+ {
137
+ "title": "Markdowns",
138
+ "patterns": "*.md\n!.wex/**"
139
+ }
119
140
  ```
120
141
 
121
- ```yaml
122
- # .wex/data/process/<uuid>.yml
123
- title: What the Markdowns take
124
- type: 'filestate:report'
125
- selection_id: <the selection's uuid>
126
- options: ''
142
+ ```json
143
+ // .wex/data/process/<uuid>.json
144
+ {
145
+ "title": "What the Markdowns take",
146
+ "type": "filestate:report",
147
+ "selection_id": "<the selection's uuid>",
148
+ "options": {}
149
+ }
127
150
  ```
128
151
 
129
152
  Asking for a run is writing a third record:
130
153
 
131
- ```yaml
132
- # .wex/data/process_run/<uuid>.yml
133
- process_id: <the process's uuid>
134
- state: pending
154
+ ```json
155
+ // .wex/data/process_run/<uuid>.json
156
+ {
157
+ "process_id": "<the process's uuid>",
158
+ "state": "pending"
159
+ }
135
160
  ```
136
161
 
137
162
  Then:
@@ -142,18 +167,76 @@ wex process::run/execute --run <the run's uuid>
142
167
 
143
168
  The same file is read back afterwards, holding what happened:
144
169
 
145
- ```yaml
146
- process_id: ...
147
- state: complete
148
- items_total: 3
149
- items_done: 3
150
- data: |
151
- files: 3
152
- bytes: 16
153
- by_extension:
154
- .md: 3
170
+ ```json
171
+ {
172
+ "process_id": "...",
173
+ "state": "complete",
174
+ "items_total": 3,
175
+ "items_done": 3,
176
+ "data": {
177
+ "files": 3,
178
+ "bytes": 16,
179
+ "by_extension": {".md": 3}
180
+ }
181
+ }
182
+ ```
183
+
184
+ ## What a run may ask for itself
185
+
186
+ A run record may carry three keys of its own, written when it is created:
187
+
188
+ - `action` — `check`, `dry_run` or `fix`, in place of the process's own, for this run.
189
+ - `items` — paths relative to the app, taken in place of the selection. A path that is
190
+ gone is listed under `items_missing` rather than failing the run; a path outside the
191
+ app fails it.
192
+ - `commit: true` — once the run completes, commit what it wrote and nothing else. The
193
+ result replaces the key: `{"sha": ..., "files": [...]}`, `{"sha": null, "files": []}`
194
+ when nothing changed, or `{"error": ..., "files": [...]}` when git refused — the
195
+ changes stay either way.
196
+
197
+ ```json
198
+ {"process_id": "...", "state": "pending", "action": "fix", "items": ["docs/a.md"], "commit": true}
155
199
  ```
156
200
 
201
+ What a run wrote is what its type declared through `context.touch(path)`: the operations
202
+ of a filestate fix, the items an agent changed and their engrams.
203
+
204
+ ## Runs that last a night
205
+
206
+ A worker takes one message at a time from the queue and acknowledges it at once: the
207
+ run's record, not the broker, says what became of it. The run is worked on in a thread
208
+ of its own while the worker keeps the broker connection alive, so a run of several
209
+ hours needs no RabbitMQ setting — neither `consumer_timeout` nor the heartbeat can cut
210
+ it. A connection lost meanwhile is opened again once the run ends; the board is told of
211
+ the run's moves on a best-effort basis and reads the record again anyway.
212
+
213
+ A running run writes `date_alive` into its record every minute. One still `running`
214
+ whose last sign of life is too old was interrupted — worker killed, machine stopped —
215
+ and is said `failed`, never run again: by the next run of the same app, and by any
216
+ worker that served that app, within a minute of finding its queue empty.
217
+
218
+ | Variable | Default | What it sets |
219
+ |---|---|---|
220
+ | `PROCESS_RUN_STALE_AFTER` | `600` | Seconds without a sign of life after which a running run is taken for dead |
221
+ | `RABBITMQ_HEARTBEAT` | the broker's | Seconds between two heartbeats on the worker's connection |
222
+
223
+ There is no limit on how long a run may take.
224
+
225
+ ## An item that fails, a run that is cancelled
226
+
227
+ A type that cannot deal with one item says so with `context.fail(path, reason)` and
228
+ moves on: the run still completes, and lists what it could not do under its data.
229
+
230
+ ```json
231
+ "data": {"errors": [{"path": "docs/a.pdf", "reason": "docling timed out"}]}
232
+ ```
233
+
234
+ To stop a run, write `"cancel": true` into its record. The runner checks it before every
235
+ item (`context.each()`, `context.advance()`, or `context.check_cancel()` in a type's own
236
+ loop) and ends the run `cancelled`, with `items_done` where it got to and the errors and
237
+ artifacts collected so far. A run cancelled before it was taken ends `cancelled` without
238
+ starting.
239
+
157
240
  ## Tests
158
241
 
159
242
  This project uses `pytest` for testing and `pytest-cov` for code coverage analysis.
@@ -211,7 +294,7 @@ The addon is one runner, a few types, and two ways to reach it.
211
294
 
212
295
  src/wexample_wex_addon_process/runner/process_runner.py is where a run is taken from `pending` to `complete` or to `failed`. It reads the run, the process it names, the selection that process names, resolves the type, walks the files, and writes back at every state change.
213
296
 
214
- Three decisions are worth reading. A run in any state but `pending` is left where it is, because a queue redelivers and a run is done once. The record is written on every file dealt with, being local and being the truth, while the ring is throttled to once a second — ten thousand files must not be ten thousand messages saying the same thing. And the type never sees the run: it is handed a src/wexample_wex_addon_process/process_type/process_context.py holding the files, the options and a way to say how far it has got, so what a run is *in* is decided in one place.
297
+ Three decisions are worth reading. A run in any state but `pending` is left where it is, because a queue redelivers and a run is done once. Progress — the files done and the file being worked on, `current` — is kept in memory and written down at most four times a second, each write followed by a ring: the other end reads the record when rung, and ten thousand files must not be ten thousand rewrites saying the same thing. A state change is never held back, and a failed run keeps `current` on the file it failed on. And the type never sees the run: it is handed a src/wexample_wex_addon_process/process_type/process_context.py holding the files, the options and a way to say how far it has got, so what a run is *in* is decided in one place.
215
298
 
216
299
  ### Reading a selection
217
300
 
@@ -229,7 +312,7 @@ A type's name carries its family: `filestate:mode`, `agent:review`. The prefix i
229
312
 
230
313
  src/wexample_wex_addon_process/process_type/family/file_state_process_type_family.py declares none of its types by hand. It walks filestate's `DefaultOptionsProvider` and keeps the options that override `create_required_operation` — the precise line between a treatment and a piece of structure, asked of the option itself rather than listed here. `mode` is a treatment, `children` is not. Thirteen types come out of it, and an option added to filestate is a fourteenth the same day.
231
314
 
232
- Each of those is a src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py, which holds no logic either: the process's `options` become the configuration asked of every selected file, and filestate turns the difference with the disk into operations that can be described, applied and undone. `dry_run` in the options runs the pass without applying it.
315
+ Each of those is a src/wexample_wex_addon_process/process_type/filestate/file_state_option_process_type.py, which holds no logic either: the process's `options` become the configuration asked of every selected file, and filestate turns the difference with the disk into operations that can be described, applied and undone. `action` in the options picks the verb: `check` returns verdicts per file, `dry_run` the operations a pass would apply, `fix` applies them.
233
316
 
234
317
  src/wexample_wex_addon_process/process_type/filestate/file_state_report_process_type.py is in that family and yet emits no operation, on filestate's own rule: a scope names something observable on disk, and a report changes nothing observable. An operation with no scope would be an operation nothing could ever run.
235
318
 
@@ -256,8 +339,8 @@ Visit the [Wexample Suite documentation](https://docs.wexample.com) for the comp
256
339
  ## Dependencies
257
340
 
258
341
  - wexample-file: >=9.2.0
259
- - wexample-queue: >=1.1.0
260
- - wexample-wex-addon-app: >=31.0.0
342
+ - wexample-queue: >=2.0.0
343
+ - wexample-wex-addon-app: >=32.0.0
261
344
 
262
345
  ## Versioning & Compatibility Policy
263
346
 
@@ -6,7 +6,7 @@ build-backend = "pdm.backend"
6
6
 
7
7
  [project]
8
8
  name = "wexample-wex-addon-process"
9
- version = "2.0.0"
9
+ version = "3.0.0"
10
10
  description = "Executes process runs defined on disk—either directly or by consuming them from a queue as a worker—against filestate selections"
11
11
  authors = [
12
12
  { name = "weeger", email = "contact@wexample.com" },
@@ -19,8 +19,8 @@ classifiers = [
19
19
  ]
20
20
  dependencies = [
21
21
  "wexample-file>=9.2.0",
22
- "wexample-queue>=1.1.0",
23
- "wexample-wex-addon-app>=31.0.0",
22
+ "wexample-queue>=2.0.0",
23
+ "wexample-wex-addon-app>=32.0.0",
24
24
  ]
25
25
 
26
26
  [project.readme]
@@ -42,10 +42,15 @@ def process__run__execute(
42
42
  ProcessTypeRegistry,
43
43
  )
44
44
  from wexample_wex_addon_process.runner.process_runner import ProcessRunner
45
+ from wexample_wex_addon_process.selection.selection_registry import (
46
+ SelectionRegistry,
47
+ )
45
48
 
46
49
  runner = ProcessRunner(
47
50
  io=context.io,
48
51
  registry=ProcessTypeRegistry.from_kernel(context.kernel),
52
+ selections=SelectionRegistry.from_kernel(context.kernel),
53
+ kernel=context.kernel,
49
54
  )
50
55
 
51
56
  record = runner.execute(
@@ -0,0 +1,21 @@
1
+ """The options a filestate process type reads for itself, before handing the rest
2
+ to each selected file.
3
+
4
+ The three actions are filestate's three verbs, and the same three the command
5
+ line offers on an app's declared state: `app::state/check`, `app::state/rectify
6
+ --dry-run`, `app::state/rectify`. A process is that, on a selection instead of a
7
+ whole declared tree.
8
+
9
+ The key must not be the name of a filestate option, since every other key of the
10
+ block is handed to each file as its configuration: `mode` already means chmod.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ # filestate: python-constant-sort
16
+ ACTION_CHECK: str = "check"
17
+ ACTION_DRY_RUN: str = "dry_run"
18
+ ACTION_FIX: str = "fix"
19
+ ACTIONS: tuple[str, ...] = (ACTION_CHECK, ACTION_DRY_RUN, ACTION_FIX)
20
+
21
+ OPTION_KEY_ACTION: str = "action"
@@ -0,0 +1,69 @@
1
+ """What a process, a selection and a run are called on disk.
2
+
3
+ Spelled once here because wex is not the only reader: the board writes these very
4
+ keys into the same files, so a name changed on one side has to be changed on the
5
+ other or the two stop understanding each other.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ # filestate: python-constant-sort
11
+ ARTIFACT_TYPE_FILE: str = "file"
12
+
13
+ # Under a run's data: the files it produced to be handed back, each
14
+ # {type, path, label}.
15
+ DATA_KEY_ARTIFACTS: str = "artifacts"
16
+
17
+ # Under a run's data: the items a type could not deal with, each {path, reason}.
18
+ # The run still completes — one bad file does not sink the batch.
19
+ DATA_KEY_ERRORS: str = "errors"
20
+
21
+ DEFAULT_COMMIT_AUTHOR: str = "wex <wex@localhost>"
22
+ """Who commits a run's work when neither the run nor git names anyone."""
23
+
24
+ ENV_PROCESS_RUN_STALE_AFTER: str = "PROCESS_RUN_STALE_AFTER"
25
+ """Seconds without a sign of life after which a running run is taken for dead."""
26
+
27
+ KIND_PROCESS: str = "process"
28
+ KIND_PROCESS_RUN: str = "process_run"
29
+ KIND_SELECTION: str = "selection"
30
+
31
+ PROCESS_KEY_OPTIONS: str = "options"
32
+ PROCESS_KEY_SELECTION_ID: str = "selection_id"
33
+ PROCESS_KEY_TITLE: str = "title"
34
+ PROCESS_KEY_TYPE: str = "type"
35
+
36
+ RUN_KEY_ACTION: str = "action"
37
+ RUN_KEY_AUTHOR: str = "author"
38
+ RUN_KEY_CANCEL: str = "cancel"
39
+ """Written `true` by whoever wants the run stopped; read between two items."""
40
+ RUN_KEY_COMMIT: str = "commit"
41
+ RUN_KEY_CURRENT: str = "current"
42
+ RUN_KEY_DATA: str = "data"
43
+ RUN_KEY_DATE_ALIVE: str = "date_alive"
44
+ RUN_KEY_DATE_ENDED: str = "date_ended"
45
+ RUN_KEY_DATE_STARTED: str = "date_started"
46
+ RUN_KEY_ITEMS: str = "items"
47
+ RUN_KEY_ITEMS_DONE: str = "items_done"
48
+ RUN_KEY_ITEMS_MISSING: str = "items_missing"
49
+ RUN_KEY_ITEMS_TOTAL: str = "items_total"
50
+ RUN_KEY_PROCESS_ID: str = "process_id"
51
+ RUN_KEY_STATE: str = "state"
52
+
53
+ SELECTION_KEY_PATTERNS: str = "patterns"
54
+ SELECTION_KEY_PROVIDER: str = "provider"
55
+ SELECTION_KEY_TITLE: str = "title"
56
+
57
+ RUN_ALIVE_INTERVAL: float = 60.0
58
+ """Seconds between two signs of life a running run writes into its record."""
59
+
60
+ RUN_STALE_AFTER_DEFAULT: float = 600.0
61
+ """How long a running run may stay silent before it is taken for dead, unless
62
+ `PROCESS_RUN_STALE_AFTER` says otherwise. Ten signs of life missed: a busy machine
63
+ may skip one, never ten."""
64
+
65
+ STATE_CANCELLED: str = "cancelled"
66
+ STATE_COMPLETE: str = "complete"
67
+ STATE_FAILED: str = "failed"
68
+ STATE_PENDING: str = "pending"
69
+ STATE_RUNNING: str = "running"
@@ -0,0 +1,9 @@
1
+ from __future__ import annotations
2
+
3
+
4
+ class RunCancelledException(Exception):
5
+ """Raised between two items of a run whose record asks for it to stop.
6
+
7
+ Not an error: the runner catches it and ends the run `cancelled`, with what it
8
+ had done so far.
9
+ """