tickwork 0.3.0__py3-none-any.whl
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/__init__.py +11 -0
- tickwork/__main__.py +8 -0
- tickwork/cli.py +288 -0
- tickwork/effect_log.py +319 -0
- tickwork/pool.py +214 -0
- tickwork/providers/__init__.py +10 -0
- tickwork/providers/openai_compat.py +82 -0
- tickwork/replay.py +134 -0
- tickwork/workflow_states.py +226 -0
- tickwork-0.3.0.data/data/share/tickwork/examples/items.jsonl +3 -0
- tickwork-0.3.0.data/data/share/tickwork/examples/logins.yaml +7 -0
- tickwork-0.3.0.data/data/share/tickwork/examples/pr-loop.states.yaml +81 -0
- tickwork-0.3.0.dist-info/METADATA +9 -0
- tickwork-0.3.0.dist-info/RECORD +18 -0
- tickwork-0.3.0.dist-info/WHEEL +5 -0
- tickwork-0.3.0.dist-info/entry_points.txt +2 -0
- tickwork-0.3.0.dist-info/licenses/LICENSE +21 -0
- tickwork-0.3.0.dist-info/top_level.txt +1 -0
tickwork/__init__.py
ADDED
|
@@ -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] = []
|
tickwork/__main__.py
ADDED
tickwork/cli.py
ADDED
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""The ``tickwork`` command line: ``check`` (the exactly-one-state check),
|
|
2
|
+
``replay`` (re-send recorded LLM calls to a model and score the replies) and
|
|
3
|
+
``effects`` (summarize an effect log).
|
|
4
|
+
|
|
5
|
+
Runs :func:`~tickwork.workflow_states.check` over a states file, a JSONL
|
|
6
|
+
items file and a YAML logins map supplied on the command line, so a
|
|
7
|
+
standalone tickwork installation can run the exactly-one-state check
|
|
8
|
+
without any instance-specific fetcher:
|
|
9
|
+
|
|
10
|
+
.. code-block:: text
|
|
11
|
+
|
|
12
|
+
tickwork check STATES --items ITEMS --logins LOGINS
|
|
13
|
+
|
|
14
|
+
* STATES is loaded with :func:`~tickwork.workflow_states.load`; the allowed
|
|
15
|
+
owner roles are the keys of LOGINS.
|
|
16
|
+
* ITEMS is a JSONL file with one item per line in the shape
|
|
17
|
+
:func:`~tickwork.workflow_states.evaluate` describes.
|
|
18
|
+
* LOGINS is a YAML mapping of role name to login.
|
|
19
|
+
|
|
20
|
+
One line is printed per anomaly (repo, issue, pr, matched states) followed
|
|
21
|
+
by a summary line. Exit codes: ``1`` when any anomaly was found, ``0``
|
|
22
|
+
when clean, ``2`` when a file cannot be read, parsed or validated (e.g. a
|
|
23
|
+
states file naming a role that is not a key of LOGINS).
|
|
24
|
+
|
|
25
|
+
``tickwork effects LOG_DIR [--role ROLE] [--since ISO] [--json]`` summarizes
|
|
26
|
+
an effect log with :func:`~tickwork.effect_log.summarize`. It is read-only:
|
|
27
|
+
it never creates or writes LOG_DIR, and a missing or empty log prints an
|
|
28
|
+
empty summary and exits 0.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import argparse
|
|
34
|
+
import datetime
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import sys
|
|
38
|
+
from collections.abc import Sequence
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
from typing import Any
|
|
41
|
+
|
|
42
|
+
import yaml
|
|
43
|
+
|
|
44
|
+
from tickwork import replay, workflow_states
|
|
45
|
+
from tickwork.effect_log import PENDING, EffectLog, summarize
|
|
46
|
+
from tickwork.providers import OpenAICompatProvider
|
|
47
|
+
|
|
48
|
+
EMPTY_SUMMARY = {"effects": {}, "finished_later": 0, "unfinished": []}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _read_logins(path: str | Path) -> dict[str, str]:
|
|
52
|
+
"""Read a YAML mapping of role name → login from *path*."""
|
|
53
|
+
logins = yaml.safe_load(Path(path).read_text(encoding="utf-8"))
|
|
54
|
+
if not isinstance(logins, dict) or not all(
|
|
55
|
+
isinstance(role, str) and isinstance(login, str) for role, login in logins.items()
|
|
56
|
+
):
|
|
57
|
+
raise ValueError(f"{path}: logins must be a mapping of role name to login")
|
|
58
|
+
return logins
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _read_items(path: str | Path) -> list[dict[str, Any]]:
|
|
62
|
+
"""Read JSONL items, one dict per line, from *path* (blank lines skipped)."""
|
|
63
|
+
items: list[dict[str, Any]] = []
|
|
64
|
+
with open(path, encoding="utf-8") as handle:
|
|
65
|
+
for lineno, line in enumerate(handle, 1):
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if not line:
|
|
68
|
+
continue
|
|
69
|
+
item = json.loads(line)
|
|
70
|
+
if not isinstance(item, dict):
|
|
71
|
+
raise ValueError(f"{path}:{lineno}: item must be a JSON object")
|
|
72
|
+
items.append(item)
|
|
73
|
+
return items
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _format_anomaly(anomaly: dict[str, Any]) -> str:
|
|
77
|
+
return (
|
|
78
|
+
f"{anomaly['repo']}: issue {anomaly['issue']} pr {anomaly['pr']} "
|
|
79
|
+
f"matched {', '.join(anomaly['matched']) or 'nothing'}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# The one CLI provider name, resolving to a stdlib OpenAI-compatible adapter.
|
|
84
|
+
_PROVIDER = "openai-compat"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _length_exact_scorer(record: dict[str, Any], reply: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""The built-in scorer: response length and whether the reply is an
|
|
89
|
+
exact match of the recorded response (null when there is nothing to
|
|
90
|
+
compare against)."""
|
|
91
|
+
recorded = record.get("response")
|
|
92
|
+
response = reply.get("response")
|
|
93
|
+
return {
|
|
94
|
+
"response_len": len(response) if response is not None else None,
|
|
95
|
+
"exact_match": None if recorded is None or response is None else response == recorded,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _run_replay(args: argparse.Namespace) -> int:
|
|
100
|
+
"""Run the generic replay over the given JSONL record paths.
|
|
101
|
+
|
|
102
|
+
Only the ``openai-compat`` provider is wired in: importing
|
|
103
|
+
:mod:`tickwork.providers` in the CLI (not in :mod:`tickwork.replay`)
|
|
104
|
+
keeps the replay core free of transport code.
|
|
105
|
+
"""
|
|
106
|
+
if args.provider != _PROVIDER:
|
|
107
|
+
print(f"tickwork: unknown provider {args.provider!r}", file=sys.stderr)
|
|
108
|
+
return 2
|
|
109
|
+
if args.repeats < 1:
|
|
110
|
+
print(f"tickwork: --repeats must be >= 1 (got {args.repeats})", file=sys.stderr)
|
|
111
|
+
return 2
|
|
112
|
+
skips: dict[str, int] = {}
|
|
113
|
+
lines = 0
|
|
114
|
+
try:
|
|
115
|
+
records = replay.read_records(args.records)
|
|
116
|
+
for metrics in replay.replay(
|
|
117
|
+
records,
|
|
118
|
+
OpenAICompatProvider(base_url=args.base_url, key_env=args.key_env),
|
|
119
|
+
[args.model],
|
|
120
|
+
{("*", "*"): _length_exact_scorer},
|
|
121
|
+
repeats=args.repeats,
|
|
122
|
+
skips=skips,
|
|
123
|
+
):
|
|
124
|
+
print(json.dumps(metrics))
|
|
125
|
+
lines += 1
|
|
126
|
+
except (OSError, ValueError) as exc:
|
|
127
|
+
print(f"tickwork: {exc}", file=sys.stderr)
|
|
128
|
+
return 2
|
|
129
|
+
for reason, count in sorted(skips.items()):
|
|
130
|
+
print(f"skipped {count} record(s): {reason}", file=sys.stderr)
|
|
131
|
+
return 0 if lines or skips else 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _roles_in(log_dir: str) -> list[str]:
|
|
135
|
+
"""Role names with a ``<role>.jsonl`` in *log_dir*, sorted. Read-only."""
|
|
136
|
+
try:
|
|
137
|
+
names = sorted(os.listdir(log_dir))
|
|
138
|
+
except (FileNotFoundError, OSError):
|
|
139
|
+
return []
|
|
140
|
+
return [name[: -len(".jsonl")] for name in names if name.endswith(".jsonl")]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _rows_for(log_dir: str, role: str) -> list[dict]:
|
|
144
|
+
"""All of *role*'s rows. Read-only: a missing log reads as no rows and the
|
|
145
|
+
log directory is never created or written."""
|
|
146
|
+
return EffectLog(log_dir)._read(role)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def print_summary(summary: dict, out=None) -> None:
|
|
150
|
+
"""Render ``summarize()``'s object as text. Pure: writes only to *out*."""
|
|
151
|
+
out = out if out is not None else sys.stdout
|
|
152
|
+
effects = summary.get("effects") or {}
|
|
153
|
+
for role in sorted(effects):
|
|
154
|
+
for effect in sorted(effects[role]):
|
|
155
|
+
s = effects[role][effect]
|
|
156
|
+
p50 = "-" if s["p50"] is None else f"{s['p50']}ms"
|
|
157
|
+
p90 = "-" if s["p90"] is None else f"{s['p90']}ms"
|
|
158
|
+
line = (
|
|
159
|
+
f"{role}/{effect}: applied={s['applied']} failed={s['failed']} "
|
|
160
|
+
f"superseded={s['superseded']} pending={s['pending']} "
|
|
161
|
+
f"finished_later={s['finished_later']} p50={p50} p90={p90}"
|
|
162
|
+
)
|
|
163
|
+
print(line, file=out)
|
|
164
|
+
for u in summary.get("unfinished") or []:
|
|
165
|
+
if u["role"] == role and u["effect"] == effect:
|
|
166
|
+
print(
|
|
167
|
+
f" unfinished: {u['item']} {u['tick_id']}/{u['seq']}",
|
|
168
|
+
file=out,
|
|
169
|
+
)
|
|
170
|
+
if not effects and not summary.get("unfinished"):
|
|
171
|
+
print("(empty log)", file=out)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _rows_since(rows: list[dict], cutoff: float) -> list[dict]:
|
|
175
|
+
"""Drop whole fold keys whose final status row finished before *cutoff*.
|
|
176
|
+
|
|
177
|
+
``--since`` filters fold keys, not rows: a plan row is written with
|
|
178
|
+
``finished: None`` and never rewritten, so dropping only old status rows
|
|
179
|
+
would leave the plan row to fold to pending and report long-finished
|
|
180
|
+
work as unfinished. A key ``(item, tick_id, seq)`` — including every
|
|
181
|
+
status row that resumed it — is dropped when its last row in log order
|
|
182
|
+
has a status other than ``pending`` and a numeric ``finished`` earlier
|
|
183
|
+
than the cutoff; keys still pending survive so they stay resumable.
|
|
184
|
+
"""
|
|
185
|
+
last: dict[tuple, dict] = {}
|
|
186
|
+
for r in rows:
|
|
187
|
+
last[(r.get("item"), r.get("tick_id"), r.get("seq"))] = r
|
|
188
|
+
stale = {
|
|
189
|
+
key
|
|
190
|
+
for key, r in last.items()
|
|
191
|
+
if r.get("status") != PENDING
|
|
192
|
+
and isinstance(r.get("finished"), (int, float))
|
|
193
|
+
and r["finished"] < cutoff
|
|
194
|
+
}
|
|
195
|
+
return [r for r in rows if (r.get("item"), r.get("tick_id"), r.get("seq")) not in stale]
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _effects(args: argparse.Namespace) -> int:
|
|
199
|
+
"""``tickwork effects``: print the effect-log summary. Read-only."""
|
|
200
|
+
roles = args.role
|
|
201
|
+
if not roles:
|
|
202
|
+
roles = _roles_in(args.log_dir)
|
|
203
|
+
cutoff = _since_epoch(args.since) if args.since else None
|
|
204
|
+
all_rows: list[dict] = []
|
|
205
|
+
for role in roles:
|
|
206
|
+
rows = _rows_for(args.log_dir, role)
|
|
207
|
+
if cutoff is not None:
|
|
208
|
+
rows = _rows_since(rows, cutoff)
|
|
209
|
+
all_rows.extend(rows)
|
|
210
|
+
summary = summarize(all_rows) if all_rows else EMPTY_SUMMARY
|
|
211
|
+
if args.json:
|
|
212
|
+
print(json.dumps(summary, sort_keys=True), file=sys.stdout)
|
|
213
|
+
else:
|
|
214
|
+
print_summary(summary)
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _since_epoch(iso_date: str) -> float:
|
|
219
|
+
"""Epoch seconds for an ISO date (a date-only string is UTC midnight)."""
|
|
220
|
+
date = datetime.date.fromisoformat(iso_date)
|
|
221
|
+
return datetime.datetime(date.year, date.month, date.day, tzinfo=datetime.UTC).timestamp()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
225
|
+
parser = argparse.ArgumentParser(prog="tickwork", description=__doc__)
|
|
226
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
227
|
+
check_parser = sub.add_parser("check", help="report items matching zero or 2+ states")
|
|
228
|
+
check_parser.add_argument("states", help="the workflow states file")
|
|
229
|
+
check_parser.add_argument("--items", required=True, help="a JSONL file of items, one per line")
|
|
230
|
+
check_parser.add_argument("--logins", required=True, help="a YAML map of role name to login")
|
|
231
|
+
replay_parser = sub.add_parser(
|
|
232
|
+
"replay",
|
|
233
|
+
help="re-send recorded final transcript records to a model and score the replies",
|
|
234
|
+
)
|
|
235
|
+
replay_parser.add_argument("records", nargs="+", help="JSONL transcript files (finals)")
|
|
236
|
+
replay_parser.add_argument("--model", required=True, help="the model to replay each call against")
|
|
237
|
+
replay_parser.add_argument(
|
|
238
|
+
"--provider", default=_PROVIDER, help=f"the provider adapter (default {_PROVIDER})"
|
|
239
|
+
)
|
|
240
|
+
replay_parser.add_argument("--base-url", required=True, help="the API base URL of the endpoint")
|
|
241
|
+
replay_parser.add_argument(
|
|
242
|
+
"--key-env",
|
|
243
|
+
default="OPENAI_API_KEY",
|
|
244
|
+
help="the env var carrying the API key (the key itself is never logged)",
|
|
245
|
+
)
|
|
246
|
+
replay_parser.add_argument("--repeats", type=int, default=1, help="repeats per record and model")
|
|
247
|
+
effects = sub.add_parser("effects", help="summarize an effect log (read-only)")
|
|
248
|
+
effects.add_argument("log_dir", help="the effects log directory (read-only)")
|
|
249
|
+
effects.add_argument(
|
|
250
|
+
"--role",
|
|
251
|
+
action="append",
|
|
252
|
+
default=None,
|
|
253
|
+
help="role to summarize (repeatable); default: every <role>.jsonl in LOG_DIR",
|
|
254
|
+
)
|
|
255
|
+
effects.add_argument(
|
|
256
|
+
"--since",
|
|
257
|
+
default=None,
|
|
258
|
+
help="ISO date; drop fold keys whose final status row finished before it",
|
|
259
|
+
)
|
|
260
|
+
effects.add_argument("--json", action="store_true", help="print the summary as one JSON object")
|
|
261
|
+
args = parser.parse_args(argv)
|
|
262
|
+
if args.command == "effects":
|
|
263
|
+
return _effects(args)
|
|
264
|
+
|
|
265
|
+
if args.command == "replay":
|
|
266
|
+
return _run_replay(args)
|
|
267
|
+
|
|
268
|
+
# The evaluation stays inside the guard: workflow_states.load() validates
|
|
269
|
+
# only the `owner` field, so a predicate naming a role absent from `logins`
|
|
270
|
+
# raises ValueError here, and that must exit 2 like the other file errors,
|
|
271
|
+
# not surface as a traceback the shell cannot distinguish from anomalies.
|
|
272
|
+
try:
|
|
273
|
+
logins = _read_logins(args.logins)
|
|
274
|
+
states = workflow_states.load(args.states, set(logins))
|
|
275
|
+
items = _read_items(args.items)
|
|
276
|
+
anomalies = workflow_states.check(states, items, logins)
|
|
277
|
+
except (OSError, ValueError, yaml.YAMLError, json.JSONDecodeError) as exc:
|
|
278
|
+
print(f"tickwork: {exc}", file=sys.stderr)
|
|
279
|
+
return 2
|
|
280
|
+
|
|
281
|
+
for anomaly in anomalies:
|
|
282
|
+
print(_format_anomaly(anomaly))
|
|
283
|
+
print(f"{len(items)} items checked, {len(anomalies)} anomalies")
|
|
284
|
+
return 1 if anomalies else 0
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
if __name__ == "__main__":
|
|
288
|
+
sys.exit(main())
|
tickwork/effect_log.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Append-only JSONL effect log, instance-neutral.
|
|
2
|
+
|
|
3
|
+
Ship records every write *before* applying it, then marks the row
|
|
4
|
+
applied / failed / superseded with timing. The next tick resumes unfinished
|
|
5
|
+
effects before deciding again, because once a write has landed the item may
|
|
6
|
+
no longer be in the state that produced it — re-deciding alone can never
|
|
7
|
+
finish the job ("Partial ship").
|
|
8
|
+
|
|
9
|
+
Nothing here is specific to one deployment: the log directory is the
|
|
10
|
+
object's constructor parameter, every call names its role explicitly (there
|
|
11
|
+
is no default role), and the module imports only the standard library.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import math
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
import time
|
|
21
|
+
|
|
22
|
+
# Status vocab. A reader folds rows by (item, tick_id, seq): the latest status
|
|
23
|
+
# row wins; only an `applied` finish counts as done.
|
|
24
|
+
APPLIED = "applied"
|
|
25
|
+
FAILED = "failed"
|
|
26
|
+
SUPERSEDED = "superseded"
|
|
27
|
+
PENDING = "pending"
|
|
28
|
+
DONE_STATUSES = frozenset({APPLIED, SUPERSEDED})
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def fold(rows: list[dict]) -> dict[tuple, str]:
|
|
32
|
+
"""Latest status per (item, tick_id, seq) key."""
|
|
33
|
+
latest: dict[tuple, str] = {}
|
|
34
|
+
for r in rows:
|
|
35
|
+
key = (r.get("item"), r.get("tick_id"), r.get("seq"))
|
|
36
|
+
latest[key] = r.get("status")
|
|
37
|
+
return latest
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class EffectLog:
|
|
41
|
+
"""The append-only JSONL effect log for one directory: one file per role.
|
|
42
|
+
|
|
43
|
+
``role`` is required on every call that does not take it from a row —
|
|
44
|
+
no default — so an omitted role is a TypeError, never a write into some
|
|
45
|
+
default role's file. A row that carries no ``role`` of its own is a
|
|
46
|
+
ValueError; ``apply`` raises it *before* running the effect so no
|
|
47
|
+
not-logged effect ever runs.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
def __init__(self, log_dir: str):
|
|
51
|
+
self.log_dir = log_dir
|
|
52
|
+
|
|
53
|
+
def path(self, role: str) -> str:
|
|
54
|
+
"""The JSONL file for *role*'s effects: ``<log dir>/<role>.jsonl``."""
|
|
55
|
+
return os.path.join(self.log_dir, f"{role}.jsonl")
|
|
56
|
+
|
|
57
|
+
def _append(self, row: dict) -> dict:
|
|
58
|
+
path = self.path(row["role"])
|
|
59
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
60
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
61
|
+
f.write(json.dumps(row, sort_keys=True) + "\n")
|
|
62
|
+
return row
|
|
63
|
+
|
|
64
|
+
def _read(self, role: str) -> list[dict]:
|
|
65
|
+
"""All rows for *role*. A missing or unreadable log reads as empty."""
|
|
66
|
+
try:
|
|
67
|
+
with open(self.path(role), encoding="utf-8") as f:
|
|
68
|
+
lines = f.readlines()
|
|
69
|
+
except (FileNotFoundError, OSError):
|
|
70
|
+
return []
|
|
71
|
+
rows: list[dict] = []
|
|
72
|
+
for line in lines:
|
|
73
|
+
line = line.strip()
|
|
74
|
+
if not line:
|
|
75
|
+
continue
|
|
76
|
+
try:
|
|
77
|
+
data = json.loads(line)
|
|
78
|
+
except ValueError:
|
|
79
|
+
continue # a corrupt line never blinds the reader to the rest
|
|
80
|
+
if isinstance(data, dict):
|
|
81
|
+
rows.append(data)
|
|
82
|
+
return rows
|
|
83
|
+
|
|
84
|
+
def _status_row(self, row, status, error=None, *, started=None, finished=None, ms=None):
|
|
85
|
+
role = row.get("role")
|
|
86
|
+
if not role:
|
|
87
|
+
raise ValueError(
|
|
88
|
+
f"row for item {row.get('item')!r} has no 'role': a status row names its "
|
|
89
|
+
"role explicitly, never a default role's file"
|
|
90
|
+
)
|
|
91
|
+
return {
|
|
92
|
+
"role": role,
|
|
93
|
+
"item": row["item"],
|
|
94
|
+
"tick_id": row["tick_id"],
|
|
95
|
+
"seq": row["seq"],
|
|
96
|
+
"effect": row["effect"],
|
|
97
|
+
"payload": row.get("payload"),
|
|
98
|
+
"head_sha": row.get("head_sha"),
|
|
99
|
+
"status": status,
|
|
100
|
+
"started": started,
|
|
101
|
+
"finished": finished,
|
|
102
|
+
"ms": ms,
|
|
103
|
+
"error": error,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
def plan(self, item, tick_id, effects, head_sha=None, *, role):
|
|
107
|
+
"""Log one ``pending`` row per effect BEFORE any of them is applied, then
|
|
108
|
+
return the rows in order. ``effects`` is a list of ``(effect_name,
|
|
109
|
+
payload)`` pairs; ``seq`` numbers them within this plan (0-based).
|
|
110
|
+
``item`` is ``<repo>#<n>``; ``head_sha`` pins resume to the exact commit
|
|
111
|
+
the rows were decided for, and every row of one plan carries the same
|
|
112
|
+
``tick_id`` for the reader to fold by. Each append is best-effort: a
|
|
113
|
+
failed log write is skipped so a logging outage never blocks the ship
|
|
114
|
+
(that row simply never makes it into the log; the pending in-memory row
|
|
115
|
+
is still returned so the caller can apply its effect).
|
|
116
|
+
"""
|
|
117
|
+
rows = [
|
|
118
|
+
{
|
|
119
|
+
"role": role,
|
|
120
|
+
"item": item,
|
|
121
|
+
"tick_id": tick_id,
|
|
122
|
+
"seq": seq,
|
|
123
|
+
"effect": effect,
|
|
124
|
+
"payload": payload,
|
|
125
|
+
"head_sha": head_sha,
|
|
126
|
+
"status": PENDING,
|
|
127
|
+
"started": None,
|
|
128
|
+
"finished": None,
|
|
129
|
+
"ms": None,
|
|
130
|
+
"error": None,
|
|
131
|
+
}
|
|
132
|
+
for seq, (effect, payload) in enumerate(effects)
|
|
133
|
+
]
|
|
134
|
+
for row in rows:
|
|
135
|
+
try:
|
|
136
|
+
self._append(row)
|
|
137
|
+
except OSError as e:
|
|
138
|
+
print(f"[effect_log] plan append failed for {item}: {e}", file=sys.stderr)
|
|
139
|
+
continue # a failed log write never blocks the ship
|
|
140
|
+
return rows
|
|
141
|
+
|
|
142
|
+
def mark(self, row, status, error=None, *, started=None, finished=None, ms=None):
|
|
143
|
+
"""Append a status row for *row* (a ``plan()`` row). Statuses are
|
|
144
|
+
``applied``, ``failed`` and ``superseded``; ``superseded`` marks a row
|
|
145
|
+
void because the item's head has moved on (the resume path marks each
|
|
146
|
+
remaining row itself).
|
|
147
|
+
|
|
148
|
+
Timing is supplied by ``apply()`` (``started``/``finished`` wall-clock
|
|
149
|
+
+ an ``ms`` duration measured around the write): ``mark()`` alone records
|
|
150
|
+
the *state change* — ``started``/``finished`` are the moment the status
|
|
151
|
+
landed (defaulting to now), and ``ms`` stays None because no write was
|
|
152
|
+
measured. A bare ``mark()`` never claims an ``ms`` the code never took.
|
|
153
|
+
"""
|
|
154
|
+
if started is None:
|
|
155
|
+
started = time.time()
|
|
156
|
+
if finished is None:
|
|
157
|
+
finished = started
|
|
158
|
+
return self._append(self._status_row(row, status, error, started=started, finished=finished, ms=ms))
|
|
159
|
+
|
|
160
|
+
def apply(self, row, fn):
|
|
161
|
+
"""Run ``fn`` and mark *row* ``applied`` or ``failed`` with timing around
|
|
162
|
+
it. Returns ``fn()``'s result; the exception (if any) propagates so the
|
|
163
|
+
caller keeps today's control flow — the log only records what happened.
|
|
164
|
+
|
|
165
|
+
The status append is best-effort here too: an OSError landing on the
|
|
166
|
+
log write is reported as a logging failure, never as the effect having
|
|
167
|
+
failed. A row with no ``role`` is a ValueError raised *before* ``fn``
|
|
168
|
+
runs — the effect never runs for a row that can't be logged."""
|
|
169
|
+
if not row.get("role"):
|
|
170
|
+
raise ValueError(
|
|
171
|
+
f"row for item {row.get('item')!r} has no 'role': refusing to run an "
|
|
172
|
+
"effect that cannot be logged to its role's file"
|
|
173
|
+
)
|
|
174
|
+
started = time.time()
|
|
175
|
+
pc_start = time.perf_counter()
|
|
176
|
+
wall_finished = None
|
|
177
|
+
ms = None
|
|
178
|
+
try:
|
|
179
|
+
result = fn()
|
|
180
|
+
except Exception as e:
|
|
181
|
+
ms = round((time.perf_counter() - pc_start) * 1000, 3)
|
|
182
|
+
wall_finished = time.time()
|
|
183
|
+
try:
|
|
184
|
+
self.mark(row, FAILED, str(e), started=started, finished=wall_finished, ms=ms)
|
|
185
|
+
except OSError as le:
|
|
186
|
+
print(f"[effect_log] apply mark (failed) append error: {le}", file=sys.stderr)
|
|
187
|
+
raise
|
|
188
|
+
wall_finished = time.time()
|
|
189
|
+
ms = round((time.perf_counter() - pc_start) * 1000, 3)
|
|
190
|
+
try:
|
|
191
|
+
self.mark(row, APPLIED, None, started=started, finished=wall_finished, ms=ms)
|
|
192
|
+
except OSError as le:
|
|
193
|
+
print(f"[effect_log] apply mark (applied) append error: {le}", file=sys.stderr)
|
|
194
|
+
return result
|
|
195
|
+
|
|
196
|
+
def latest_plans(self, rows: list[dict]) -> dict:
|
|
197
|
+
"""The latest plan (by log order) per item, as ``{item: [pending rows]}``
|
|
198
|
+
with each row annotated with its folded status. A plan is keyed by its
|
|
199
|
+
``tick_id`` — pending rows of an *earlier* plan for the same item never
|
|
200
|
+
leak into the latest one's row list, so the returned rows are always one
|
|
201
|
+
plan's, in that plan's seq order."""
|
|
202
|
+
plans: dict[str, list[dict]] = {}
|
|
203
|
+
latest_tick: dict[str, str] = {}
|
|
204
|
+
status_by_key = fold(rows)
|
|
205
|
+
pending_rows = [r for r in rows if r.get("status") == PENDING]
|
|
206
|
+
# The item's latest plan is the LAST pending row in log order — a re-plan
|
|
207
|
+
# replaces any earlier draft, whose rows never resume.
|
|
208
|
+
for r in pending_rows:
|
|
209
|
+
latest_tick[r.get("item")] = r.get("tick_id")
|
|
210
|
+
for r in pending_rows:
|
|
211
|
+
item = r.get("item")
|
|
212
|
+
if r.get("tick_id") != latest_tick.get(item):
|
|
213
|
+
continue # an older draft of this item — a re-plan replaced it
|
|
214
|
+
key = (item, r.get("tick_id"), r.get("seq"))
|
|
215
|
+
plans.setdefault(item, []).append({**r, "status": status_by_key.get(key, PENDING)})
|
|
216
|
+
return plans
|
|
217
|
+
|
|
218
|
+
def unfinished(self, role: str):
|
|
219
|
+
"""Items whose latest plan still has rows that are neither ``applied`` nor
|
|
220
|
+
``superseded``: ``{item: [pending rows in seq order]}``. A missing or
|
|
221
|
+
empty log reads as no unfinished items. Never raises."""
|
|
222
|
+
pending = self.latest_plans(self._read(role))
|
|
223
|
+
out: dict[str, list[dict]] = {}
|
|
224
|
+
for item, rows in pending.items():
|
|
225
|
+
live = [r for r in rows if r["status"] not in DONE_STATUSES]
|
|
226
|
+
if live:
|
|
227
|
+
out[item] = sorted(live, key=lambda r: r.get("seq", 0))
|
|
228
|
+
return out
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _percentile(values: list, p: int):
|
|
232
|
+
"""Nearest-rank percentile of *values* — ``idx = ceil(p/100 * n) - 1``, no
|
|
233
|
+
interpolation, stdlib only. Empty input is ``None``, never ``0``."""
|
|
234
|
+
if not values:
|
|
235
|
+
return None
|
|
236
|
+
ordered = sorted(values)
|
|
237
|
+
return ordered[math.ceil(p / 100 * len(ordered)) - 1]
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _empty_stats() -> dict:
|
|
241
|
+
return {
|
|
242
|
+
"applied": 0,
|
|
243
|
+
"failed": 0,
|
|
244
|
+
"superseded": 0,
|
|
245
|
+
"pending": 0,
|
|
246
|
+
"p50": None,
|
|
247
|
+
"p90": None,
|
|
248
|
+
"finished_later": 0,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def summarize(rows: list[dict]) -> dict:
|
|
253
|
+
"""A JSON-able, read-only summary of effect-log *rows*.
|
|
254
|
+
|
|
255
|
+
Rows are folded by ``(item, tick_id, seq)`` — the latest status row wins —
|
|
256
|
+
and counted per ``(role, effect)`` as ``applied`` / ``failed`` /
|
|
257
|
+
``superseded`` / ``pending``; a folded ``pending`` key is its item's still
|
|
258
|
+
unfinished work. ``p50``/``p90`` are nearest-rank percentiles over ``ms``
|
|
259
|
+
on folded-``applied`` keys only (``mark()`` records a state change without
|
|
260
|
+
measuring a write and leaves ``ms`` None; only ``apply()`` measures it),
|
|
261
|
+
and stay ``None`` when no applied ``ms`` exists. ``finished_later`` counts
|
|
262
|
+
fold keys — one each — whose status sequence holds a ``failed`` row
|
|
263
|
+
followed, later in log order, by an ``applied`` row for the same key: a
|
|
264
|
+
resume finished it. ``unfinished`` lists the folded-pending rows in log
|
|
265
|
+
order. The command never writes to the log directory.
|
|
266
|
+
"""
|
|
267
|
+
folded_rows: dict[tuple, dict] = {}
|
|
268
|
+
for r in rows:
|
|
269
|
+
key = (r.get("item"), r.get("tick_id"), r.get("seq"))
|
|
270
|
+
folded_rows[key] = r # the last row in log order wins, like fold()
|
|
271
|
+
|
|
272
|
+
groups: dict = {}
|
|
273
|
+
ms_by_group: dict[tuple, list] = {}
|
|
274
|
+
for _key, r in folded_rows.items():
|
|
275
|
+
gkey = (r.get("role"), r.get("effect"))
|
|
276
|
+
stats = groups.setdefault(gkey[0], {}).setdefault(gkey[1], _empty_stats())
|
|
277
|
+
status = r.get("status")
|
|
278
|
+
if status == APPLIED:
|
|
279
|
+
stats["applied"] += 1
|
|
280
|
+
ms = r.get("ms")
|
|
281
|
+
if ms is not None:
|
|
282
|
+
ms_by_group.setdefault(gkey, []).append(ms)
|
|
283
|
+
elif status == FAILED:
|
|
284
|
+
stats["failed"] += 1
|
|
285
|
+
elif status == SUPERSEDED:
|
|
286
|
+
stats["superseded"] += 1
|
|
287
|
+
else:
|
|
288
|
+
stats["pending"] += 1
|
|
289
|
+
|
|
290
|
+
for gkey, ms_values in ms_by_group.items():
|
|
291
|
+
stats = groups[gkey[0]][gkey[1]]
|
|
292
|
+
stats["p50"] = _percentile(ms_values, 50)
|
|
293
|
+
stats["p90"] = _percentile(ms_values, 90)
|
|
294
|
+
|
|
295
|
+
finished_later = 0
|
|
296
|
+
seen_failed: set[tuple] = set()
|
|
297
|
+
for r in rows: # log order: a `failed` row finished by a later `applied` row
|
|
298
|
+
key = (r.get("item"), r.get("tick_id"), r.get("seq"))
|
|
299
|
+
if r.get("status") == FAILED:
|
|
300
|
+
seen_failed.add(key)
|
|
301
|
+
elif r.get("status") == APPLIED and key in seen_failed:
|
|
302
|
+
groups.setdefault(r.get("role"), {}).setdefault(r.get("effect"), _empty_stats())[
|
|
303
|
+
"finished_later"
|
|
304
|
+
] += 1
|
|
305
|
+
finished_later += 1
|
|
306
|
+
seen_failed.discard(key) # count each fold key once
|
|
307
|
+
|
|
308
|
+
unfinished = [
|
|
309
|
+
{
|
|
310
|
+
"role": r.get("role"),
|
|
311
|
+
"item": r.get("item"),
|
|
312
|
+
"tick_id": r.get("tick_id"),
|
|
313
|
+
"seq": r.get("seq"),
|
|
314
|
+
"effect": r.get("effect"),
|
|
315
|
+
}
|
|
316
|
+
for r in folded_rows.values()
|
|
317
|
+
if r.get("status") not in (APPLIED, FAILED, SUPERSEDED)
|
|
318
|
+
]
|
|
319
|
+
return {"effects": groups, "finished_later": finished_later, "unfinished": unfinished}
|