tickwork 0.3.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.
tickwork-0.3.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) The tickwork Authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,4 @@
1
+ # Ship the example files in the sdist too; setuptools pulls in package
2
+ # sources by default, but the top-level examples/ directory is data-files
3
+ # content and only reaches the sdist via MANIFEST.in.
4
+ recursive-include examples *
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: tickwork
3
+ Version: 0.3.0
4
+ Summary: A generic, instance-neutral workflow engine: states, transitions, sweeps and call replay over a queue of work items
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ License-File: LICENSE
8
+ Requires-Dist: PyYAML>=6.0
9
+ Dynamic: license-file
@@ -0,0 +1,235 @@
1
+ # tickwork
2
+
3
+ A generic, instance-neutral workflow engine: it loads a declarative
4
+ "states-as-data" file, validates it, and evaluates which state each work item
5
+ in a queue of issues and pull requests is in — including a per-item anomaly
6
+ report when an item matches zero or several states. It knows nothing about
7
+ any specific deployment or consumer: callers supply the states file, the set
8
+ of allowed owner role names, and the role-to-login mapping.
9
+
10
+ ## Install
11
+
12
+ ```bash
13
+ pip install tickwork
14
+ ```
15
+
16
+ Requires Python 3.11+ and PyYAML.
17
+
18
+ ## States as data
19
+
20
+ A states file maps each state to a `when` predicate, a board bucket, and an
21
+ `owner` (a role name, or `human`). Generic role names only — the file, like
22
+ the package, names no instance:
23
+
24
+ ```yaml
25
+ states:
26
+ queued:
27
+ when:
28
+ issue.assignee: none
29
+ board: queued
30
+ owner: dev
31
+ in_review:
32
+ when:
33
+ pr.requested_reviewers: reviewer
34
+ board: blocked
35
+ owner: reviewer
36
+ waiting_on_human:
37
+ when:
38
+ any:
39
+ - pr.state: merged
40
+ - issue.state: merged
41
+ board: waiting_on_human
42
+ owner: human
43
+ done:
44
+ when:
45
+ issue.state: closed
46
+ board: done
47
+ terminal: true
48
+ ```
49
+
50
+ Load, validate, evaluate, and report anomalies:
51
+
52
+ ```python
53
+ import tickwork.workflow_states as workflow_states
54
+
55
+ states = workflow_states.load("states.yaml", roles={"dev", "reviewer", "planner"})
56
+
57
+ item = {...} # repo, issue (number/state/assignees/open_dependencies), pr
58
+ logins = {"dev": "d", "reviewer": "r"}
59
+ mapped = workflow_states.evaluate(states, item, logins=logins)
60
+
61
+ items = [item]
62
+ anomalies = workflow_states.check(states, items, logins=logins) # zero- or 2+-matched items
63
+ ```
64
+
65
+ ## Effect log
66
+
67
+ `tickwork.effect_log` is an append-only JSONL log of intended and applied
68
+ writes ("effects"): a caller plans one pending row per effect *before*
69
+ applying any of them, then each row is marked applied / failed / superseded
70
+ with timing. On the next run the caller folds the log and resumes rows that
71
+ are still unfinished, instead of re-deciding a job it can no longer finish.
72
+ The module names no deployment: the log directory is a constructor parameter
73
+ and every call names its role explicitly — there is no default role.
74
+
75
+ ```python
76
+ from tickwork.effect_log import EffectLog
77
+
78
+ log = EffectLog("/var/lib/myapp/effects") # one <role>.jsonl per role
79
+
80
+ rows = log.plan("repo#7", "run-42", [("comment", {"body": "..."})], head_sha="abc", role="bot")
81
+ log.apply(rows[0], lambda: post_comment(rows[0]["payload"])) # marks applied/failed with timing
82
+ log.mark(rows[0], "superseded") # void a row without a write
83
+
84
+ resume = log.unfinished("bot") # {item: [pending rows in seq order]}
85
+ ```
86
+
87
+ `tickwork.effect_log.summarize(rows)` reads the same log back and returns a
88
+ JSON-able dict: per `(role, effect)` counts of `applied` / `failed` /
89
+ `superseded` / `pending` after folding (the latest status row per
90
+ `(item, tick_id, seq)` wins), nearest-rank `p50`/`p90` of `ms` over folded
91
+ `applied` rows (`None` where no measured write exists), a `finished_later`
92
+ count of fold keys whose `failed` status was followed, later in log order, by
93
+ an `applied` one (a resume finished it), and the still-`pending` rows. The
94
+ companion command renders that object read-only, never creating or writing
95
+ the log directory, and exits 0 on a missing or empty one:
96
+
97
+ ```bash
98
+ tickwork effects /var/lib/myapp/effects # every <role>.jsonl
99
+ tickwork effects /var/lib/myapp/effects --role bot # one role
100
+ tickwork effects /var/lib/myapp/effects --json # one JSON object
101
+ tickwork effects /var/lib/myapp/effects --since 2026-07-01
102
+ ```
103
+
104
+ ## Checking a queue
105
+
106
+ A tickwork install carries two commands:
107
+
108
+ - `tickwork check STATES --items ITEMS --logins LOGINS` — report items
109
+ matching zero or 2+ states
110
+ - `tickwork replay RECORDS... --model M --provider openai-compat
111
+ --base-url URL --key-env VAR` — re-send and score the recorded LLM calls
112
+ in *RECORDS* (final transcript records, one JSONL object per line, each
113
+ carrying `final: true` and the full `messages` array) against a model and
114
+ print one JSON metrics line per (record, model, repeat). The metrics line
115
+ carries the record identity (`repo`, `issue`, `role`, `stage`), the
116
+ per-call facts (`ms`, token usage, `finish_reason`, `error`)
117
+ plus the built-in scorer's `response_len` and `exact_match` fields: the
118
+ reply's length and whether it matches the recorded response exactly.
119
+ Exit code `2` means a file could not be read, an unknown `--provider` was
120
+ given or `--repeats` was below 1; a run with zero scored and zero skipped
121
+ records exits `1`.
122
+
123
+ `STATES` is a states file in the format shown above, `ITEMS` is a JSONL file
124
+ with one item per line, and `LOGINS` is a YAML mapping of role name to login.
125
+ The package ships three example files under `examples/`:
126
+
127
+ - `examples/pr-loop.states.yaml` — a states file (see "States as data").
128
+ - `examples/items.jsonl` — three sample work items.
129
+ - `examples/logins.yaml` — the role-to-login map the predicates resolve
130
+ through.
131
+
132
+ Every item line in `ITEMS` is a JSON object with a `repo` name, an `issue`
133
+ map (`number`, `state`, `assignees`, `open_dependencies`) and either a null
134
+ or a `pr` map (`number`, `state`, `assignees`, `requested_reviewers`,
135
+ `reviewed_on_head`, `approved_on_head`). The example items are contrived so
136
+ that one is clean, one matches no state, and one matches two at once, so a
137
+ run over them reports two anomalies and exits `1`. After an install, the
138
+ three files live under `<sys.prefix>/share/tickwork/examples/`; from a
139
+ source checkout, the same files sit at `release/tickwork/examples/`, and
140
+ every path below works with either location:
141
+
142
+ ```bash
143
+ tickwork check examples/pr-loop.states.yaml \
144
+ --items examples/items.jsonl --logins examples/logins.yaml
145
+ # pip-installed: point the arguments at the installed share directories, e.g.
146
+ # tickwork check "$(python -c 'import sys; print(sys.prefix)')/share/tickwork/examples/pr-loop.states.yaml" ...
147
+ ```
148
+
149
+ The check prints one line per item that matches zero or more than one state
150
+ (an anomaly: an item no state owns, or two states contest), then a summary
151
+ line. Exit code `1` means at least one anomaly was found, `0` means the
152
+ input was clean, and `2` means a file could not be read, parsed or validated.
153
+
154
+ ## Replaying recorded calls
155
+
156
+ ```bash
157
+ tickwork replay issue-42/20260915-reviewer-review-final.jsonl \
158
+ --model my-model --provider openai-compat \
159
+ --base-url https://example.internal/v1 --key-env MY_API_KEY
160
+ ```
161
+
162
+ The provider adapter is stdlib-only (`urllib`) and speaks any
163
+ OpenAI-compatible `/chat/completions` endpoint: the base URL and the name of
164
+ the API-key environment variable are flags, not baked-in endpoints. The key
165
+ is read from the environment and used only in the request's `Authorization`
166
+ header — it is never logged or printed. A provider failure (bad URL, missing
167
+ or unset key, refused connection) does not abort the run or change the exit
168
+ code: it rides in the metrics line's `error` field, so an errored call is
169
+ still one printed line with `response: null`. `--provider` names the adapter
170
+ (`openai-compat`; that default is used when the flag is omitted), and
171
+ `--repeats N` (a positive integer) sends each recorded call N times.
172
+
173
+ For library use the replay core is provider- and scorer-agnostic — callers
174
+ supply both:
175
+
176
+ ```python
177
+ import tickwork.replay as replay
178
+
179
+ records = replay.read_records(["run-final.jsonl"])
180
+ skips: dict[str, int] = {}
181
+ for metrics in replay.replay(
182
+ records,
183
+ provider=my_provider, # .generate(model, messages) -> reply
184
+ models=["model-a", "model-b"],
185
+ scorers={("*", "*"): my_scorer}, # (record, reply) -> extra metrics keys
186
+ repeats=2,
187
+ skips=skips, # counted skip reasons
188
+ ):
189
+ print(metrics)
190
+ ```
191
+
192
+ `replay.deep_merge(base, overlay)` is the recursive variant-overlay merge
193
+ (nested mappings merge key by key; other values are replaced) — use it to
194
+ compose a config variant before replaying under it.
195
+
196
+ ## Worker pools
197
+
198
+ `tickwork.pool` provides the concurrency primitives a worker pool needs,
199
+ built on non-blocking `fcntl.flock`: no waiting, no daemon, and no explicit
200
+ cleanup — the kernel releases the lock when the holding process exits, so a
201
+ crashed worker never leaves a stale lock behind. Four functions:
202
+
203
+ - `try_claim(role, state_dir, kind, repo, number)` — claim exclusive
204
+ ownership of one item (e.g. `kind="issue"` or `"pr"`) so two concurrent
205
+ ticks of the same role never work the same item. Returns a context
206
+ manager holding the claim, or `None` when another process already holds
207
+ it.
208
+ - `acquire_worker_slot(role, state_dir, pool_size)` — claim the
209
+ lowest-numbered free slot in `1..pool_size` for per-tick disambiguation
210
+ (branch naming, local state). Returns a context manager yielding the slot
211
+ number, or `None` when every slot is taken — skip the tick rather than
212
+ wait.
213
+ - `lock_worker_slot(role, state_dir, slot)` — claim a *specific* slot's
214
+ lease (no free-slot scan), for when the slot is pinned from outside the
215
+ process.
216
+ - `flock_nonblock(path)` — open *path* and take a non-blocking exclusive
217
+ `flock`; returns the file descriptor, or `None` when another holder has
218
+ the lock. Public so a caller can hold an extra lock file next to the
219
+ generic ones.
220
+
221
+ Lock files live under `<state_dir>/claims/<role>/...` (one per claimed
222
+ item) and `<state_dir>/slots/<role>/<slot>.lock` (one per worker slot). The
223
+ state directory is a call parameter and the module reads no environment
224
+ variables, so it names nothing of the instance it runs for. The module is
225
+ POSIX-only: it fails at import on platforms without `fcntl`.
226
+
227
+ ```python
228
+ import tickwork.pool as pool
229
+
230
+ claim = pool.try_claim("worker", "/var/lib/myapp/state", "issue", "owner/name", 42)
231
+ if claim is None:
232
+ return # another tick already owns issue 42 — skip this one
233
+ with claim:
234
+ ... # compose the work; the claim is released on exit or crash
235
+ ```
@@ -0,0 +1,3 @@
1
+ {"repo": "example/widget", "issue": {"number": 101, "state": "open", "assignees": ["dee"], "open_dependencies": []}, "pr": null}
2
+ {"repo": "example/widget", "issue": {"number": 102, "state": "open", "assignees": ["dee"], "open_dependencies": []}, "pr": {"number": 202, "state": "open", "assignees": [], "requested_reviewers": [], "reviewed_on_head": false, "approved_on_head": false}}
3
+ {"repo": "example/widget", "issue": {"number": 103, "state": "open", "assignees": ["dee"], "open_dependencies": []}, "pr": {"number": 203, "state": "open", "assignees": [], "requested_reviewers": ["ria"], "reviewed_on_head": false, "approved_on_head": true}}
@@ -0,0 +1,7 @@
1
+ # Role name → login. The logins are made up for the example; substitute your
2
+ # own. `human` is needed because the states file's override predicates name it.
3
+ dev: dee
4
+ reviewer: ria
5
+ planner: pip
6
+ verifier: val
7
+ human: hum
@@ -0,0 +1,81 @@
1
+ # The PR loop's states as data: the exactly-one-owner state machine the
2
+ # tickwork workflow engine drives, one entry per state.
3
+ # Load via tickwork.workflow_states.load(); nothing in the loop evaluates
4
+ # these predicates yet (the engine ships them before the loop consumes them).
5
+ #
6
+ # Each state: a predicate over artifacts → (owner, board bucket).
7
+ # `override: true` states are cross-cutting (closed / held by the human /
8
+ # unassigned intake / blocked) and win in listed order. All other predicates
9
+ # must stay disjoint:
10
+ # an item matching zero or 2+ of them is an anomaly, reported by
11
+ # tickwork.workflow_states.check(): nothing in the loop evaluates these
12
+ # predicates yet (the engine ships them before the loop consumes them).
13
+ #
14
+ # Roles are role names (dev, reviewer, planner, verifier) and `human`;
15
+ # the caller maps roles to logins. Never a persona login here.
16
+
17
+ workflow: pr-loop
18
+ version: 1
19
+ states:
20
+ done:
21
+ override: true
22
+ when: { issue.state: closed }
23
+ board: done
24
+ terminal: true
25
+
26
+ waiting_on_human:
27
+ override: true
28
+ # a merged PR leaves this state even while still assigned to the human,
29
+ # so a human merge hands off to `verifying` like an automated one
30
+ when: { any: [ { issue.assignee: human }, { pr.assignee: human, pr.state: open } ] }
31
+ board: waiting_on_human
32
+ owner: human
33
+
34
+ intake:
35
+ # the board buckets unassigned open issues here even when they have open
36
+ # dependencies, so this must win over `blocked`
37
+ override: true
38
+ when: { issue.state: open, issue.assignee: none, pr: none }
39
+ board: intake
40
+ owner: human # visibility only; a human or the planner picks it up
41
+
42
+ blocked:
43
+ override: true
44
+ when: { issue.dependencies: open, not: { issue.assignee: human } }
45
+ board: blocked
46
+ owner: human # unblocking is a manual step; entry assigns the human
47
+
48
+ triage:
49
+ when: { issue.assignee: planner, pr: none }
50
+ board: queued
51
+ owner: planner
52
+
53
+ ready_for_dev:
54
+ when: { issue.assignee: dev, pr: none }
55
+ board: queued
56
+ owner: dev
57
+
58
+ in_review:
59
+ when: { pr.state: open, pr.requested_reviewers: reviewer, pr.reviewed_on_head: false }
60
+ board: in_progress
61
+ owner: reviewer
62
+
63
+ revising:
64
+ when: { pr.state: open, pr.assignee: dev }
65
+ board: in_progress
66
+ owner: dev
67
+
68
+ merging:
69
+ when: { pr.state: open, pr.requested_reviewers: reviewer, pr.approved_on_head: true }
70
+ board: in_progress
71
+ owner: reviewer
72
+
73
+ decomposing:
74
+ when: { pr.state: open, pr.assignee: planner }
75
+ board: in_progress
76
+ owner: planner
77
+
78
+ verifying:
79
+ when: { issue.state: open, pr.state: merged } # whoever merged
80
+ board: in_progress
81
+ owner: verifier
@@ -0,0 +1,34 @@
1
+ # Build metadata for the standalone tickwork distribution: the release
2
+ # pipeline stages this beside a copy of the tickwork/ package. The tree must
3
+ # stay instance-neutral (the boundary lint scans .toml files here with the
4
+ # same forbidden names as the package itself).
5
+ [build-system]
6
+ requires = ["setuptools>=68.0", "wheel"]
7
+ build-backend = "setuptools.build_meta"
8
+
9
+ [project]
10
+ name = "tickwork"
11
+ version = "0.3.0"
12
+ description = "A generic, instance-neutral workflow engine: states, transitions, sweeps and call replay over a queue of work items"
13
+ requires-python = ">=3.11"
14
+ dependencies = [
15
+ "PyYAML>=6.0",
16
+ ]
17
+ license = { text = "MIT" }
18
+
19
+ [project.scripts]
20
+ tickwork = "tickwork.cli:main"
21
+
22
+ [tool.setuptools.packages.find]
23
+ include = ["tickwork*"]
24
+
25
+ # The README documents the example files as installed side data: they are
26
+ # not part of the tickwork package, so data-files is the only way they reach
27
+ # the wheel/sdist. The install destination stays instance-neutral
28
+ # (share/tickwork/examples under sys.prefix).
29
+ [tool.setuptools.data-files]
30
+ "share/tickwork/examples" = [
31
+ "examples/pr-loop.states.yaml",
32
+ "examples/items.jsonl",
33
+ "examples/logins.yaml",
34
+ ]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,11 @@
1
+ """The public tickwork engine: a generic, instance-neutral workflow engine.
2
+
3
+ It drives states, transitions and sweeps over a queue of work items. It is
4
+ developed in-tree but deliberately names nothing of the instance it runs
5
+ for: no persona, host, repository or person names appear here, and the
6
+ package imports only the standard library, PyYAML and other ``tickwork``
7
+ modules — everything
8
+ instance-specific is supplied by the caller.
9
+ """
10
+
11
+ __all__: list[str] = []
@@ -0,0 +1,8 @@
1
+ """Entry point for ``python -m tickwork`` — delegates to the CLI."""
2
+
3
+ import sys
4
+
5
+ from tickwork.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ sys.exit(main())