git-worklog 1.6.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.
- git_worklog/__init__.py +58 -0
- git_worklog/__main__.py +10 -0
- git_worklog/analysis/__init__.py +48 -0
- git_worklog/analysis/commits.py +118 -0
- git_worklog/analysis/coverage.py +149 -0
- git_worklog/analysis/history.py +339 -0
- git_worklog/analysis/manifest.py +432 -0
- git_worklog/analysis/reconcile.py +211 -0
- git_worklog/analysis/refs.py +178 -0
- git_worklog/analysis/results.py +871 -0
- git_worklog/analysis/worktree.py +114 -0
- git_worklog/cli/__init__.py +414 -0
- git_worklog/cli/analyze.py +340 -0
- git_worklog/cli/apply.py +61 -0
- git_worklog/cli/coverage.py +62 -0
- git_worklog/cli/doctor.py +348 -0
- git_worklog/cli/migrate.py +43 -0
- git_worklog/cli/models.py +112 -0
- git_worklog/cli/preview.py +124 -0
- git_worklog/cli/refs.py +55 -0
- git_worklog/cli/reindex.py +72 -0
- git_worklog/cli/report.py +227 -0
- git_worklog/cli/validate.py +252 -0
- git_worklog/cli/version.py +47 -0
- git_worklog/cli/view.py +218 -0
- git_worklog/config.py +79 -0
- git_worklog/data/provider_models.json +34 -0
- git_worklog/dates.py +237 -0
- git_worklog/language.py +295 -0
- git_worklog/markers.py +765 -0
- git_worklog/mdhtml.py +266 -0
- git_worklog/migrate.py +305 -0
- git_worklog/paths.py +85 -0
- git_worklog/preview.py +776 -0
- git_worklog/providers.py +337 -0
- git_worklog/viewer.py +1198 -0
- git_worklog/viewserver.py +120 -0
- git_worklog/writer.py +420 -0
- git_worklog-1.6.0.dist-info/METADATA +352 -0
- git_worklog-1.6.0.dist-info/RECORD +44 -0
- git_worklog-1.6.0.dist-info/WHEEL +5 -0
- git_worklog-1.6.0.dist-info/entry_points.txt +2 -0
- git_worklog-1.6.0.dist-info/licenses/LICENSE +21 -0
- git_worklog-1.6.0.dist-info/top_level.txt +1 -0
git_worklog/preview.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
"""The preview record: what apply will write, decided before anyone confirms it.
|
|
2
|
+
|
|
3
|
+
A preview used to be a *fingerprint* — a hash of everything that must not change
|
|
4
|
+
between the dry-run and the apply — while the content itself stayed in the
|
|
5
|
+
agent's conversation and was handed back at apply time. That makes the guarantee
|
|
6
|
+
backwards: the tool could prove the world had not moved, but not that the bytes
|
|
7
|
+
it wrote were the bytes the user saw. Anything that re-renders between the two
|
|
8
|
+
(a re-run subagent, a lost message, a different model) produces a plausible
|
|
9
|
+
worklog nobody approved, and every check still passes.
|
|
10
|
+
|
|
11
|
+
So the record here is the artifact, not a receipt for one (roadmap §10.1). It
|
|
12
|
+
carries the complete final text of every file the apply will write, and
|
|
13
|
+
``apply`` writes *that* — it takes a preview id and nothing else. There is no
|
|
14
|
+
input at apply time for a re-render to come in through.
|
|
15
|
+
|
|
16
|
+
What is stored, and why each part is here:
|
|
17
|
+
|
|
18
|
+
* **The payload** — every day file's and index.md's final text, plus their
|
|
19
|
+
hashes. This is the whole point.
|
|
20
|
+
* **Repository fingerprint** — identity, git dir, branch, HEAD, submodules and
|
|
21
|
+
(only when the run read it) the working tree. Answers "is this the same
|
|
22
|
+
repository, in the same state, as the one that was analysed?"
|
|
23
|
+
* **Worklog fingerprint** — index.md's hash, each target day file's hash, and
|
|
24
|
+
the day-file listing. Answers "has anyone edited the worklog since?" An added
|
|
25
|
+
or removed day file counts: the index is a function of the whole directory.
|
|
26
|
+
* **Run fingerprint** — the hashes of the analysis run's manifests and results.
|
|
27
|
+
Answers "is this still the analysis that produced this payload?"
|
|
28
|
+
* **Config fingerprint** — the project's language settings. They are read at
|
|
29
|
+
preview time to decide what gets rendered, so a change to them between preview
|
|
30
|
+
and apply means the stored payload is no longer what the project asked for.
|
|
31
|
+
* **TTL and state** — below.
|
|
32
|
+
|
|
33
|
+
The state machine (roadmap §17, PR 6)::
|
|
34
|
+
|
|
35
|
+
previewed ──(apply: verified)──▶ confirmed ──(write lands)──▶ applied
|
|
36
|
+
│ │
|
|
37
|
+
│ └──(write failed, rolled back)──▶ failed
|
|
38
|
+
├──(cancel)──▶ cancelled
|
|
39
|
+
├──(TTL passes)──▶ expired
|
|
40
|
+
└──(the world moved)──▶ stale
|
|
41
|
+
|
|
42
|
+
``expired`` and ``stale`` are computed, never stored: they are facts about the
|
|
43
|
+
world now, not decisions the tool made, and reading a record must not rewrite
|
|
44
|
+
it. ``confirmed`` *is* stored, and stored before the write starts — a record
|
|
45
|
+
still sitting in ``confirmed`` means a process died mid-apply, which is the one
|
|
46
|
+
state worth refusing to guess about.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
from __future__ import annotations
|
|
50
|
+
|
|
51
|
+
import errno
|
|
52
|
+
import hashlib
|
|
53
|
+
import json
|
|
54
|
+
import os
|
|
55
|
+
import socket
|
|
56
|
+
from datetime import datetime, timedelta, timezone
|
|
57
|
+
|
|
58
|
+
from git_worklog import __version__, config, paths, writer
|
|
59
|
+
from git_worklog import markers as wm
|
|
60
|
+
from git_worklog.analysis import RESULTS_SUBDIR, TASKS_SUBDIR
|
|
61
|
+
from git_worklog.analysis import SCHEMA_VERSION as ANALYSIS_SCHEMA_VERSION
|
|
62
|
+
from git_worklog.analysis import history as ah
|
|
63
|
+
from git_worklog.analysis import manifest as am
|
|
64
|
+
from git_worklog.analysis import results as ar
|
|
65
|
+
from git_worklog.analysis import worktree as aw
|
|
66
|
+
|
|
67
|
+
SCHEMA_VERSION = 1
|
|
68
|
+
DEFAULT_TTL_SECONDS = 24 * 3600
|
|
69
|
+
|
|
70
|
+
# Stored states.
|
|
71
|
+
PREVIEWED = "previewed"
|
|
72
|
+
CONFIRMED = "confirmed"
|
|
73
|
+
APPLIED = "applied"
|
|
74
|
+
CANCELLED = "cancelled"
|
|
75
|
+
FAILED = "failed"
|
|
76
|
+
# Computed states -- see the module docstring.
|
|
77
|
+
EXPIRED = "expired"
|
|
78
|
+
STALE = "stale"
|
|
79
|
+
|
|
80
|
+
#: States from which nothing further happens.
|
|
81
|
+
TERMINAL = {APPLIED, CANCELLED, FAILED}
|
|
82
|
+
|
|
83
|
+
MISSING = "missing"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class PreviewError(ValueError):
|
|
87
|
+
"""A refused preview or apply, carrying the wire code its caller reports."""
|
|
88
|
+
|
|
89
|
+
def __init__(self, code: str, message: str, **extra):
|
|
90
|
+
self.code = code
|
|
91
|
+
self.message = message
|
|
92
|
+
self.extra = extra
|
|
93
|
+
super().__init__(message)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _sha256(text: str) -> str:
|
|
97
|
+
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def now_utc(override: "str | None" = None) -> datetime:
|
|
101
|
+
"""Current time, or a caller-supplied one so tests are not clock-dependent."""
|
|
102
|
+
if override:
|
|
103
|
+
dt = datetime.fromisoformat(override)
|
|
104
|
+
return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
|
|
105
|
+
return datetime.now(timezone.utc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# --------------------------------------------------------------------------
|
|
109
|
+
# Fingerprints — the three worlds a preview depends on
|
|
110
|
+
# --------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
def repository_fingerprint(repo: str, include_uncommitted: bool) -> dict:
|
|
113
|
+
"""Everything about the repository the payload depends on."""
|
|
114
|
+
info = ah.repo_info(repo)
|
|
115
|
+
return {
|
|
116
|
+
"identity": ah.identity(repo),
|
|
117
|
+
"root": info["root"],
|
|
118
|
+
"git_dir": info["git_dir"],
|
|
119
|
+
"branch": info["branch"],
|
|
120
|
+
"head": info["head"],
|
|
121
|
+
"submodule_fingerprint": ah.submodule_fingerprint(repo),
|
|
122
|
+
# Only when the run actually read the working tree. A run that analysed
|
|
123
|
+
# commits alone cannot have its output changed by an unrelated edit, and
|
|
124
|
+
# invalidating its preview because someone saved a file would be a
|
|
125
|
+
# refusal with nothing behind it. This is narrower than a literal reading
|
|
126
|
+
# of §10.3's "Working Tree", and deliberately so.
|
|
127
|
+
"worktree_fingerprint": (aw.inspect(repo)["worktree_fingerprint"]
|
|
128
|
+
if include_uncommitted else None),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def worklog_fingerprint(worklog_dir: str, dates: "list[str]") -> dict:
|
|
133
|
+
"""The worklog directory as it stands, in the three ways it can drift.
|
|
134
|
+
|
|
135
|
+
Per-date hashes catch an edit to a day this preview would overwrite;
|
|
136
|
+
``index_sha256`` catches an edit to the index; ``dir_fingerprint`` catches a
|
|
137
|
+
day file appearing or vanishing, which changes the index without touching
|
|
138
|
+
anything this preview names.
|
|
139
|
+
"""
|
|
140
|
+
layout = wm.detect_layout(worklog_dir)
|
|
141
|
+
day_files = {}
|
|
142
|
+
for date in dates:
|
|
143
|
+
path = wm.day_path(worklog_dir, date, layout)
|
|
144
|
+
day_files[date] = _file_sha256(path)
|
|
145
|
+
listing = "\x00".join(sorted(wm.list_day_dates(worklog_dir, layout)))
|
|
146
|
+
return {
|
|
147
|
+
"index_sha256": _file_sha256(wm.index_path(worklog_dir)),
|
|
148
|
+
"day_files": day_files,
|
|
149
|
+
"dir_fingerprint": _sha256(listing),
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _file_sha256(path: str) -> str:
|
|
154
|
+
"""The file's hash, or ``"missing"``. Absent and empty must not collide."""
|
|
155
|
+
try:
|
|
156
|
+
with open(path, "rb") as fh:
|
|
157
|
+
return hashlib.sha256(fh.read()).hexdigest()
|
|
158
|
+
except OSError:
|
|
159
|
+
return MISSING
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _dir_fingerprint(directory: str) -> str:
|
|
163
|
+
"""Hash every ``.json`` in ``directory``, by name and by content."""
|
|
164
|
+
h = hashlib.sha256()
|
|
165
|
+
try:
|
|
166
|
+
names = sorted(n for n in os.listdir(directory) if n.endswith(".json"))
|
|
167
|
+
except OSError:
|
|
168
|
+
return MISSING
|
|
169
|
+
for name in names:
|
|
170
|
+
h.update(name.encode("utf-8"))
|
|
171
|
+
h.update(b"\x00")
|
|
172
|
+
try:
|
|
173
|
+
with open(os.path.join(directory, name), "rb") as fh:
|
|
174
|
+
h.update(fh.read())
|
|
175
|
+
except OSError:
|
|
176
|
+
h.update(b"<unreadable>")
|
|
177
|
+
h.update(b"\x00")
|
|
178
|
+
return h.hexdigest()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def run_fingerprint(run_dir: str) -> dict:
|
|
182
|
+
"""The analysis run's manifests and results, hashed separately.
|
|
183
|
+
|
|
184
|
+
Separately because the two say different things when they move: changed
|
|
185
|
+
results mean someone edited the analysis this payload was written from;
|
|
186
|
+
changed tasks mean the run was re-prepared, possibly asking for a different
|
|
187
|
+
language than the one the payload is written in (§6.2.10).
|
|
188
|
+
"""
|
|
189
|
+
return {
|
|
190
|
+
"tasks_fingerprint": _dir_fingerprint(os.path.join(run_dir, TASKS_SUBDIR)),
|
|
191
|
+
"results_fingerprint": _dir_fingerprint(os.path.join(run_dir, RESULTS_SUBDIR)),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def config_fingerprint(worklog_dir: str) -> dict:
|
|
196
|
+
"""The project settings that decide what a preview renders.
|
|
197
|
+
|
|
198
|
+
Both are read at preview time -- the content language feeds the run, the
|
|
199
|
+
index language decides index.md's headings -- so a change to either between
|
|
200
|
+
preview and apply means the stored bytes are no longer what the project is
|
|
201
|
+
asking for. Recomputing the payload instead would apply something nobody
|
|
202
|
+
saw, which is the failure this whole module exists to prevent.
|
|
203
|
+
"""
|
|
204
|
+
data = config.load(worklog_dir)
|
|
205
|
+
return {"language": config.language(data),
|
|
206
|
+
"index_language": config.index_language(data)}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# --------------------------------------------------------------------------
|
|
210
|
+
# Building the record
|
|
211
|
+
# --------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
def build(run_dir: str, entries: dict, repo: str = ".",
|
|
214
|
+
worklog_dir: "str | None" = None,
|
|
215
|
+
ttl_seconds: "int | None" = None,
|
|
216
|
+
now: "datetime | None" = None) -> dict:
|
|
217
|
+
"""Turn a collected run plus the day's rendered prose into a preview record.
|
|
218
|
+
|
|
219
|
+
``entries`` is ``{date: {"generated_markdown": ...}}`` — the one thing the
|
|
220
|
+
CLI cannot produce, because writing it means reading patches and choosing
|
|
221
|
+
words (§6.1). It comes in here, once, before the user is shown anything, and
|
|
222
|
+
is never asked for again.
|
|
223
|
+
"""
|
|
224
|
+
now = now or now_utc()
|
|
225
|
+
ttl_seconds = DEFAULT_TTL_SECONDS if ttl_seconds is None else ttl_seconds
|
|
226
|
+
if not isinstance(entries, dict) or not entries:
|
|
227
|
+
raise PreviewError("NO_ENTRIES",
|
|
228
|
+
"No entries provided, so there is nothing to preview.")
|
|
229
|
+
|
|
230
|
+
tasks = am.load_tasks(run_dir)
|
|
231
|
+
worklog_dir = worklog_dir or os.path.join(repo, wm.WORKLOG_DIRNAME)
|
|
232
|
+
writer.check_layout(worklog_dir)
|
|
233
|
+
|
|
234
|
+
# A day the run never dispatched has not been analysed, language-checked or
|
|
235
|
+
# evidence-checked. Writing it would put content into the worklog that no
|
|
236
|
+
# part of the pipeline ever looked at -- the same failure `collect` reports
|
|
237
|
+
# as `unknown`, arriving one step later.
|
|
238
|
+
unknown = sorted(set(entries) - set(tasks["dates"]))
|
|
239
|
+
if unknown:
|
|
240
|
+
raise PreviewError(
|
|
241
|
+
"UNKNOWN_DATE",
|
|
242
|
+
f"Run {os.path.basename(run_dir)} never analysed "
|
|
243
|
+
f"{', '.join(unknown)}, so there is no validated result behind "
|
|
244
|
+
f"{'them' if len(unknown) > 1 else 'it'}. Prepare a run that covers "
|
|
245
|
+
f"the dates you want to write.",
|
|
246
|
+
dates=unknown, run_dir=run_dir)
|
|
247
|
+
|
|
248
|
+
# Re-run collect's verdict rather than trust that it was run. `previewed` is
|
|
249
|
+
# only reachable from `collected`, and a preview that took the caller's word
|
|
250
|
+
# for it would be exactly the conversation-dependence this record removes.
|
|
251
|
+
collected = ar.read_run(tasks["results_dir"], tasks["dates"], repo,
|
|
252
|
+
tasks["language"], tasks["required_by_date"])
|
|
253
|
+
if collected["partial_run"]:
|
|
254
|
+
raise PreviewError(
|
|
255
|
+
"RUN_NOT_COLLECTED",
|
|
256
|
+
"This run is partial — some days are missing, invalid, degraded, or "
|
|
257
|
+
"disagree on language — so it cannot go to preview. Fix or re-run "
|
|
258
|
+
"those days first.",
|
|
259
|
+
run_dir=run_dir,
|
|
260
|
+
missing=collected["missing"], degraded=collected["degraded"],
|
|
261
|
+
invalid=[i["date"] for i in collected["invalid"]],
|
|
262
|
+
languages_seen=collected["languages_seen"])
|
|
263
|
+
|
|
264
|
+
info = ah.repo_info(repo)
|
|
265
|
+
meta = {"timezone": tasks["manifests"][tasks["dates"][0]].get("timezone"),
|
|
266
|
+
"branch": info["branch"], "head": info["short_head"]}
|
|
267
|
+
days = writer.plan_days(worklog_dir, entries, meta)
|
|
268
|
+
index = writer.plan_index(worklog_dir,
|
|
269
|
+
{d["date"]: d["summary"] for d in days},
|
|
270
|
+
tasks["language"])
|
|
271
|
+
|
|
272
|
+
include_uncommitted = _include_uncommitted(tasks)
|
|
273
|
+
dates = sorted(entries)
|
|
274
|
+
fingerprint_dates = sorted(set(tasks["dates"]) | set(dates))
|
|
275
|
+
preview_id = _mint_id(now, days, index)
|
|
276
|
+
record = {
|
|
277
|
+
"schema_version": SCHEMA_VERSION,
|
|
278
|
+
"preview_id": preview_id,
|
|
279
|
+
"state": PREVIEWED,
|
|
280
|
+
"created_at": now.isoformat(),
|
|
281
|
+
"expires_at": (now + timedelta(seconds=ttl_seconds)).isoformat(),
|
|
282
|
+
"ttl_seconds": ttl_seconds,
|
|
283
|
+
"confirmed_at": None,
|
|
284
|
+
"applied_at": None,
|
|
285
|
+
"cancelled_at": None,
|
|
286
|
+
"failure": None,
|
|
287
|
+
"versions": {
|
|
288
|
+
"cli": __version__,
|
|
289
|
+
"layout": wm.LAYOUT_VERSION,
|
|
290
|
+
"preview_schema": SCHEMA_VERSION,
|
|
291
|
+
"analysis_schema": ANALYSIS_SCHEMA_VERSION,
|
|
292
|
+
},
|
|
293
|
+
"run": {
|
|
294
|
+
"run_id": os.path.basename(run_dir.rstrip(os.sep)),
|
|
295
|
+
"run_dir": run_dir,
|
|
296
|
+
"dates": tasks["dates"],
|
|
297
|
+
**run_fingerprint(run_dir),
|
|
298
|
+
},
|
|
299
|
+
"repository": repository_fingerprint(repo, include_uncommitted),
|
|
300
|
+
"worklog": worklog_fingerprint(worklog_dir, fingerprint_dates),
|
|
301
|
+
"params": {
|
|
302
|
+
"worklog_dir": os.path.abspath(worklog_dir),
|
|
303
|
+
"timezone": meta["timezone"],
|
|
304
|
+
"include_uncommitted": include_uncommitted,
|
|
305
|
+
"fingerprint_dates": fingerprint_dates,
|
|
306
|
+
},
|
|
307
|
+
"language": {
|
|
308
|
+
"resolved": tasks["language"],
|
|
309
|
+
"source": _language_source(tasks),
|
|
310
|
+
},
|
|
311
|
+
"config": config_fingerprint(worklog_dir),
|
|
312
|
+
"payload": {
|
|
313
|
+
"days": [{
|
|
314
|
+
"date": d["date"],
|
|
315
|
+
"path": d["path"],
|
|
316
|
+
"action": d["action"],
|
|
317
|
+
"manual_preserved": d["manual_preserved"],
|
|
318
|
+
"summary": d["summary"],
|
|
319
|
+
"content": d["content"],
|
|
320
|
+
"content_sha256": _sha256(d["content"]),
|
|
321
|
+
"original_sha256": (_sha256(d["original"])
|
|
322
|
+
if d["original"] is not None else None),
|
|
323
|
+
} for d in days],
|
|
324
|
+
"index": {
|
|
325
|
+
"path": index["index_path"],
|
|
326
|
+
"action": index["action"],
|
|
327
|
+
"dates": index["dates"],
|
|
328
|
+
"preserved_manual": index["preserved_index_manual"],
|
|
329
|
+
"language": index["index_language"],
|
|
330
|
+
"language_source": index["index_language_source"],
|
|
331
|
+
"content": index["content"],
|
|
332
|
+
"content_sha256": _sha256(index["content"]),
|
|
333
|
+
"original_sha256": (_sha256(index["original"])
|
|
334
|
+
if index["original"] is not None else None),
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
# Days the run analysed but that this preview will not write. Usually
|
|
338
|
+
# correct -- a day with no changes gets no file (§6 of worklog-format) --
|
|
339
|
+
# but silently dropping a day the user expected is not something to find
|
|
340
|
+
# out after apply, so the number is put in front of them.
|
|
341
|
+
"not_written": sorted(set(tasks["dates"]) - set(dates)),
|
|
342
|
+
"warnings": _with_unverified(index["warnings"], collected),
|
|
343
|
+
}
|
|
344
|
+
return record
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def _include_uncommitted(tasks: dict) -> bool:
|
|
348
|
+
return any(m.get("include_uncommitted") for m in tasks["manifests"].values())
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _language_source(tasks: dict) -> "str | None":
|
|
352
|
+
first = tasks["manifests"][tasks["dates"][0]]
|
|
353
|
+
return (first.get("language") or {}).get("source")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _with_unverified(warnings: "list[dict]", collected: dict) -> "list[dict]":
|
|
357
|
+
"""Carry the checks that could not be run through to the preview.
|
|
358
|
+
|
|
359
|
+
They do not block: "could not be checked" is not "found to be false". But a
|
|
360
|
+
preview that showed nothing of them would be the validator overstating what
|
|
361
|
+
it verified, which is the failure this whole layer exists to prevent.
|
|
362
|
+
"""
|
|
363
|
+
out = list(warnings)
|
|
364
|
+
for day in collected.get("unverified", []):
|
|
365
|
+
for issue in day["issues"]:
|
|
366
|
+
out.append({"code": issue["code"],
|
|
367
|
+
"message": f"{day['date']}: {issue['message']}"})
|
|
368
|
+
return out
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _mint_id(now: datetime, days: "list[dict]", index: dict) -> str:
|
|
372
|
+
"""Name the record after its payload *and* its moment.
|
|
373
|
+
|
|
374
|
+
The payload alone would be tempting -- identical bytes, identical id -- but
|
|
375
|
+
two previews of the same content would then collide on one file, and the
|
|
376
|
+
second would silently overwrite the first's state. An already-applied record
|
|
377
|
+
losing its "applied" that way is exactly the double-write the state machine
|
|
378
|
+
is there to stop.
|
|
379
|
+
"""
|
|
380
|
+
basis = hashlib.sha256()
|
|
381
|
+
for d in days:
|
|
382
|
+
basis.update(f"{d['date']}\x00{d['content']}\x00".encode("utf-8"))
|
|
383
|
+
basis.update(index["content"].encode("utf-8"))
|
|
384
|
+
basis.update(now.isoformat().encode("utf-8"))
|
|
385
|
+
return f"rw-{now.strftime('%Y%m%d')}-{basis.hexdigest()[:6]}"
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
# --------------------------------------------------------------------------
|
|
389
|
+
# Storage
|
|
390
|
+
# --------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
def preview_path(preview_id: str) -> str:
|
|
393
|
+
return os.path.join(paths.previews_dir(), f"{preview_id}.json")
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def save(record: dict) -> str:
|
|
397
|
+
"""Write the record owner-only, atomically. Returns its path."""
|
|
398
|
+
paths.ensure_dir(paths.previews_dir())
|
|
399
|
+
path = preview_path(record["preview_id"])
|
|
400
|
+
tmp = f"{path}.tmp"
|
|
401
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
402
|
+
try:
|
|
403
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
404
|
+
json.dump(record, fh, ensure_ascii=False, indent=2)
|
|
405
|
+
fh.flush()
|
|
406
|
+
os.fsync(fh.fileno())
|
|
407
|
+
os.replace(tmp, path)
|
|
408
|
+
finally:
|
|
409
|
+
if os.path.exists(tmp):
|
|
410
|
+
os.unlink(tmp)
|
|
411
|
+
return path
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def load(preview_id: str) -> dict:
|
|
415
|
+
path = preview_path(preview_id)
|
|
416
|
+
if not os.path.exists(path):
|
|
417
|
+
raise PreviewError("UNKNOWN_PREVIEW",
|
|
418
|
+
f"No preview found for id {preview_id!r}.",
|
|
419
|
+
preview_id=preview_id)
|
|
420
|
+
try:
|
|
421
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
422
|
+
record = json.load(fh)
|
|
423
|
+
except (OSError, ValueError) as exc:
|
|
424
|
+
raise PreviewError("PREVIEW_UNREADABLE",
|
|
425
|
+
f"Preview {preview_id} could not be read: {exc}",
|
|
426
|
+
preview_id=preview_id)
|
|
427
|
+
stored = record.get("schema_version")
|
|
428
|
+
if stored != SCHEMA_VERSION:
|
|
429
|
+
# A record from another version of this schema may not mean what this
|
|
430
|
+
# code would read it to mean, and a preview is not worth guessing about:
|
|
431
|
+
# rebuilding one is cheap and applying a misread one is not.
|
|
432
|
+
raise PreviewError(
|
|
433
|
+
"PREVIEW_SCHEMA_MISMATCH",
|
|
434
|
+
f"Preview {preview_id} uses schema version {stored}, but this CLI "
|
|
435
|
+
f"reads version {SCHEMA_VERSION}. Build a fresh preview.",
|
|
436
|
+
preview_id=preview_id, found=stored, expected=SCHEMA_VERSION)
|
|
437
|
+
return record
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def public(record: dict) -> dict:
|
|
441
|
+
"""The record without the payload text — what a caller wants to look at.
|
|
442
|
+
|
|
443
|
+
The file contents are the bulk of a record and are already on the user's
|
|
444
|
+
screen from the dry-run. Repeating them in every ``show`` turns a status
|
|
445
|
+
check into a wall of Markdown.
|
|
446
|
+
"""
|
|
447
|
+
out = {k: v for k, v in record.items() if k != "payload"}
|
|
448
|
+
payload = record.get("payload") or {}
|
|
449
|
+
out["files"] = [
|
|
450
|
+
{"path": d["path"], "action": d["action"], "date": d["date"],
|
|
451
|
+
"sha256": d["content_sha256"]}
|
|
452
|
+
for d in payload.get("days", [])
|
|
453
|
+
]
|
|
454
|
+
index = payload.get("index")
|
|
455
|
+
if index:
|
|
456
|
+
out["files"].append({"path": index["path"], "action": index["action"],
|
|
457
|
+
"sha256": index["content_sha256"]})
|
|
458
|
+
return out
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# --------------------------------------------------------------------------
|
|
462
|
+
# The state machine
|
|
463
|
+
# --------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
# Compared between build-time and apply-time, with a label a human can act on.
|
|
466
|
+
# Each entry is a way the world can move underneath a stored payload; nothing
|
|
467
|
+
# the caller re-supplies is listed, because apply takes no input to re-supply it
|
|
468
|
+
# with -- that is the difference this record makes.
|
|
469
|
+
_CONSISTENCY_KEYS = [
|
|
470
|
+
("repository", "identity", "repository identity"),
|
|
471
|
+
("repository", "root", "repository"),
|
|
472
|
+
("repository", "git_dir", "git directory"),
|
|
473
|
+
("repository", "branch", "branch"),
|
|
474
|
+
("repository", "head", "HEAD"),
|
|
475
|
+
("repository", "submodule_fingerprint", "submodules"),
|
|
476
|
+
("repository", "worktree_fingerprint", "working tree"),
|
|
477
|
+
("worklog", "index_sha256", "index.md content"),
|
|
478
|
+
("worklog", "day_files", "day files"),
|
|
479
|
+
("worklog", "dir_fingerprint", "worklog directory listing"),
|
|
480
|
+
("run", "tasks_fingerprint", "analysis tasks"),
|
|
481
|
+
("run", "results_fingerprint", "analysis results"),
|
|
482
|
+
# §6.2.10: a project that changed its language between preview and apply is
|
|
483
|
+
# asking for a different worklog, not the same one rendered differently.
|
|
484
|
+
("config", "language", "project language setting"),
|
|
485
|
+
("config", "index_language", "project index language setting"),
|
|
486
|
+
]
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def snapshot(record: dict) -> dict:
|
|
490
|
+
"""Re-read the world, using only what the record itself says to look at.
|
|
491
|
+
|
|
492
|
+
Apply passes a preview id and nothing else, so the record has to be enough
|
|
493
|
+
to find everything it depends on. That is the property being relied on here.
|
|
494
|
+
"""
|
|
495
|
+
params = record["params"]
|
|
496
|
+
repo = record["repository"]["root"]
|
|
497
|
+
worklog_dir = params["worklog_dir"]
|
|
498
|
+
return {
|
|
499
|
+
"repository": repository_fingerprint(repo, params["include_uncommitted"]),
|
|
500
|
+
"worklog": worklog_fingerprint(worklog_dir, params["fingerprint_dates"]),
|
|
501
|
+
"run": run_fingerprint(record["run"]["run_dir"]),
|
|
502
|
+
"config": config_fingerprint(worklog_dir),
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def _nested(data: dict, group: str, key: str):
|
|
507
|
+
return (data.get(group) or {}).get(key)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
def evaluate(record: dict, current: "dict | None" = None,
|
|
511
|
+
now: "datetime | None" = None) -> dict:
|
|
512
|
+
"""The record's state right now, and why.
|
|
513
|
+
|
|
514
|
+
``current`` is a :func:`snapshot`; omit it to check only the things that need
|
|
515
|
+
no look at the world (TTL and the stored state), which is what ``show``
|
|
516
|
+
wants and what a caller with no repository access can still answer.
|
|
517
|
+
"""
|
|
518
|
+
now = now or now_utc()
|
|
519
|
+
stored = record.get("state", PREVIEWED)
|
|
520
|
+
expires = datetime.fromisoformat(record["expires_at"])
|
|
521
|
+
expired = now > expires
|
|
522
|
+
|
|
523
|
+
mismatches = []
|
|
524
|
+
if current is not None:
|
|
525
|
+
for group, key, label in _CONSISTENCY_KEYS:
|
|
526
|
+
expected = _nested(record, group, key)
|
|
527
|
+
actual = _nested(current, group, key)
|
|
528
|
+
if expected != actual:
|
|
529
|
+
mismatches.append({"field": label, "expected": expected,
|
|
530
|
+
"actual": actual})
|
|
531
|
+
|
|
532
|
+
# A decision the tool already recorded outranks anything about the world:
|
|
533
|
+
# an applied preview is applied whether or not it also expired since.
|
|
534
|
+
if stored in TERMINAL or stored == CONFIRMED:
|
|
535
|
+
state = stored
|
|
536
|
+
elif expired:
|
|
537
|
+
state = EXPIRED
|
|
538
|
+
elif mismatches:
|
|
539
|
+
state = STALE
|
|
540
|
+
else:
|
|
541
|
+
state = PREVIEWED
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
"preview_id": record["preview_id"],
|
|
545
|
+
"state": state,
|
|
546
|
+
"stored_state": stored,
|
|
547
|
+
"applicable": state == PREVIEWED,
|
|
548
|
+
"mismatches": mismatches,
|
|
549
|
+
"expired": expired,
|
|
550
|
+
"expires_at": record["expires_at"],
|
|
551
|
+
"age_seconds": int((now - datetime.fromisoformat(record["created_at"]))
|
|
552
|
+
.total_seconds()),
|
|
553
|
+
"reason": _reason(state, mismatches),
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
_REASONS = {
|
|
558
|
+
PREVIEWED: None,
|
|
559
|
+
APPLIED: "This preview has already been applied.",
|
|
560
|
+
CANCELLED: "This preview was cancelled.",
|
|
561
|
+
FAILED: "A previous apply of this preview failed and was rolled back. "
|
|
562
|
+
"Build a fresh preview rather than retrying this one.",
|
|
563
|
+
CONFIRMED: "A previous apply of this preview was interrupted after it was "
|
|
564
|
+
"confirmed, so whether it wrote anything is unknown. Check the "
|
|
565
|
+
"worklog directory, then build a fresh preview.",
|
|
566
|
+
EXPIRED: "This preview has expired.",
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def _reason(state: str, mismatches: "list[dict]") -> "str | None":
|
|
571
|
+
if state == STALE:
|
|
572
|
+
fields = ", ".join(m["field"] for m in mismatches)
|
|
573
|
+
return f"The state changed since the preview was built: {fields}."
|
|
574
|
+
return _REASONS.get(state)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def cancel(record: dict, now: "datetime | None" = None) -> dict:
|
|
578
|
+
"""Retire a preview the user decided against."""
|
|
579
|
+
now = now or now_utc()
|
|
580
|
+
stored = record.get("state", PREVIEWED)
|
|
581
|
+
if stored in TERMINAL:
|
|
582
|
+
raise PreviewError("PREVIEW_NOT_OPEN",
|
|
583
|
+
f"Preview {record['preview_id']} is already {stored}; "
|
|
584
|
+
f"there is nothing to cancel.",
|
|
585
|
+
preview_id=record["preview_id"], state=stored)
|
|
586
|
+
record["state"] = CANCELLED
|
|
587
|
+
record["cancelled_at"] = now.isoformat()
|
|
588
|
+
save(record)
|
|
589
|
+
return record
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
# --------------------------------------------------------------------------
|
|
593
|
+
# Concurrent apply lock
|
|
594
|
+
# --------------------------------------------------------------------------
|
|
595
|
+
|
|
596
|
+
class ApplyLock:
|
|
597
|
+
"""One writer at a time per worklog directory.
|
|
598
|
+
|
|
599
|
+
Scoped to the directory rather than to the preview because that is where the
|
|
600
|
+
damage would be: two *different* previews applying at once would interleave
|
|
601
|
+
their day-file transactions and rebuild the index from a directory that is
|
|
602
|
+
halfway through someone else's write. Two applies of the *same* preview are
|
|
603
|
+
caught by the state machine, but only if they do not race it.
|
|
604
|
+
|
|
605
|
+
The lock lives under ``~/.git-worklog/tmp/`` -- never in the repository,
|
|
606
|
+
which is project content. A lock left behind by a process that died is
|
|
607
|
+
broken only when it is provably dead: same host, and a pid that is gone.
|
|
608
|
+
Across hosts there is nothing to check, so it is left alone and reported.
|
|
609
|
+
"""
|
|
610
|
+
|
|
611
|
+
def __init__(self, worklog_dir: str, now: "datetime | None" = None):
|
|
612
|
+
key = hashlib.sha256(
|
|
613
|
+
os.path.realpath(worklog_dir).encode("utf-8")).hexdigest()[:16]
|
|
614
|
+
self.worklog_dir = worklog_dir
|
|
615
|
+
self.path = os.path.join(paths.tmp_dir(), f"apply-{key}.lock")
|
|
616
|
+
self._now = now or now_utc()
|
|
617
|
+
self.broke_stale = False
|
|
618
|
+
|
|
619
|
+
def __enter__(self) -> "ApplyLock":
|
|
620
|
+
paths.ensure_dir(paths.tmp_dir())
|
|
621
|
+
try:
|
|
622
|
+
self._create()
|
|
623
|
+
except FileExistsError:
|
|
624
|
+
holder = self._read()
|
|
625
|
+
if not _holder_alive(holder):
|
|
626
|
+
self.broke_stale = True
|
|
627
|
+
os.unlink(self.path)
|
|
628
|
+
self._create()
|
|
629
|
+
else:
|
|
630
|
+
raise PreviewError(
|
|
631
|
+
"APPLY_LOCKED",
|
|
632
|
+
f"Another apply is writing to {self.worklog_dir} "
|
|
633
|
+
f"(pid {holder.get('pid')} on {holder.get('host')}, since "
|
|
634
|
+
f"{holder.get('acquired_at')}). Wait for it to finish.",
|
|
635
|
+
lock_path=self.path, holder=holder)
|
|
636
|
+
return self
|
|
637
|
+
|
|
638
|
+
def _create(self) -> None:
|
|
639
|
+
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
640
|
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
|
641
|
+
json.dump({"pid": os.getpid(), "host": socket.gethostname(),
|
|
642
|
+
"acquired_at": self._now.isoformat(),
|
|
643
|
+
"worklog_dir": self.worklog_dir}, fh)
|
|
644
|
+
|
|
645
|
+
def _read(self) -> dict:
|
|
646
|
+
try:
|
|
647
|
+
with open(self.path, "r", encoding="utf-8") as fh:
|
|
648
|
+
return json.load(fh)
|
|
649
|
+
except (OSError, ValueError):
|
|
650
|
+
# An unreadable lock names no holder, so it can never be proven
|
|
651
|
+
# dead and will never be broken. That is the safe direction: it
|
|
652
|
+
# blocks writes rather than allowing a racing one.
|
|
653
|
+
return {}
|
|
654
|
+
|
|
655
|
+
def __exit__(self, *exc) -> None:
|
|
656
|
+
try:
|
|
657
|
+
os.unlink(self.path)
|
|
658
|
+
except OSError:
|
|
659
|
+
pass
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _holder_alive(holder: dict) -> bool:
|
|
663
|
+
"""Whether the lock's owner still exists. Unknown counts as alive."""
|
|
664
|
+
pid, host = holder.get("pid"), holder.get("host")
|
|
665
|
+
if not isinstance(pid, int) or host != socket.gethostname():
|
|
666
|
+
return True
|
|
667
|
+
try:
|
|
668
|
+
os.kill(pid, 0)
|
|
669
|
+
except OSError as exc:
|
|
670
|
+
# ESRCH: no such process. EPERM: it exists and is someone else's.
|
|
671
|
+
return exc.errno != errno.ESRCH
|
|
672
|
+
return True
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
# --------------------------------------------------------------------------
|
|
676
|
+
# Apply
|
|
677
|
+
# --------------------------------------------------------------------------
|
|
678
|
+
|
|
679
|
+
def apply(record: dict, now: "datetime | None" = None) -> dict:
|
|
680
|
+
"""Write the stored payload, or refuse. Nothing is re-rendered here.
|
|
681
|
+
|
|
682
|
+
The record moves to ``confirmed`` on disk *before* the first byte is written,
|
|
683
|
+
so a crash mid-apply leaves evidence that a write was in flight rather than a
|
|
684
|
+
record that still claims nothing happened.
|
|
685
|
+
"""
|
|
686
|
+
now = now or now_utc()
|
|
687
|
+
verdict = evaluate(record, snapshot(record), now)
|
|
688
|
+
if not verdict["applicable"]:
|
|
689
|
+
# The field list only belongs to a stale verdict. An applied preview
|
|
690
|
+
# also "mismatches" -- against the files it wrote itself -- and printing
|
|
691
|
+
# that next to "already applied" reads as though somebody else had been
|
|
692
|
+
# editing, sending the reader to look for a problem that is not there.
|
|
693
|
+
extra = ({"mismatches": verdict["mismatches"]}
|
|
694
|
+
if verdict["state"] == STALE else {})
|
|
695
|
+
raise PreviewError(
|
|
696
|
+
_refusal_code(verdict["state"]), verdict["reason"],
|
|
697
|
+
preview_id=record["preview_id"], state=verdict["state"],
|
|
698
|
+
instruction="Build a fresh preview before applying.", **extra)
|
|
699
|
+
|
|
700
|
+
params = record["params"]
|
|
701
|
+
payload = record["payload"]
|
|
702
|
+
with ApplyLock(params["worklog_dir"], now) as lock:
|
|
703
|
+
record["state"] = CONFIRMED
|
|
704
|
+
record["confirmed_at"] = now.isoformat()
|
|
705
|
+
save(record)
|
|
706
|
+
|
|
707
|
+
# writer.apply_days wants the shape plan_days returns, and `original`
|
|
708
|
+
# with it -- it is what a rollback restores. Reading it from disk now is
|
|
709
|
+
# sound precisely because the fingerprint check above just proved every
|
|
710
|
+
# target file is byte-identical to what the preview recorded.
|
|
711
|
+
writes = [{**d, "original": _current_text(d["path"])}
|
|
712
|
+
for d in payload["days"]]
|
|
713
|
+
try:
|
|
714
|
+
writer.apply_days(params["worklog_dir"], writes, params["timezone"])
|
|
715
|
+
except Exception as exc: # noqa: BLE001 — any write failure ends the same way
|
|
716
|
+
record["state"] = FAILED
|
|
717
|
+
record["failure"] = f"{type(exc).__name__}: {exc}"
|
|
718
|
+
save(record)
|
|
719
|
+
raise PreviewError(
|
|
720
|
+
"WRITE_FAILED",
|
|
721
|
+
f"Transactional write failed and was rolled back: {exc}",
|
|
722
|
+
preview_id=record["preview_id"],
|
|
723
|
+
worklog_dir=params["worklog_dir"])
|
|
724
|
+
|
|
725
|
+
# The day files are the worklog; index.md is navigation derived from
|
|
726
|
+
# them. Once the day transaction lands, the preview *has* been applied,
|
|
727
|
+
# and the index failing afterwards is a repairable inconsistency rather
|
|
728
|
+
# than a reason to claim nothing happened -- or to tear the day files
|
|
729
|
+
# back out (worklog-format.md §7). So the state is recorded first and
|
|
730
|
+
# the index failure is reported on top of it, with the repair.
|
|
731
|
+
record["state"] = APPLIED
|
|
732
|
+
record["applied_at"] = now.isoformat()
|
|
733
|
+
save(record)
|
|
734
|
+
|
|
735
|
+
try:
|
|
736
|
+
writer.apply_index(payload["index"]["path"], payload["index"]["content"])
|
|
737
|
+
except Exception as exc: # noqa: BLE001
|
|
738
|
+
record["failure"] = f"index write failed: {type(exc).__name__}: {exc}"
|
|
739
|
+
save(record)
|
|
740
|
+
raise PreviewError(
|
|
741
|
+
"INDEX_WRITE_FAILED",
|
|
742
|
+
f"The day files were written, but index.md was not: {exc}. No "
|
|
743
|
+
f"day data is lost — the index is rebuilt from the day files, so "
|
|
744
|
+
f"run `rebuild_worklog_index.py --dir {params['worklog_dir']} "
|
|
745
|
+
f"--apply` to repair it.",
|
|
746
|
+
preview_id=record["preview_id"],
|
|
747
|
+
worklog_dir=params["worklog_dir"], state=APPLIED)
|
|
748
|
+
|
|
749
|
+
written = [d["date"] for d in payload["days"] if d["action"] != "no_change"]
|
|
750
|
+
return {
|
|
751
|
+
"preview_id": record["preview_id"],
|
|
752
|
+
"state": APPLIED,
|
|
753
|
+
"worklog_dir": params["worklog_dir"],
|
|
754
|
+
"written_dates": written,
|
|
755
|
+
"index_action": payload["index"]["action"],
|
|
756
|
+
"index_path": payload["index"]["path"],
|
|
757
|
+
"preserved_manual_dates": sorted(d["date"] for d in payload["days"]
|
|
758
|
+
if d["action"] == "overwrite"
|
|
759
|
+
and d["manual_preserved"]),
|
|
760
|
+
"broke_stale_lock": lock.broke_stale,
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
def _current_text(path: str) -> "str | None":
|
|
765
|
+
try:
|
|
766
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
767
|
+
return fh.read()
|
|
768
|
+
except OSError:
|
|
769
|
+
return None
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
def _refusal_code(state: str) -> str:
|
|
773
|
+
return {STALE: "PREVIEW_STALE", EXPIRED: "PREVIEW_EXPIRED",
|
|
774
|
+
APPLIED: "PREVIEW_ALREADY_APPLIED", CANCELLED: "PREVIEW_CANCELLED",
|
|
775
|
+
FAILED: "PREVIEW_FAILED",
|
|
776
|
+
CONFIRMED: "PREVIEW_INTERRUPTED"}.get(state, "PREVIEW_NOT_APPLICABLE")
|