cctally 1.93.1 → 1.94.0
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.
- package/CHANGELOG.md +43 -0
- package/bin/_cctally_cache.py +55 -6
- package/bin/_cctally_codex.py +35 -3
- package/bin/_cctally_config.py +108 -2
- package/bin/_cctally_core.py +37 -8
- package/bin/_cctally_dashboard.py +6 -0
- package/bin/_cctally_dashboard_share.py +744 -137
- package/bin/_cctally_dashboard_sources.py +9 -0
- package/bin/_cctally_db.py +315 -40
- package/bin/_cctally_doctor.py +188 -3
- package/bin/_cctally_five_hour.py +39 -12
- package/bin/_cctally_forecast.py +29 -47
- package/bin/_cctally_journal.py +79 -7
- package/bin/_cctally_parser.py +71 -1
- package/bin/_cctally_project.py +0 -2
- package/bin/_cctally_record.py +18 -0
- package/bin/_cctally_reporting.py +0 -9
- package/bin/_cctally_retention.py +2890 -0
- package/bin/_cctally_share.py +97 -92
- package/bin/_cctally_source_analytics.py +44 -16
- package/bin/_cctally_store.py +194 -14
- package/bin/_lib_artifact_retention.py +1645 -0
- package/bin/_lib_display_tz.py +18 -0
- package/bin/_lib_doctor.py +378 -24
- package/bin/_lib_share.py +1359 -183
- package/bin/_lib_share_templates.py +181 -69
- package/bin/_lib_view_models.py +12 -0
- package/bin/cctally +36 -4
- package/dashboard/static/assets/{index-HlIK7k8Q.js → index-CaUQziq_.js} +48 -48
- package/dashboard/static/assets/index-DUQjFSX7.css +1 -0
- package/dashboard/static/dashboard.html +2 -2
- package/package.json +3 -1
- package/dashboard/static/assets/index-DwWJOYxd.css +0 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,49 @@ based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [1.94.0] - 2026-08-08
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Every share artifact now states, directly under its title, the period it covers, the timezone those dates are in, and whether its project names are anonymized — in Markdown, HTML and SVG alike, from the command line and from the dashboard. Most artifacts said none of this: twenty-seven of the forty-three places a snapshot is built passed no subtitle, and the renderer gated both the subtitle and the generation timestamp on that one field, so those exports could not be dated or bounded by the person reading them. A report whose data contains no project names states the period and the timezone and stops, rather than claiming a privacy mode it has no basis for, and the claim is read from what the render pipeline actually did rather than guessed from the labels — so a project genuinely named `project-1` is reported as a real name (#503).
|
|
12
|
+
- `cctally budget --format html` and `--format svg` now show the `ok`/`warn`/`over` verdict and the budget target. Both live in the artifact's totals, which HTML and SVG rendered nowhere, so those two formats silently omitted the two figures the report exists to deliver. Totals now appear once in each of the three formats (#503).
|
|
13
|
+
- Renaming a saved share preset now keeps the preset. It was carried out as a create-then-delete pair that rebuilt the record from four fields, so a renamed Codex preset came back labelled Claude, its "Saved at" time was reset even though the recipe had not changed, and renaming onto a name another preset already held destroyed that preset without asking. Renaming is now one operation on the server, which moves the stored record whole, and both renaming and saving onto an existing name ask you to confirm the replacement first (#503).
|
|
14
|
+
- A multi-panel composed export now appears in Recent shares. The composer's five export buttons recorded nothing at all, so a composed report left no trace in the history the single-panel share modal writes to. A composed export is shown in every panel's list, labelled with how many sections it contained, and is a record rather than a shortcut — clicking it does nothing, because restoring a basket from history does not exist (#503).
|
|
15
|
+
- `cctally doctor` gained a `db.retained_artifacts` check that reports how much disk the retained corruption evidence occupies, how much of it is reclaimable, and how much is protected. It warns when a reclamation is due and would bring you back inside your policy, and fails when evidence cctally cannot classify is what holds you over a bound, or when the retention policy is malformed. Both messages name the rule that is actually unsatisfied — the age bound, the per-family count, the size budget or the free-disk floor — and all of them when more than one applies. The last surviving example of each distinct kind of damage is reported as information rather than as a problem, because keeping it is what your policy asks for. It also warns, once, if a reclamation left an entry nothing can finish — a file it cannot decide about, or one it is not permitted to delete — naming the plan and the file to inspect, because nothing else would ever mention it. If the scan itself cannot run, the check warns and names the reason rather than reading as a healthy install with nothing retained. The scan itself is measured only when you run `cctally doctor`; the dashboard and the terminal dashboard report "not scanned" instead, so their refresh does not pay for a walk of your data directory (#496).
|
|
16
|
+
- A new `cctally db prune` reclaims the corruption evidence cctally has retained — quarantine incidents, forensics bundles, rebuild records and the backups `db repair` writes for itself — down to what your `storage.artifact_retention` policy keeps. It previews by default and prints what it would delete, what it would keep, and why anything is protected, in whichever unit carries a real figure rather than rounding a small corpus to `0.00 GiB`; `--yes` applies it, `--include-backups` also reaches families you made with `cctally db backup`, and `--json` emits the whole plan. Four things are never deleted: evidence cctally could not determine the cause of, evidence something is still using, the last surviving example of each distinct kind of damage — one example per kind, for as many kinds as `max_shape_examples` allows — and any `.bak-*` file whose name cctally does not recognize. Deletion is crash-safe — every file is renamed to a tombstone and recorded before anything is unlinked, so an interrupted run is finished by the next one. If the policy block is present but malformed, the command deletes nothing and exits 2 in both modes (#496).
|
|
17
|
+
- Ordinary successful commands now schedule at most one background reclamation a day, so the retained evidence stays inside your policy without you running anything. It never runs from `cctally statusline`, `cctally doctor`, a report, a failed command, `cctally db prune` itself, or the preview half of a command that previews before it applies — `cctally record-credit`, `cctally db rederive` and `cctally db journal-repair` schedule nothing until you pass `--yes`. An interrupted reclamation is finished immediately rather than waiting for the next daily window. Set `CCTALLY_DISABLE_RETENTION_SWEEP=1` to switch the automatic pass off and reclaim by hand (#496).
|
|
18
|
+
- A new `storage.artifact_retention` setting bounds the on-disk evidence corruption recovery keeps: `cctally config set storage.artifact_retention '{"max_age_days": 14}'`. The block takes `max_age_days`, `max_count_per_family`, `max_total_mib` and `min_free_mib` — any of which may be `null` to switch that rule off, as long as one of the first three stays on — plus `max_shape_examples`, which is never nullable because the last surviving example of a distinct kind of damage is always kept. Fields you omit keep their default, so you can set one without restating the rest. The defaults are 30 days, 20 incidents per database family, 4096 MiB in total and a 10240 MiB free-disk floor, keeping 8 damage-shape examples. If the setting is present but malformed, cctally does not fall back to the defaults: it switches automatic reclamation off, reports the problem in `cctally doctor`, and refuses to prune until you fix or remove the block (#496).
|
|
19
|
+
|
|
20
|
+
### Changed
|
|
21
|
+
- Share artifacts now print full dates. A report covering January 2020 to May 2026 read `Jan 01 → May 09` and named neither year; period labels, report titles and week, day and session cells are all full ISO now (#503).
|
|
22
|
+
- `--no-branding` now strips the advertisement and keeps the provenance. In Markdown it removed the entire frontmatter — the export's title, period, panel and privacy mode went with the version stamp — while HTML and SVG lost only their footer and kept their timestamps. It now removes the `Generated by cctally` footer and the `cctally_version` key, and nothing else, in all three formats (#503).
|
|
23
|
+
- The timezone a share artifact names is now a concrete zone. Command-line artifacts labelled every period `(local)`, which names no zone at all, so a reader could not tell which day boundary the dates used. The configuration tokens `local` and `utc` are resolved before the artifact is built, and the period is computed in the zone it is labelled with — a five-hour block or a session that began at 03:30 UTC is now dated as the previous day in an American zone, as it should be, while a daily or monthly bucket keeps the calendar day it is named after (#503).
|
|
24
|
+
- A composed multi-panel document now names cctally and its version in its footer, as a single-panel one does. It read only `cctally · composed`, so stitching two panels together dropped the provenance either of them carried on its own (#503).
|
|
25
|
+
- A composed multi-panel document now has one top-level heading and one heading per section. Markdown printed each section's title twice, at two different ranks, and HTML wrapped a composite `<h1>` around one `<h1>` per section. When a document combines Claude and Codex sections of the same report, each heading now names its provider, because both sections otherwise carry the same title (#503).
|
|
26
|
+
- Clearing the report basket, deleting a preset, and replacing one by saving or renaming over it now ask before they act. All four committed on the first click, and `Clear all` wrote straight through to the browser's storage with nothing to undo it. Each now shows a confirmation naming exactly what it is about to destroy — how many sections, or which preset — and Escape or Cancel leaves everything as it was (#503).
|
|
27
|
+
- The composer's per-section menu no longer offers "Preview only this". It was wired to nothing: clicking it did nothing and said nothing. The documentation that described it has been corrected along with several other claims about affordances the share surface does not have (#503).
|
|
28
|
+
- Everywhere cctally describes what `stats.db` is worth, it now tells you which of two situations you are in instead of assuming the worse one. When cctally has retained journal data for your installation — which it does on any install that has been through the journal cutover — `stats.db` is a disposable index derived from that journal: cctally rebuilds or heals it automatically, and deleting it loses nothing. Only on a pre-cutover installation with no retained journal data may `stats.db` be the only copy of your recorded history, and there cctally preserves it, refuses to replace it with an empty rebuild, and points you at `cctally db repair --db stats --yes` instead. Thirteen places said the second thing unconditionally — the file map, the `doctor` integrity check and its remediation, the `db repair` and `db recover` help and refusals, and the message printed when an open finds the index damaged — which told most users their disposable index was irreplaceable and left them running a guarded repair where a rebuild was the answer. `docs/runtime-data.md` also documents, for the first time, every artifact corruption recovery leaves behind: the quarantined incidents, the forensics bundles and their write-ahead-log evidence, the rebuild records, the heal-event ring and the classification files — each with what bounds it and the standing rule that evidence cctally could not determine the cause of is never deleted automatically (#496).
|
|
29
|
+
- `cctally doctor`'s auto-heal check now tells you how often corruption is happening, not just that it happened once. It states how many incidents there are and how long ago the most recent one was — in hours when it was today, rather than reporting everything under a day as "0d ago" — and it escalates to a failure when three heals occur within a week, or when the same kind of damage appears in two separate incidents within a week. A forensics bundle is no longer counted as an incident of its own, so a quarantine directory and the bundle written moments before it read as one incident rather than two (#496).
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
- Public snapshot releases now preserve GitHub contributor credit without merging an unadopted implementation or adding a second public commit. The private release record queues each contributor's Git-linked name and email plus its issue/PR scope, and each release binds the next identity as its single snapshot commit's primary author — the metadata GitHub's repository contributor endpoint actually counts — while its tree, message, tag annotation, CHANGELOG body, privacy boundary, and resume guarantees stay unchanged. The first fix-forward record credits @darlingm for public issue #6 and PR #7, whose safer in-house implementation shipped in v1.92.0/v1.92.1. A same-release retry also now refuses an existing conflicting tag before moving public `main`, closing the tree-neutral attribution hazard that could append a duplicate snapshot and only then fail (#506).
|
|
33
|
+
- The public-mirror dependency guard now rejects public tests that hide private fixture dependencies behind dynamic path segments or irrelevant gate-call metadata, preventing missing-fixture failures that appear only in the public clone (#523).
|
|
34
|
+
- A dark-theme share artifact now prints legibly. The print stylesheet set black text on the page body and stopped there, but every element in the document carries its own colour, so nothing inherited the correction and the pale grey text reached paper at a contrast ratio of 1.24 to 1 — effectively blank. The embedded chart was worse: it paints a near-black background rectangle that no rule about text colour could reach. Text, table backgrounds, the chart canvas and the chart's axis lines are all corrected now, and so are the chart's data colours: a printed dark forecast used to show its 90% ceiling line at a contrast of 1.67 to 1 — essentially invisible — while the light version of the same report printed clearly. Each reference line and its label now print the same colour — in the light theme as well as the dark one, where the label was going black above a line that stayed amber — so the colour that marks a threshold still means something on paper. Printing also fits the page again: the chart and the table each scroll inside their own box on screen, and on paper a scroll box is a crop, so the chart lost its right edge — where the bar values and the last axis label sit — and a long table printed its first page and dropped the rest. Both boxes are released when printing, and the chart is scaled to the sheet (#503).
|
|
35
|
+
- A composed document exported to a dark theme now has a visible title. The composite heading was the only text in the whole document with no colour of its own, so it fell back to the browser's default black on a near-black background. A composed SVG also gains the title, footer and background it never had: its section gaps showed through as transparent, and `--no-branding` had no effect on it at all, because there was no footer to remove (#503).
|
|
36
|
+
- Chart labels no longer run off the edge of the canvas, print on top of each other, or push the page sideways. The horizontal bar chart reserved a fixed gutter on the left and ten pixels on the right, so a revealed project name was clipped at the left edge and every value label — even `$0.01` — was clipped at the right; the line chart's y-axis label reserved nothing at all and started six pixels outside the canvas in every forecast export. Both edges are measured now and the canvas widens to fit, so bars are not shrunk and labels are not truncated. An axis whose labels cannot all fit — a 5-hour block chart, whose ticks are full timestamps — now drops the ones that do not fit rather than printing them over one another as a single unreadable string. The first label is always kept, and the last one is kept whenever it fits beside it (#503).
|
|
37
|
+
- An HTML export no longer scrolls sideways, and its chart stays readable on a narrow screen. The chart and the data table each scroll inside their own box now. Previously the chart was scaled down to fit the page instead — on a 420px screen its axis labels came out below six pixels tall — and the table, which is what actually pushed the page sideways, had no box at all, so its rightmost columns were reachable only by scrolling the whole document (#503).
|
|
38
|
+
- Chart text is measured by the width each letter actually takes, in every script. Every character was estimated at the same width, which under-reserved space for capitals by nearly forty per cent and over-reserved it for narrow letters by more than double — so a wide label was cut off while a narrow one wasted a third of the canvas, and the same estimate decides where an SVG table truncates a cell. Chinese, Japanese and Korean names and emoji were under-reserved by thirty to fifty per cent even after the first correction, so a project named in Chinese had its cost printed on top of its own name (#503).
|
|
39
|
+
- A share artifact now states a period that describes what that artifact actually shows, and the answer depends on the format. Markdown draws no chart, so a weekly Markdown recap states the week it is about while the HTML and SVG exports of the same report state the eight weeks their chart draws; previously all three claimed the eight weeks, so one week's spend sat under an eight-week heading. In the other direction, several `--source`-aware reports stated a period NARROWER than their own table — one of them a single instant above a row dated three days earlier — and now cover every row they print. `cctally five-hour-blocks` also ended its stated period at the newest block's start rather than its end. Each report's title still names the week or block it is about, and no report's period is ever narrowed: one that covered nine days and found rows on five still says nine (#503).
|
|
40
|
+
- The `period:` field in an exported Markdown file's frontmatter now ends after the last thing the export shows. Where the stated window had to be widened to cover a row the report displays, the machine-readable end was set to midnight of that day — so a row printing a full timestamp such as `2026-05-08T10:00:00+00:00` fell outside the range the file declared about itself, and a month bucket ended the range on the first of the month instead of the last. The dates a reader sees are unchanged (#503).
|
|
41
|
+
- A share artifact is now typographically the same file as an SVG and as an HTML page. Only the SVG table cells named a font, so everything else — the title, the period line, the timestamp, the footer and every chart label — fell back to whatever the viewer chose, which is a serif face in Chrome (#503).
|
|
42
|
+
- A chart-only Markdown export no longer carries a blank gap where its table would have been, and the Projects visual no longer emits an empty table header in any format — its description said it had no table while it declared one. No artifact draws a table header with no rows under it any more: an empty-result report used to print a four-column frame around nothing under a title that already read "no data", and one Codex report printed "Codex quota state is unavailable." and then drew the frame anyway (#503).
|
|
43
|
+
- The project column in a `--source`-aware report is labelled `Project` again. Its header was being anonymized along with the data, so the column was titled `project-1` — and in a two-provider report the header and the rows disagreed about which alias they meant, leaving the column effectively unlabelled (#503).
|
|
44
|
+
- A shared Codex quota block now states a full date. It carried the dashboard's compact `13:00 May 07` chip text, which names no year, while every other block export states the full timestamp (#503).
|
|
45
|
+
- `cctally <command> --source all --format …` now exits 3 when it refuses to write an artifact on privacy grounds, matching every single-source command. It exited 1 with a traceback instead, so the exit code the documentation promises was true of one path and not the other (#503).
|
|
46
|
+
- A basket section no longer goes "Outdated" just because a moment passed. The check that compares a section against current data was reading the clock at microsecond resolution: two exports of the same unchanged report a millisecond apart disagreed, so the badge was guaranteed for any section old enough to notice, and "Refresh from current data" could not clear it. It now compares what the report is made of — its title, period, rows, chart, totals and notes — so it moves when the report's own contents move and stays put otherwise. One consequence follows from that rule rather than from the clock: a current-week recap or detail prints how many days remain in the week to one decimal place, so such a section does still turn Outdated when that printed figure changes, which happens about every two and a half hours. Hiding a section's chart or table is no longer treated as a data change either, and two different five-hour windows on the same day are no longer treated as the same one (#503).
|
|
47
|
+
- An export that never happened is no longer reported as a success. Opening a report in a new tab did nothing at all when the browser blocked the pop-up, and printing reported success when there was no print dialog to open — and both wrote a Recent-shares entry for the export you did not get. Both now tell you what went wrong and record nothing, and both show a confirmation when they do succeed (#503).
|
|
48
|
+
- The share modal now warns you that the preview failed before you export, rather than after. A failed preview was visible only in the preview pane, so the export buttons beside it said nothing about it; the privacy line beside them also kept making a definite claim about project names it no longer had any basis for. The buttons stay available, because an export re-fetches and may well succeed (#503).
|
|
49
|
+
- `stats.db`, its write-ahead log files and the `logs/` directory are now created readable and writable only by you, matching `cache.db`, `conversations.db` and the data directory itself. **Nothing was exposed by this and no action is required:** cctally's data directory is already private to your account, so no other user on the machine could reach these files whatever their own permissions said. It is corrected because a copy of a file carries its own permissions with it, a data directory you create yourself need not be private, and a layer that disagrees with the ones around it becomes a real hole the moment one of them changes. cctally now checks the permissions each time it opens or writes the database and corrects them only when they are wrong, so an ordinary command does no extra work and a database whose permissions are changed underneath it is repaired on the next command rather than staying wrong until it is recreated (#496).
|
|
50
|
+
|
|
8
51
|
## [1.93.1] - 2026-08-07
|
|
9
52
|
|
|
10
53
|
### Fixed
|
package/bin/_cctally_cache.py
CHANGED
|
@@ -8075,7 +8075,15 @@ def _cache_open_guarded() -> sqlite3.Connection:
|
|
|
8075
8075
|
str(pid) for pid in sorted(open_pids)
|
|
8076
8076
|
)
|
|
8077
8077
|
)
|
|
8078
|
-
|
|
8078
|
+
# #496 S6 §5.3: a resume publishes the incident's final
|
|
8079
|
+
# manifest, so it is a producer too.
|
|
8080
|
+
import _cctally_retention as _retention_sib
|
|
8081
|
+
with _retention_sib.retention_shared(
|
|
8082
|
+
label="cache quarantine resume"
|
|
8083
|
+
):
|
|
8084
|
+
_cctally_db_sib.quarantine_db_family(
|
|
8085
|
+
path, strict=True,
|
|
8086
|
+
)
|
|
8079
8087
|
except OSError as exc:
|
|
8080
8088
|
fcntl.flock(lock_fh, fcntl.LOCK_UN)
|
|
8081
8089
|
raise sqlite3.DatabaseError(
|
|
@@ -8330,6 +8338,12 @@ def _recover_corrupt_cache(
|
|
|
8330
8338
|
_close_cache_trigger_connection_best_effort(active_conn)
|
|
8331
8339
|
_cctally_db_sib._release_repair_marker(path, claim)
|
|
8332
8340
|
raise
|
|
8341
|
+
# #496 S6 §5.3: this producer holds `artifact-retention.lock` SHARED from
|
|
8342
|
+
# before its forensics bundle through the final manifest, so reclamation
|
|
8343
|
+
# cannot mark evidence that is still being published. Released FIRST in the
|
|
8344
|
+
# finally, below the maintenance flock it sits under in the lock order.
|
|
8345
|
+
import _cctally_retention as _retention_sib
|
|
8346
|
+
retention = contextlib.ExitStack()
|
|
8333
8347
|
try:
|
|
8334
8348
|
fcntl.flock(lock_fh, fcntl.LOCK_EX)
|
|
8335
8349
|
if active_conn is not None:
|
|
@@ -8353,6 +8367,9 @@ def _recover_corrupt_cache(
|
|
|
8353
8367
|
# this snapshot earlier and restoring it after the probe would overwrite
|
|
8354
8368
|
# that writer's newer WAL index.
|
|
8355
8369
|
shm_snapshot = _capture_cache_shm_snapshot(path)
|
|
8370
|
+
retention.enter_context(
|
|
8371
|
+
_retention_sib.retention_shared(label="cache recovery")
|
|
8372
|
+
)
|
|
8356
8373
|
try:
|
|
8357
8374
|
forensics = _cctally_db_sib.write_corruption_forensics(
|
|
8358
8375
|
path,
|
|
@@ -8397,7 +8414,17 @@ def _recover_corrupt_cache(
|
|
|
8397
8414
|
)
|
|
8398
8415
|
return False
|
|
8399
8416
|
try:
|
|
8400
|
-
incident = _cctally_db_sib.quarantine_db_family(
|
|
8417
|
+
incident = _cctally_db_sib.quarantine_db_family(
|
|
8418
|
+
path,
|
|
8419
|
+
strict=True,
|
|
8420
|
+
# #496 S6 §4.2: this is the process that observed the
|
|
8421
|
+
# corruption, so it is the only one that can describe it.
|
|
8422
|
+
context=_cctally_db_sib.quarantine_context(
|
|
8423
|
+
trigger=origin,
|
|
8424
|
+
trigger_error=exc,
|
|
8425
|
+
forensics_path=forensics.path,
|
|
8426
|
+
),
|
|
8427
|
+
)
|
|
8401
8428
|
except OSError as quarantine_exc:
|
|
8402
8429
|
raise sqlite3.DatabaseError(
|
|
8403
8430
|
"cache.db recovery could not complete whole-family quarantine: "
|
|
@@ -8410,6 +8437,7 @@ def _recover_corrupt_cache(
|
|
|
8410
8437
|
)
|
|
8411
8438
|
return True
|
|
8412
8439
|
finally:
|
|
8440
|
+
retention.close()
|
|
8413
8441
|
if active_conn is not None:
|
|
8414
8442
|
_close_cache_trigger_connection_best_effort(active_conn)
|
|
8415
8443
|
try:
|
|
@@ -8791,9 +8819,15 @@ def _conversations_open_guarded(
|
|
|
8791
8819
|
str(pid) for pid in sorted(open_pids)
|
|
8792
8820
|
)
|
|
8793
8821
|
)
|
|
8794
|
-
|
|
8795
|
-
|
|
8796
|
-
|
|
8822
|
+
# #496 S6 §5.3: a resume publishes the incident's final
|
|
8823
|
+
# manifest, so it is a producer too.
|
|
8824
|
+
import _cctally_retention as _retention_sib
|
|
8825
|
+
with _retention_sib.retention_shared(
|
|
8826
|
+
label="conversations quarantine resume"
|
|
8827
|
+
):
|
|
8828
|
+
_cctally_db_sib.quarantine_db_family(
|
|
8829
|
+
path, strict=True,
|
|
8830
|
+
)
|
|
8797
8831
|
removed, reclaim_reason = (
|
|
8798
8832
|
_cctally_db_sib._remove_stale_repair_marker(path)
|
|
8799
8833
|
)
|
|
@@ -9291,6 +9325,9 @@ def _recover_corrupt_conversations(
|
|
|
9291
9325
|
|
|
9292
9326
|
lock_fh = None
|
|
9293
9327
|
provider_locks: list[Any] | None = None
|
|
9328
|
+
# #496 S6 §5.3, same span as the cache producer above.
|
|
9329
|
+
import _cctally_retention as _retention_sib
|
|
9330
|
+
retention = contextlib.ExitStack()
|
|
9294
9331
|
try:
|
|
9295
9332
|
lock_path = pathlib.Path(
|
|
9296
9333
|
_cctally_core.CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
@@ -9323,6 +9360,9 @@ def _recover_corrupt_conversations(
|
|
|
9323
9360
|
+ "; leaving the live family untouched"
|
|
9324
9361
|
) from exc
|
|
9325
9362
|
|
|
9363
|
+
retention.enter_context(
|
|
9364
|
+
_retention_sib.retention_shared(label="conversations recovery")
|
|
9365
|
+
)
|
|
9326
9366
|
try:
|
|
9327
9367
|
with _conversation_probe_snapshot(path) as snapshot:
|
|
9328
9368
|
forensics = _cctally_db_sib.write_corruption_forensics(
|
|
@@ -9370,7 +9410,15 @@ def _recover_corrupt_conversations(
|
|
|
9370
9410
|
_conversation_recovery_test_pause("confirmed")
|
|
9371
9411
|
try:
|
|
9372
9412
|
incident = _cctally_db_sib.quarantine_db_family(
|
|
9373
|
-
path,
|
|
9413
|
+
path,
|
|
9414
|
+
strict=True,
|
|
9415
|
+
# #496 S6 §4.2: this is the process that observed the
|
|
9416
|
+
# corruption, so it is the only one that can describe it.
|
|
9417
|
+
context=_cctally_db_sib.quarantine_context(
|
|
9418
|
+
trigger=origin,
|
|
9419
|
+
trigger_error=exc,
|
|
9420
|
+
forensics_path=forensics.path,
|
|
9421
|
+
),
|
|
9374
9422
|
)
|
|
9375
9423
|
except OSError as quarantine_exc:
|
|
9376
9424
|
raise sqlite3.DatabaseError(
|
|
@@ -9391,6 +9439,7 @@ def _recover_corrupt_conversations(
|
|
|
9391
9439
|
)
|
|
9392
9440
|
return True
|
|
9393
9441
|
finally:
|
|
9442
|
+
retention.close()
|
|
9394
9443
|
if provider_locks is not None:
|
|
9395
9444
|
_release_conversation_provider_locks(provider_locks)
|
|
9396
9445
|
if lock_fh is not None:
|
package/bin/_cctally_codex.py
CHANGED
|
@@ -40,6 +40,7 @@ from dataclasses import dataclass, field
|
|
|
40
40
|
from datetime import timezone
|
|
41
41
|
|
|
42
42
|
from _cctally_core import WEEKDAY_MAP, _command_as_of, eprint, get_week_start_name
|
|
43
|
+
from _lib_display_tz import _resolve_tz, resolve_display_tz_name
|
|
43
44
|
|
|
44
45
|
|
|
45
46
|
UTC = timezone.utc
|
|
@@ -288,10 +289,39 @@ def _build_codex_share_snapshot(command: str, view, rows):
|
|
|
288
289
|
c = _cctally()
|
|
289
290
|
lib = c._share_load_lib()
|
|
290
291
|
end = getattr(view, "period_end", None) or _command_as_of()
|
|
291
|
-
|
|
292
|
+
view_start = getattr(view, "period_start", None)
|
|
293
|
+
start = view_start or end
|
|
292
294
|
if end < start:
|
|
293
295
|
start = end
|
|
294
|
-
|
|
296
|
+
# RESOLVED, not passed through (#503 S2 D7). The view's label comes
|
|
297
|
+
# from `_lib_view_models._codex_tz_label`, which returns whatever
|
|
298
|
+
# `--timezone` was given verbatim and otherwise falls back to
|
|
299
|
+
# `_local_tz_name()` — so it can carry a bare `UTC`, which is not a
|
|
300
|
+
# loadable ZoneInfo key on a case-sensitive filesystem, or a non-IANA
|
|
301
|
+
# abbreviation such as `IST`. Either would leave one artifact reading
|
|
302
|
+
# `(UTC)` beside a sibling reading `(Etc/UTC)`. Resolving here makes
|
|
303
|
+
# the label a concrete IANA zone whatever the view carried.
|
|
304
|
+
display_tz = resolve_display_tz_name(
|
|
305
|
+
getattr(view, "display_tz_label", None) or None)
|
|
306
|
+
# `period_start` on a bucketed Codex view is a CALENDAR LABEL — the
|
|
307
|
+
# oldest visible `YYYY-MM-DD` / `YYYY-MM` bucket, which
|
|
308
|
+
# `_lib_view_models` lifts with `datetime.combine(..., tzinfo=UTC)`.
|
|
309
|
+
# `period_end` is a real instant (`now_utc`), so the two boundaries
|
|
310
|
+
# are of different kinds and the per-period `civil_bucket` flag
|
|
311
|
+
# cannot express them. Ground the label at midnight in the zone the
|
|
312
|
+
# artifact names instead, which makes `period_civil_dates` recover
|
|
313
|
+
# the same bucket in every zone while the end still converts
|
|
314
|
+
# (#503 S2 D7). The VIEW declares which it carries through
|
|
315
|
+
# `period_civil_bucket`, because the value alone cannot say: an empty
|
|
316
|
+
# view's `period_start` is a real instant that happens to sit at
|
|
317
|
+
# whatever time "now" is, and `codex-session`'s is the earliest
|
|
318
|
+
# `last_activity`. A view without the field (the dashboard's adapter
|
|
319
|
+
# namespace, whose bounds are already grounded) is left alone.
|
|
320
|
+
if getattr(view, "period_civil_bucket", False) and view_start is not None:
|
|
321
|
+
start = c._share_ground_civil_date(
|
|
322
|
+
view_start.date(), _resolve_tz(display_tz, fallback=None))
|
|
323
|
+
if end < start:
|
|
324
|
+
start = end
|
|
295
325
|
period_label = f"{start.date().isoformat()} → {end.date().isoformat()} ({display_tz})"
|
|
296
326
|
titles = {
|
|
297
327
|
"codex-daily": "Codex Token Usage — Daily",
|
|
@@ -339,7 +369,9 @@ def _build_codex_share_snapshot(command: str, view, rows):
|
|
|
339
369
|
return lib.ShareSnapshot(
|
|
340
370
|
cmd=command,
|
|
341
371
|
title=titles[command],
|
|
342
|
-
|
|
372
|
+
# #503 S2 D5 — the facts strip states this period, so a
|
|
373
|
+
# subtitle repeating it is duplicate chrome.
|
|
374
|
+
subtitle=None,
|
|
343
375
|
period=lib.PeriodSpec(start=start, end=end, display_tz=display_tz, label=period_label),
|
|
344
376
|
columns=columns,
|
|
345
377
|
rows=table_rows,
|
package/bin/_cctally_config.py
CHANGED
|
@@ -332,6 +332,7 @@ ALLOWED_CONFIG_KEYS = (
|
|
|
332
332
|
"budget.codex.accounts",
|
|
333
333
|
"telemetry.enabled",
|
|
334
334
|
"conversation.retention_days",
|
|
335
|
+
"storage.artifact_retention",
|
|
335
336
|
"codex.hook.ingest_budget_seconds",
|
|
336
337
|
)
|
|
337
338
|
|
|
@@ -389,6 +390,61 @@ def _validate_retention_days_value(raw: object) -> int:
|
|
|
389
390
|
return value
|
|
390
391
|
|
|
391
392
|
|
|
393
|
+
def _validate_artifact_retention_value(raw: object) -> dict:
|
|
394
|
+
"""Validate a ``config set`` value for ``storage.artifact_retention`` (#496 S6).
|
|
395
|
+
|
|
396
|
+
The argument is the raw CLI string (or an already-parsed object). JSON is
|
|
397
|
+
parsed here; every semantic rule is delegated to the kernel's
|
|
398
|
+
``resolve_retention_policy`` so ``config set`` and the retention worker
|
|
399
|
+
share ONE predicate rather than drifting apart. Raises ``ValueError``,
|
|
400
|
+
which the caller maps to exit 2.
|
|
401
|
+
"""
|
|
402
|
+
import _lib_artifact_retention as _ar
|
|
403
|
+
|
|
404
|
+
if isinstance(raw, str):
|
|
405
|
+
text = raw.strip()
|
|
406
|
+
if text.lower() in {"null", "none", ""}:
|
|
407
|
+
raise ValueError(
|
|
408
|
+
"storage.artifact_retention must be a JSON object "
|
|
409
|
+
"(use `config unset storage.artifact_retention` to restore "
|
|
410
|
+
"the defaults)"
|
|
411
|
+
)
|
|
412
|
+
try:
|
|
413
|
+
parsed: object = json.loads(text)
|
|
414
|
+
except (json.JSONDecodeError, ValueError):
|
|
415
|
+
raise ValueError(
|
|
416
|
+
"storage.artifact_retention must be a JSON object, got "
|
|
417
|
+
f"{raw!r}"
|
|
418
|
+
)
|
|
419
|
+
else:
|
|
420
|
+
parsed = raw
|
|
421
|
+
if not isinstance(parsed, dict):
|
|
422
|
+
raise ValueError("storage.artifact_retention must be a JSON object")
|
|
423
|
+
resolution = _ar.resolve_retention_policy(parsed)
|
|
424
|
+
if resolution.status == "malformed":
|
|
425
|
+
raise ValueError(resolution.reason)
|
|
426
|
+
return parsed
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def resolve_artifact_retention_block(config: object) -> dict:
|
|
430
|
+
"""The effective ``storage.artifact_retention`` block, in config units.
|
|
431
|
+
|
|
432
|
+
Read-only convenience for ``config get``: a malformed persisted block
|
|
433
|
+
surfaces the defaults here (mirroring every other ``_config_known_value``
|
|
434
|
+
branch) because a plain read must not error out. The DELETION path never
|
|
435
|
+
uses this — it calls ``resolve_retention_policy`` on a raw file read and
|
|
436
|
+
refuses to act on ``malformed`` (spec §6.5, C14).
|
|
437
|
+
"""
|
|
438
|
+
import _lib_artifact_retention as _ar
|
|
439
|
+
|
|
440
|
+
storage = config.get("storage") if isinstance(config, dict) else None
|
|
441
|
+
raw = storage.get("artifact_retention") if isinstance(storage, dict) else None
|
|
442
|
+
resolution = _ar.resolve_retention_policy(raw)
|
|
443
|
+
if resolution.policy is None:
|
|
444
|
+
return _ar.default_policy_block()
|
|
445
|
+
return _ar.policy_to_block(resolution.policy)
|
|
446
|
+
|
|
447
|
+
|
|
392
448
|
def _validate_codex_hook_ingest_budget_value(raw: object) -> float:
|
|
393
449
|
"""Validate a ``config set`` value for ``codex.hook.ingest_budget_seconds``.
|
|
394
450
|
|
|
@@ -1068,6 +1124,11 @@ def _config_known_value(config: dict, key: str) -> "object":
|
|
|
1068
1124
|
# Effective transcript-retention window in days (default 180; 0 = keep
|
|
1069
1125
|
# forever). Malformed persisted data surfaces the safe default (F8).
|
|
1070
1126
|
return resolve_retention_days(config)
|
|
1127
|
+
if key == "storage.artifact_retention":
|
|
1128
|
+
# Effective retained-evidence policy in config units (#496 S6 §6.5).
|
|
1129
|
+
# Malformed persisted data surfaces the defaults on this READ path
|
|
1130
|
+
# only; the deletion path refuses to act on it instead.
|
|
1131
|
+
return resolve_artifact_retention_block(config)
|
|
1071
1132
|
if key == "codex.hook.ingest_budget_seconds":
|
|
1072
1133
|
# Wall-clock budget for the native Codex hook's ingest leg (public #5).
|
|
1073
1134
|
# Malformed or out-of-range persisted data surfaces the default.
|
|
@@ -1172,7 +1233,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
1172
1233
|
def _coerce(k: str, v: "object") -> "object":
|
|
1173
1234
|
if k in (
|
|
1174
1235
|
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
1175
|
-
"budget.accounts", "budget.codex",
|
|
1236
|
+
"budget.accounts", "budget.codex", "storage.artifact_retention",
|
|
1176
1237
|
) or k.startswith(_CODEX_BUDGET_LEAF_PREFIX):
|
|
1177
1238
|
return v
|
|
1178
1239
|
return v if v is not None else ""
|
|
@@ -1216,7 +1277,7 @@ def _cmd_config_get(args: argparse.Namespace, config: dict) -> int:
|
|
|
1216
1277
|
# round-trips via `config set alerts.enabled <plain-text>` work.
|
|
1217
1278
|
if k in (
|
|
1218
1279
|
"alerts.command_template", "alerts.quota", "budget.projects",
|
|
1219
|
-
"budget.accounts", "budget.codex",
|
|
1280
|
+
"budget.accounts", "budget.codex", "storage.artifact_retention",
|
|
1220
1281
|
):
|
|
1221
1282
|
# JSON-encoded so `config get` output round-trips through the
|
|
1222
1283
|
# matching `config set` branch (both JSON-parse their value).
|
|
@@ -1803,6 +1864,37 @@ def _cmd_config_set(args: argparse.Namespace) -> int:
|
|
|
1803
1864
|
else:
|
|
1804
1865
|
print(f"{key}={normalized}")
|
|
1805
1866
|
return 0
|
|
1867
|
+
if key == "storage.artifact_retention":
|
|
1868
|
+
# Validate first; rejection short-circuits before lock acquisition.
|
|
1869
|
+
# The whole block is stored verbatim (partial blocks inherit the
|
|
1870
|
+
# per-field defaults at resolve time), so an operator can write one
|
|
1871
|
+
# field without restating the rest.
|
|
1872
|
+
try:
|
|
1873
|
+
block_value = _validate_artifact_retention_value(raw)
|
|
1874
|
+
except ValueError as exc:
|
|
1875
|
+
print(f"cctally: {exc}", file=sys.stderr)
|
|
1876
|
+
return 2
|
|
1877
|
+
with config_writer_lock():
|
|
1878
|
+
config = _load_config_unlocked()
|
|
1879
|
+
existing_storage = config.get("storage")
|
|
1880
|
+
if existing_storage is not None and not isinstance(
|
|
1881
|
+
existing_storage, dict
|
|
1882
|
+
):
|
|
1883
|
+
print(
|
|
1884
|
+
"cctally: storage config error: storage must be an object",
|
|
1885
|
+
file=sys.stderr,
|
|
1886
|
+
)
|
|
1887
|
+
return 2
|
|
1888
|
+
storage_block = dict(existing_storage or {})
|
|
1889
|
+
storage_block["artifact_retention"] = block_value
|
|
1890
|
+
config["storage"] = storage_block
|
|
1891
|
+
save_config(config)
|
|
1892
|
+
if getattr(args, "emit_json", False):
|
|
1893
|
+
print(json.dumps(
|
|
1894
|
+
{"storage": {"artifact_retention": block_value}}, indent=2))
|
|
1895
|
+
else:
|
|
1896
|
+
print(f"{key}={json.dumps(block_value, sort_keys=True)}")
|
|
1897
|
+
return 0
|
|
1806
1898
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
1807
1899
|
# Validate first; rejection short-circuits before lock acquisition.
|
|
1808
1900
|
if key == "update.check.enabled":
|
|
@@ -2354,6 +2446,20 @@ def _cmd_config_unset(args: argparse.Namespace) -> int:
|
|
|
2354
2446
|
save_config(config)
|
|
2355
2447
|
# idempotent: silent on missing key
|
|
2356
2448
|
return 0
|
|
2449
|
+
if key == "storage.artifact_retention":
|
|
2450
|
+
# Mirror the conversation.retention_days branch: drop the leaf; if the
|
|
2451
|
+
# `storage` block ends up empty, drop it too. The next resolve returns
|
|
2452
|
+
# the whole DEFAULT_POLICY, not a partially-defaulted one.
|
|
2453
|
+
with config_writer_lock():
|
|
2454
|
+
config = _load_config_unlocked()
|
|
2455
|
+
block = config.get("storage")
|
|
2456
|
+
if isinstance(block, dict) and "artifact_retention" in block:
|
|
2457
|
+
del block["artifact_retention"]
|
|
2458
|
+
if not block:
|
|
2459
|
+
config.pop("storage", None)
|
|
2460
|
+
save_config(config)
|
|
2461
|
+
# idempotent: silent on missing key
|
|
2462
|
+
return 0
|
|
2357
2463
|
if key in ("update.check.enabled", "update.check.ttl_hours"):
|
|
2358
2464
|
# Mirror the dashboard.bind branch: drop the leaf, then prune
|
|
2359
2465
|
# empty `check` and empty `update` so config.json stays tidy.
|
package/bin/_cctally_core.py
CHANGED
|
@@ -67,6 +67,7 @@ def _init_paths_from_env() -> None:
|
|
|
67
67
|
global CONVERSATIONS_LOCK_MAINTENANCE_PATH
|
|
68
68
|
global STATS_LOCK_MAINTENANCE_PATH
|
|
69
69
|
global JOURNAL_DIR, JOURNAL_LOCK_PATH, JOURNAL_INGEST_LOCK_PATH
|
|
70
|
+
global ARTIFACT_RETENTION_LOCK_PATH
|
|
70
71
|
global CONFIG_LOCK_PATH
|
|
71
72
|
global CONFIG_PATH, MIGRATION_ERROR_LOG_PATH, CHANGELOG_PATH
|
|
72
73
|
global HOOK_TICK_LOG_DIR, HOOK_TICK_LOG_PATH, HOOK_TICK_LOG_ROTATED_PATH
|
|
@@ -142,6 +143,14 @@ def _init_paths_from_env() -> None:
|
|
|
142
143
|
JOURNAL_DIR = APP_DIR / "journal"
|
|
143
144
|
JOURNAL_LOCK_PATH = APP_DIR / "journal.lock"
|
|
144
145
|
JOURNAL_INGEST_LOCK_PATH = APP_DIR / "journal.ingest.lock"
|
|
146
|
+
# Retained-evidence reclamation flock (#496 S6, spec §5.3). It enters the
|
|
147
|
+
# lock-order law AFTER the conversation provider flocks and BEFORE SQLite
|
|
148
|
+
# transactions, which keeps `journal.lock` the leaf. Every producer of
|
|
149
|
+
# retained evidence — corruption forensics, quarantine manifests, rebuild
|
|
150
|
+
# records, the backups `db repair` writes — holds it SHARED across the span
|
|
151
|
+
# in which its evidence is being published; the reclamation worker takes it
|
|
152
|
+
# EXCLUSIVE holding nothing earlier, marks, releases, and only then deletes.
|
|
153
|
+
ARTIFACT_RETENTION_LOCK_PATH = APP_DIR / "artifact-retention.lock"
|
|
145
154
|
CONFIG_LOCK_PATH = APP_DIR / "config.json.lock"
|
|
146
155
|
|
|
147
156
|
CONFIG_PATH = APP_DIR / "config.json"
|
|
@@ -390,7 +399,7 @@ LEGACY_STATS_HEAD = 13
|
|
|
390
399
|
#: #496 S1 F1. A NEW branch, for a state that cannot occur before the
|
|
391
400
|
#: publication transaction exists: a replacement index was published and then
|
|
392
401
|
#: failed validation on a fresh connection. The existing corrupt-stats text
|
|
393
|
-
#: says the database
|
|
402
|
+
#: says the database "is never auto-recreated", which would be false here, so
|
|
394
403
|
#: this path gets its own wording. It does not alter the heal message or any
|
|
395
404
|
#: other corruption path.
|
|
396
405
|
STATS_PUBLICATION_FAILED_MSG = (
|
|
@@ -729,6 +738,14 @@ def ensure_dirs() -> None:
|
|
|
729
738
|
os.chmod(APP_DIR, 0o700)
|
|
730
739
|
except OSError as exc:
|
|
731
740
|
eprint(f"[core] could not chmod data dir 0700 ({exc}); continuing")
|
|
741
|
+
# #496 S6 §9.4: LOG_DIR was created at the umask and was drwxr-xr-x, the
|
|
742
|
+
# same class of defect as the 0644 stats family. It holds corruption
|
|
743
|
+
# forensics bundles and rebuild records, which name paths and carry damage
|
|
744
|
+
# detail, so it gets the data directory's mode rather than the umask's.
|
|
745
|
+
try:
|
|
746
|
+
os.chmod(LOG_DIR, 0o700)
|
|
747
|
+
except OSError as exc:
|
|
748
|
+
eprint(f"[core] could not chmod log dir 0700 ({exc}); continuing")
|
|
732
749
|
|
|
733
750
|
|
|
734
751
|
# === stats.db maintenance-hold tracking (#386) ======================
|
|
@@ -1652,9 +1669,13 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1652
1669
|
# marker-only behaviour; see `_cctally_store.stats_open_guarded`.
|
|
1653
1670
|
conn = _cctally_store.stats_open_guarded(db_path)
|
|
1654
1671
|
conn.row_factory = sqlite3.Row
|
|
1655
|
-
# #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db
|
|
1656
|
-
#
|
|
1657
|
-
#
|
|
1672
|
+
# #279 S1 F4: probe connect + initial PRAGMAs so a corrupt stats.db
|
|
1673
|
+
# surfaces as a one-line diagnosis + staged exit 3 instead of a raw
|
|
1674
|
+
# traceback. With retained journal data stats.db is a disposable index and
|
|
1675
|
+
# the heal hook below rebuilds it; the guided error is for the pre-cutover
|
|
1676
|
+
# install with no retained journal data, whose stats.db may be the only
|
|
1677
|
+
# copy of its recorded history and is therefore never auto-recreated.
|
|
1678
|
+
# The catch boundary is DELIBERATELY narrow — ONLY the
|
|
1658
1679
|
# connect/PRAGMA/probe below. The DDL + `_run_pending_migrations` region
|
|
1659
1680
|
# further down is NOT wrapped: migration-handler failures have their own
|
|
1660
1681
|
# logging/suppression contract and must not be mislabeled as corruption
|
|
@@ -1671,6 +1692,9 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1671
1692
|
# StatsDbCorruptError boundary.
|
|
1672
1693
|
_cctally_store.apply_policy(conn, "stats")
|
|
1673
1694
|
conn.execute("SELECT 1").fetchone()
|
|
1695
|
+
# §9.2 (#496 S6 F23). AFTER `apply_policy`, because that is what sets
|
|
1696
|
+
# journal_mode=WAL and so what can materialize the sidecars.
|
|
1697
|
+
_cctally_store._harden_stats_family(db_path)
|
|
1674
1698
|
except sqlite3.DatabaseError as exc:
|
|
1675
1699
|
try:
|
|
1676
1700
|
conn.close()
|
|
@@ -1699,6 +1723,7 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1699
1723
|
try:
|
|
1700
1724
|
_cctally_store.apply_policy(conn, "stats")
|
|
1701
1725
|
conn.execute("SELECT 1").fetchone()
|
|
1726
|
+
_cctally_store._harden_stats_family(db_path)
|
|
1702
1727
|
except sqlite3.DatabaseError as exc2:
|
|
1703
1728
|
try:
|
|
1704
1729
|
conn.close()
|
|
@@ -1713,10 +1738,14 @@ def open_db(*, _target_path=None) -> sqlite3.Connection:
|
|
|
1713
1738
|
else:
|
|
1714
1739
|
raise c.StatsDbCorruptError(
|
|
1715
1740
|
f"stats.db appears corrupt or unreadable ({exc}). path: {db_path}. "
|
|
1716
|
-
|
|
1717
|
-
"
|
|
1718
|
-
"
|
|
1719
|
-
"
|
|
1741
|
+
"When journal data is retained for this install, stats.db is a "
|
|
1742
|
+
"disposable index and `cctally db rebuild --db stats` rebuilds "
|
|
1743
|
+
"it from the journal, losing nothing. On a pre-cutover install "
|
|
1744
|
+
"with no retained journal data it may be the only copy of your "
|
|
1745
|
+
"recorded history, so it is never auto-recreated: run `cctally "
|
|
1746
|
+
"db repair --db stats --yes`, which preserves the corrupt "
|
|
1747
|
+
"original before replacing anything. Do not copy, restore, or "
|
|
1748
|
+
"move the live DB by hand."
|
|
1720
1749
|
) from exc
|
|
1721
1750
|
|
|
1722
1751
|
# ── stats.db epoch gate / in-place cutover (DB journal redesign §7.1/§8) ──
|
|
@@ -1238,6 +1238,7 @@ from _cctally_dashboard_share import (
|
|
|
1238
1238
|
_handle_share_compose_post_impl,
|
|
1239
1239
|
_handle_share_presets_get_impl,
|
|
1240
1240
|
_handle_share_presets_post_impl,
|
|
1241
|
+
_handle_share_presets_rename_post_impl,
|
|
1241
1242
|
_handle_share_presets_delete_impl,
|
|
1242
1243
|
_handle_share_history_get_impl,
|
|
1243
1244
|
_handle_share_history_post_impl,
|
|
@@ -4637,6 +4638,8 @@ _POST_ROUTES = (
|
|
|
4637
4638
|
("exact", "/api/share/render", "_handle_share_render_post", None, False),
|
|
4638
4639
|
("exact", "/api/share/compose", "_handle_share_compose_post", None, False),
|
|
4639
4640
|
("exact", "/api/share/presets", "_handle_share_presets_post", None, False),
|
|
4641
|
+
("exact", "/api/share/presets/rename", "_handle_share_presets_rename_post",
|
|
4642
|
+
None, False),
|
|
4640
4643
|
("exact", "/api/share/history", "_handle_share_history_post", None, False),
|
|
4641
4644
|
)
|
|
4642
4645
|
|
|
@@ -6087,6 +6090,9 @@ class DashboardHTTPHandler(BaseHTTPRequestHandler):
|
|
|
6087
6090
|
def _handle_share_presets_post(self) -> None:
|
|
6088
6091
|
return _handle_share_presets_post_impl(self)
|
|
6089
6092
|
|
|
6093
|
+
def _handle_share_presets_rename_post(self) -> None:
|
|
6094
|
+
return _handle_share_presets_rename_post_impl(self)
|
|
6095
|
+
|
|
6090
6096
|
def _handle_share_presets_delete(self) -> None:
|
|
6091
6097
|
return _handle_share_presets_delete_impl(self)
|
|
6092
6098
|
|