tracking: one ledger, one authority — prune protection and replace-vs-add answered from the same records, fail-safe on unreadable state

This commit is contained in:
2026-07-30 19:44:11 -04:00
parent 7bd911d58b
commit 7f70d94228
40 changed files with 1546 additions and 633 deletions
+3 -2
View File
@@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
**ReaSampler** is a per-project audio sample-bank capture tool that builds two artifacts: the REAPER extension (`reaper_reasampler`) and **ReaSampler 9000**, a Windows-only VST3 sampler instrument (`reasampler_9000.vst3`, `core/instrument/` + `shell/instrument/`, second CMake target `reasampler_vst`, gated on the vendored `vendor/vst3sdk` submodule slice). The pure-testable-core / REAPER-facing-shell discipline is preserved throughout: `core/` never includes REAPER or VST3 SDK types, `shell/` is where those hosts are actually touched, `app/` is the extension entry point. Every REAPER API name cited in project docs is correct-by-intent; verify argument order, types, and flag values against `vendor/reaper-sdk/sdk/reaper_plugin_functions.h` before use.
Per-module detail — what each file owns, its invariants — lives in the twenty per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
Per-module detail — what each file owns, its invariants — lives in the twenty-one per-directory `src/**/CLAUDE.md` files; see the compact map in "Architecture: the load-bearing split" below to find the right one. Landed-phase history lives in `docs/ARCHIVE.md`; current work lives in `docs/COMPLETED.md`, `docs/TODO.md`, and `docs/TODO-1.0.md` — see "Project docs" below.
## Settled decisions
@@ -73,7 +73,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
## Architecture: the load-bearing split
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
`core/` holds pure, unit-testable logic — no REAPER or VST3 SDK types, each with a corresponding `<module>_tests` target that runs without a DAW. `shell/` holds the REAPER/host-facing shells — where those SDK types are actually touched. `app/` is the extension entry point. Each of the twenty-one directories below carries its own `CLAUDE.md` with the full module list and that area's invariants — open the relevant one for detail; this file states only repo-wide truth.
| Directory | Scope |
|---|---|
@@ -85,6 +85,7 @@ There is no hot-reload. Copy the built binary into REAPER's `UserPlugins/` folde
| `src/core/json/` | the hand-rolled JSON lexical layer |
| `src/core/model/` | the pure bank/sample index and its multi-bank container |
| `src/core/reclaim/` | pure prune orphan computation |
| `src/core/tracking/` | the consolidated file-tracking system — birth/lineage records and the one authority answering prune's protected set and the resample's replace-vs-add |
| `src/core/ui/` | pure UI geometry, palette, and interaction-decision modules |
| `src/core/util/` | small shared pure utilities |
| `src/core/version/` | version/channel identity |
+3 -1
View File
@@ -46,7 +46,9 @@ Forward-looking follow-ups. Deferred by decision, not oversight — each entry r
**Priority / risk.** Low / deferred. Current behavior is safe; the only cost is unbounded bank-folder growth after reopens. Decided 2026-07-28 to ship the safe version and defer this.
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds.
**Re-examined 2026-07-30 by the tracking consolidation, and DELIBERATELY NOT absorbed.** The consolidation's mandate is a *safety* claim (no destructive act follows from ambiguity); this wart is a *completeness* one (nothing is lost, the folder grows). They do not conflict, and folding a fix in would have widened a safety-critical review surface with a mechanism that can under-protect. The strongest candidate examined was a **session epoch**: the extension mints a fresh epoch value at each project load and an instance stamps it into its record, so a record carrying a previous epoch is known-stale and may be clean-replaced regardless of nonce. It fixes exactly the reopen case — but a divergent same-key clone pair reopening together gives the first publisher a clean replace that drops the second's holds until the second republishes, i.e. a narrow revival of the sibling-drop bug. Any future attempt must close that window (e.g. by making the epoch rollover a union that clears the sticky poison only once both siblings have republished) before it is worth taking.
**Done looks like.** Save → reopen → de-reference a capture from an instance → prune reclaims it. And: in-place-duplicate + diverge + delete-from-bank never deletes a capture a live instance holds, with no window between the two publishes in which a hold is unprotected.
## Isolate capture from out-of-scope aux/parallel sends, not just FX/gain/pan
+1 -1
View File
@@ -44,7 +44,7 @@ add_library(reaper_reasampler MODULE
${REASAMPLER_SRC_DIR}/shell/actions/instrument_drop_win.cpp
${REASAMPLER_SRC_DIR}/shell/persist/usage_scan.cpp
)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec owned_manifest prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
target_link_libraries(reaper_reasampler PRIVATE json wire file_bytes bank_model capture_paths peaks bank_grid mode_switch tab_strip view_mode_model view_tree guid_diff lane_keys insert_plan render_settings batch_capture tail_control capture_realtime bank_book wav_codec origin_ledger tracking_authority prune_reconcile prune_button app_version provenance drag_out instrument_drop theme component_geometry action_bar footer_bar overflow_menu mode_enable tooltip card_meta card_drag assignment_request bank_sync sample_usage)
target_include_directories(reaper_reasampler PRIVATE ${SDK_INC} ${WDL_INC})
# OUTPUT_NAME is channel-derived; the CMake target name stays "reaper_reasampler" for both
+1
View File
@@ -10,6 +10,7 @@ add_subdirectory(wire)
add_subdirectory(audio)
add_subdirectory(model)
add_subdirectory(capture)
add_subdirectory(tracking)
add_subdirectory(reclaim)
add_subdirectory(version)
add_subdirectory(view)
+4 -5
View File
@@ -4,9 +4,9 @@
Pure (REAPER-free, unit-tested outside the DAW) sample-index models: the single-bank
index, the multi-bank registry that wraps it, its JSON codec, the gap-preserving
per-bank slot carrier, the owned-file manifest, and the capture-recipe fingerprint.
No REAPER types, no filesystem I/O — see root `CLAUDE.md` for the pure-core/shell
split this directory sits on.
per-bank slot carrier, and the capture-recipe fingerprint. No REAPER types, no
filesystem I/O — see root `CLAUDE.md` for the pure-core/shell split this directory
sits on.
## Invariants
@@ -69,8 +69,7 @@ split this directory sits on.
- `bank_model``Sample` metadata struct + `BankIndex` (add/remove/query/tier/dedup-by-hash + JSON round-trip). Test it hard — it is the heart.
- `bank_book` — multi-bank registry: an ordered set of banks each wrapping a `BankIndex`. **Pool privileges (un-deletable/un-renamable/un-evacuable, never zero banks) enforced in-model.** Owns create/rename/reorder/delete of named banks, active-bank id, and index-only move/copy/remove of a sample between banks. The JSON round-trip lives in the sibling `bank_book_json` TU (Q-W5 split; serialize/deserialize via a private static `nameKey` seam) — one model, one codec, same public surface.
- `slot_map` (`core/model`) — the gap-preserving display-position carrier for ONE bank (sample id → slot, ≥0), extracted from `bank_book` (Q-W1): append/remove/reorder (insert-before-and-shift)/`reconcile` against live membership, `resetDense` migration seed, JSON round-trip. Wrapped (not merged) by `bank_book`.
- `owned_manifest` — the set of project-relative files the capture path itself created, persisted under the `"owned_files"` ext-state key, so the prune path can distinguish the bank system's own orphans from hand-dropped files.
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.**
- `provenance` — capture-recipe fingerprint: build/encode/compare a `rsprov1` fingerprint of scope, range, tail, rate/channels, track GUIDs, and FX-chain identity. **A thin reproducibility fingerprint — NOT a serialized chain to restore.** It is the recipe facet of the tracking system whose authority lives in `core/tracking`; the lineage facet (which file derives from which) is the ledger's, not the `Sample`'s.
## Gotchas
-3
View File
@@ -9,9 +9,6 @@ reasampler_pure_library(bank_book
LINK PUBLIC bank_model slot_map PRIVATE json)
reasampler_test(bank_book LINK bank_book)
reasampler_pure_library(owned_manifest SOURCES owned_manifest.cpp LINK PRIVATE json)
reasampler_test(owned_manifest LINK owned_manifest)
reasampler_pure_library(provenance SOURCES provenance.cpp LINK PRIVATE wire)
# bank_model: the test proves the recorded recipe survives the Sample-JSON round-trip.
reasampler_test(provenance LINK provenance bank_model)
-100
View File
@@ -1,100 +0,0 @@
#include "core/model/owned_manifest.h"
#include <cctype>
#include "core/json/json.h"
// owned_manifest implementation. JSON shape is a single object with one string
// array:
//
// {"owned":["reasampler_bank/a.wav","reasampler_bank/b.wav"]}
namespace reasampler::model {
// -- path invariant (mirror of bank_model's isAbsolutePath) ----------------
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo") is absolute — same rejection bank_model applies to
// Sample.relativePath; the manifest holds the same kind of path, so the invariant
// must match exactly.
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
} // namespace
// -- mutation / query -------------------------------------------------------
ManifestAddResult OwnedFileManifest::add(const std::string& relativePath) {
if (relativePath.empty()) return ManifestAddResult::RejectedEmptyPath;
if (isAbsolutePath(relativePath)) return ManifestAddResult::RejectedAbsolutePath;
if (contains(relativePath)) return ManifestAddResult::AlreadyPresent;
paths_.push_back(relativePath);
return ManifestAddResult::Added;
}
bool OwnedFileManifest::contains(const std::string& relativePath) const {
for (const auto& p : paths_)
if (p == relativePath) return true;
return false;
}
// -- JSON writer --------------------------------------------------------
std::string OwnedFileManifest::serialize() const {
std::string out = "{\"owned\":[";
for (std::size_t i = 0; i < paths_.size(); ++i) {
if (i) out += ',';
json::writeEscaped(out, paths_[i]);
}
out += "]}";
return out;
}
// JSON parser: string-array-only grammar. Tolerates unknown keys and requires
// the "owned" value to be an array of strings.
namespace {
bool parseManifest(json::Reader& r, OwnedFileManifest& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true; // empty object -> empty manifest
for (;;) {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "owned") {
std::vector<std::string> paths;
if (!r.parseStringArray(paths)) return false;
for (auto& p : paths) {
// Feed through add() so the persisted invariants (dedup, reject
// empty/absolute) are re-asserted on load — a hand-edited or corrupt
// blob cannot smuggle an absolute or duplicate path into the manifest.
out.add(p);
}
} else {
if (!r.skipValue()) return false; // forward-compat: tolerate unknown keys
}
r.skipWs();
if (r.consume(',')) continue;
if (r.consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OwnedFileManifest> OwnedFileManifest::deserialize(const std::string& blob) {
OwnedFileManifest m;
json::Reader r(blob);
if (!parseManifest(r, m)) return std::nullopt;
return m;
}
} // namespace reasampler::model
-76
View File
@@ -1,76 +0,0 @@
#pragma once
// owned_manifest — the set of files the bank system ITSELF created; every file the
// capture path writes gets recorded here so prune can tell the system's own orphans
// (owned ∩ present referenced) apart from hand-dropped files. Writes and persists
// the manifest only — no prune logic lives here.
//
// NOT a mirror of the bank index: removing/moving an index entry does NOT remove
// the file's manifest record (the manifest tracks files *created*; prune reconciles
// manifest-vs-index later). Only the capture add-path adds to it — no remove verb.
//
// Paths are ALWAYS project-relative (same invariant as Sample.relativePath). add()
// rejects an absolute path rather than guess a relativization — the pure model has
// no project root, so "normalizing" could point at the wrong file.
#include <optional>
#include <string>
#include <vector>
namespace reasampler::model {
// Outcome of an add(). Mirrors BankModel::AddResult's honesty — the op reports what
// happened rather than silently mutating on a bad request.
// - Added: the path was new and recorded.
// - AlreadyPresent: the path was already in the manifest (dedup no-op).
// - RejectedEmptyPath: the path was empty.
// - RejectedAbsolutePath: the path was absolute (relative-paths-only invariant).
enum class ManifestAddResult {
Added,
AlreadyPresent,
RejectedEmptyPath,
RejectedAbsolutePath,
};
// The owned-file manifest: an insertion-ordered, deduplicated set of project-relative
// paths the capture path has created. Insertion order is preserved so serialize()
// round-trips byte-identically (deterministic ext-state, mirror of the index).
class OwnedFileManifest {
public:
OwnedFileManifest() = default;
// Record a project-relative path as owned. Rejects an empty or absolute path (no
// mutation). A path already present is a dedup no-op (AlreadyPresent), so a repeat
// capture of an identical request does not double-record.
ManifestAddResult add(const std::string& relativePath);
// True iff the exact path string is recorded. Prune uses this to attribute a
// present file to the bank system. Exact string match — path normalization (if
// any) is the caller's concern, consistent across add and query.
bool contains(const std::string& relativePath) const;
// The owned paths in insertion order. Prune unions this with the on-disk file
// set; here it is the round-trip + query surface.
const std::vector<std::string>& paths() const { return paths_; }
std::size_t size() const { return paths_.size(); }
bool empty() const { return paths_.empty(); }
bool operator==(const OwnedFileManifest& o) const { return paths_ == o.paths_; }
// -- Persistence ---------------------------------------------------------
// Serialize to a JSON string (lossless round-trip): deserialize(serialize(x)) == x.
// An empty manifest serializes to a well-formed empty shape (round-trips to empty).
std::string serialize() const;
// Parse a manifest JSON produced by serialize(). std::nullopt on malformed input
// (the persist shell warns + falls back to an empty manifest, mirroring the bank /
// view malformed handling). An empty/absent stored value is the caller's concern
// (an empty string is not valid JSON) — the shell maps absence to a fresh manifest.
static std::optional<OwnedFileManifest> deserialize(const std::string& json);
private:
std::vector<std::string> paths_; // insertion order; deduplicated
};
} // namespace reasampler::model
+4 -4
View File
@@ -20,9 +20,9 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he
- Referenced-set is the union across ALL banks, pool included: a file is an orphan
iff no bank in the book references it. This is the safety-critical computation —
the prune null test is *prune never deletes a file that any index references.*
- Orphan attribution is an owned-file manifest (fork R-D): the book tracks the set
of files it has created; prune reclaims `(owned ∩ on-disk) referenced`. This
rejects folder-sweep (which would delete hand-dropped files).
- Orphan attribution comes from the tracking ledger (`core/tracking`): the book
tracks the files it has created; prune reclaims `(owned ∩ on-disk) referenced`.
This rejects folder-sweep (which would delete hand-dropped files).
- **Prune null test:** a prune of a folder whose every file is referenced by some
bank deletes nothing; a prune deletes exactly the `present referenced` orphan
set and nothing else.
@@ -32,7 +32,7 @@ enumeration and the actual deletion live in `shell/persist` (`prune_fs`), not he
## Modules
- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. Gains `mergeReferenced(bankRefs, liveInstanceHeldPaths)` (pS-usage) — unions live instance holds into the prune referenced-set so the pure orphan computation includes them.
- `prune_reconcile` — pure prune core: `pruneOrphans(present, referenced, owned)` computes `(owned ∩ present) referenced`; the safety-critical "which files are orphans" decision, filesystem-free and hard-tested before any I/O exists. `mergeReferenced(bankRefs, heldPaths)` unions live instance holds into the referenced-set. Two of the three inputs (`owned`, and the held half of `referenced`) come from `core/tracking`'s authority, never assembled ad hoc by a shell. `PruneReport` carries the authority's verdict: `blockedByTracking` + `ledgerUnreadable` / `unreadableUsageKeys`.
## Gotchas
+1 -1
View File
@@ -13,7 +13,7 @@ std::vector<std::string> pruneOrphans(const std::vector<std::string>& present,
const std::vector<std::string>& referenced,
const std::vector<std::string>& owned) {
// Exact-string membership — the model's canonical relative-path comparison
// (Sample.relativePath / OwnedFileManifest::contains). std::string hashes/compares
// (Sample.relativePath / OriginLedger::contains). std::string hashes/compares
// byte-for-byte, so no normalization creeps in.
const std::unordered_set<std::string> referencedSet(referenced.begin(),
referenced.end());
+17 -18
View File
@@ -10,20 +10,20 @@
// pool included (union across the whole book — see BankBook::
// referencedPaths). A file referenced by any bank — including via a
// COPY into a second bank — is NEVER an orphan (the prune null test).
// * owned — the owned-file manifest: files the bank system itself created. A
// present-but-unowned (hand-dropped) file is NEVER reclaimed.
// * owned — the tracking ledger's paths: files the bank system itself created.
// A present-but-unowned (hand-dropped) file is NEVER reclaimed.
//
// The three guardrails fall straight out of the set algebra:
// * ∩ present — never proposes deleting a file that is not on disk (an owned-
// but-absent manifest entry yields no orphan, no error).
// but-absent ledger record yields no orphan, no error).
// * ∩ owned — never a hand-dropped file (ownership attribution).
// * referenced — never a file any bank references (union safety, prune null test).
//
// Path representation: EXACT-STRING match everywhere — Sample.relativePath,
// OwnedFileManifest::contains, BankModel all use raw std::string equality: no
// OriginLedger::contains, BankModel all use raw std::string equality: no
// separator normalization, no case-folding, no trailing-slash trimming. Feeding a
// consistent spelling across the three inputs is the shell's contract (it enumerates
// the folder, unions the book, and reads the manifest against the SAME resolved
// the folder, unions the book, and reads the ledger against the SAME resolved
// current folder). Diverging from exact match here (e.g. case-insensitive compare)
// would be the unsafe direction — it could let one spelling of a referenced file be
// treated as an orphan under another.
@@ -48,23 +48,22 @@ namespace reasampler::reclaim {
// order (deterministic). MAY be truncated for a large set (the
// shell's display cap); `count` stays exact regardless.
// * truncated — true iff `orphans` holds fewer than `count` entries.
// * abortedUnreadableUsage — true iff the scan found a present-but-unreadable
// rsusage_* instance-usage record: the orphan computation was NOT
// performed (count 0, empty list) and the prune must HALT —
// deleting with degraded protection is the data-loss direction.
// Set by the scan shell, never by buildPruneReport (which stays a
// pure tally).
// * offendingUsageKeys — the exact "rsusage_<guid>" key names that triggered the
// abort (non-empty iff abortedUnreadableUsage), so the operator
// can clear each key via ReaScript:
// reaper.SetProjExtState(0, "reasampler", "<key>", "")
// * blockedByTracking — true iff the tracking authority could not answer the
// protection question: the orphan computation was NOT performed
// (count 0, empty list) and the prune must HALT — deleting with
// degraded protection is the data-loss direction. Set by the scan
// shell, never by buildPruneReport (which stays a pure tally).
// * ledgerUnreadable / unreadableUsageKeys — which side blocked, so the action can
// tell the operator what to recover. The key names are the exact
// "rsusage_<guid>" spellings.
struct PruneReport {
std::size_t count = 0;
std::uint64_t totalBytes = 0;
std::vector<std::string> orphans;
bool truncated = false;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
bool blockedByTracking = false;
bool ledgerUnreadable = false;
std::vector<std::string> unreadableUsageKeys;
};
// The outcome of an actual prune DELETION. The shell fills this as it deletes the
@@ -91,7 +90,7 @@ struct PruneDeletionResult {
//
// Returns the subset of `present` that is BOTH owned AND unreferenced, in the order
// they appear in `present` (deterministic — mirrors the insertion-order determinism
// the index/manifest keep). Duplicate spellings within `present` are de-duplicated
// the index/ledger keep). Duplicate spellings within `present` are de-duplicated
// in the result (a folder enumeration yields distinct names, but the core does not
// rely on that).
//
+76
View File
@@ -0,0 +1,76 @@
# src/core/tracking — the consolidated file-tracking system
## Scope
The one system behind every question about a file the tool created: who created it,
what it derives from, who is using it now. Pure (REAPER-free, unit-tested outside the
DAW). Two safety-critical consumers read it and no others: prune's protected set and
the resample's replace-vs-add decision.
This is the territory's **authority**, not one of three mechanisms. Two codecs feed it
from where codecs belong — the persisted ledger's own JSON (here) and the
instrument→extension usage wire (`core/wire/sample_usage`) — but every *decision* is
made in `tracking_authority`, from one `TrackingState`.
## Invariants
**Safety-critical, overriding.** This territory gates the system's only file-deletion
authority (prune) and its only capture-replacement act (resample). A tracking error
loses a user's audio. Every failure, ambiguity, or unreadable input resolves to the
non-destructive side of the question being asked — over-protection (prune skips a
reclaimable file, or refuses to run; resample adds instead of replacing) is an
accepted residual; under-protection is a data-loss bug.
**No silent gaps.** A system-created file is tracked from the instant it exists.
`ReaSamplerSession::recordCreated` is the only writer, called at the same point the
`Sample` is added, and it reads lineage off that `Sample`'s own provenance — so the
recipe fingerprint and the lineage record come from one act and cannot disagree.
**Lineage is never backfilled.** `OriginLedger::record` on a path already present is
`AlreadyPresent` and leaves the stored record untouched. A record says what was known
at birth or says nothing at all; nothing may later rewrite history from a guess.
**Never-recorded and unreadable are different absences.** `LedgerStatus` keeps them
apart, and only `loadLedger` can tell them apart (an empty stored value is not valid
JSON, so the parser alone cannot). `Fresh` — a new project or a bank predating the
ledger — blocks nothing and yields definite answers. `Unreadable` blocks every
destructive answer AND suppresses the next write, so a corrupt blob survives for
recovery instead of being replaced by a ledger missing every earlier file.
**Consumers cannot disagree.** Both answers come out of one `TrackingState`. The two
universes differ deliberately — prune protects bank-referenced paths, ledger-owned
paths, and every live hold; the tie query counts only live holds other than the
asker's own — but the replace-vs-add universe is a **strict subset** of the
prune-protection universe, proven in `test_tracking_authority`.
**Conservatism is asymmetric by facet.** The recipe fingerprint (`core/model/provenance`)
keeps its record-nothing-when-ambiguous stance: an ambiguous parent is no parent. The
lineage half has no record-nothing option — replace-vs-add must be computable — so a
birth record is written for every system-created file, ambiguous parentage or not
(the parent is simply empty).
## Modules
- `origin_ledger` — the record family: `OriginRecord` (project-relative path, `OriginKind`,
the sample id minted at birth, the parent sample id) and the insertion-ordered,
path-keyed, deduplicated `OriginLedger` that holds them, with its JSON codec and the
`loadLedger` three-way `Fresh` / `Loaded` / `Unreadable` classification. Persisted
under the FOREVER-STABLE `owned_files` ext-state key; the legacy path-only shape
(`{"owned":[...]}`) lifts in as `Unknown`-kind records with no lineage.
- `tracking_authority` — the one decision surface: `pruneProtection` (the `owned` and
held-path inputs prune's set algebra consumes, plus the blocked/blockers verdict)
and `tiedUsageExists` (`Yes` / `No` / `Indeterminate`, per capture, excluding the
asker's own usage key). Both read one borrowed `TrackingState`.
## Gotchas
- `OriginKind` values are PERSISTED INTEGERS — never renumber, only append. An
unrecognized value degrades to `Unknown` rather than failing the parse: a vocabulary
gap must not halt the prune.
- A `unioned` usage record can never be excluded as "my own" — it carries more than one
incarnation's holds, so attributing it to a single owner could hide a sibling's tie.
- The set algebra prune runs on `(owned ∩ present) referenced` is `core/reclaim`'s,
not this directory's; this directory supplies two of its three inputs.
- `sample_usage` deliberately stays in `core/wire` — it is a wire format, and its
instrument-side writer needs it there. Consolidation is of the *decisions*, not of
the codecs.
+10
View File
@@ -0,0 +1,10 @@
reasampler_pure_library(origin_ledger SOURCES origin_ledger.cpp LINK PRIVATE json)
reasampler_test(origin_ledger LINK origin_ledger json)
# prune_reconcile + bank_book are linked into the authority's test, not the library:
# the test proves end-to-end that a tied usage can never reach the orphan set, which
# is the "consumers cannot disagree" property stated at the pure layer.
reasampler_pure_library(tracking_authority
SOURCES tracking_authority.cpp
LINK PUBLIC origin_ledger sample_usage)
reasampler_test(tracking_authority LINK tracking_authority prune_reconcile bank_book)
+187
View File
@@ -0,0 +1,187 @@
#include "core/tracking/origin_ledger.h"
#include <cctype>
#include "core/json/json.h"
// Version ladder for the stored blob, under the FOREVER-STABLE "owned_files" key:
//
// v1 (legacy, path-only) {"owned":["reasampler_bank/a.wav"]}
// v2 (current) {"v":2,"records":[{"path":"...","kind":1,
// "sample":"id","parent":"pid"}]}
//
// v1 blobs lift to v2 records with kind Unknown and empty ids — a pre-existing bank
// keeps every protection it had (the paths are still owned) and gains no invented
// lineage. Both shapes parse; only v2 is written.
namespace reasampler::tracking {
namespace {
// Any leading '/' or '\' (POSIX root / UNC), or a leading <alpha>: (Windows drive,
// incl. drive-relative "C:foo"). Must match bank_model's rejection exactly — the
// ledger holds the same kind of path as Sample.relativePath.
bool isAbsolutePath(const std::string& p) {
if (p.empty()) return false;
if (p[0] == '/' || p[0] == '\\') return true;
if (p.size() >= 2 && std::isalpha(static_cast<unsigned char>(p[0])) && p[1] == ':')
return true;
return false;
}
// An unrecognized persisted integer degrades to Unknown rather than failing the
// parse: a newer channel's kind must not make the whole ledger unreadable, which
// would halt prune on nothing worse than a vocabulary gap.
OriginKind kindFromInt(int v) {
switch (v) {
case 1: return OriginKind::Capture;
case 2: return OriginKind::Ingest;
case 3: return OriginKind::Recapture;
case 4: return OriginKind::Resample;
default: return OriginKind::Unknown;
}
}
} // namespace
bool OriginRecord::operator==(const OriginRecord& o) const {
return relativePath == o.relativePath && kind == o.kind &&
sampleId == o.sampleId && parentSampleId == o.parentSampleId;
}
RecordResult OriginLedger::record(const OriginRecord& rec) {
if (rec.relativePath.empty()) return RecordResult::RejectedEmptyPath;
if (isAbsolutePath(rec.relativePath)) return RecordResult::RejectedAbsolutePath;
if (contains(rec.relativePath)) return RecordResult::AlreadyPresent;
records_.push_back(rec);
return RecordResult::Recorded;
}
const OriginRecord* OriginLedger::find(const std::string& relativePath) const {
for (const OriginRecord& r : records_)
if (r.relativePath == relativePath) return &r;
return nullptr;
}
bool OriginLedger::contains(const std::string& relativePath) const {
return find(relativePath) != nullptr;
}
std::vector<std::string> OriginLedger::ownedPaths() const {
std::vector<std::string> out;
out.reserve(records_.size());
for (const OriginRecord& r : records_) out.push_back(r.relativePath);
return out;
}
std::string OriginLedger::serialize() const {
std::string out = "{\"v\":2,\"records\":[";
for (std::size_t i = 0; i < records_.size(); ++i) {
if (i) out += ',';
const OriginRecord& r = records_[i];
out += "{\"path\":";
json::writeEscaped(out, r.relativePath);
out += ",\"kind\":" + json::numToStr(static_cast<int>(r.kind));
out += ",\"sample\":";
json::writeEscaped(out, r.sampleId);
out += ",\"parent\":";
json::writeEscaped(out, r.parentSampleId);
out += '}';
}
out += "]}";
return out;
}
namespace {
bool parseRecord(json::Reader& r, OriginRecord& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true;
for (;;) {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "path") {
if (!r.parseString(out.relativePath)) return false;
} else if (key == "kind") {
int k = 0;
if (!r.parseInt(k)) return false;
out.kind = kindFromInt(k);
} else if (key == "sample") {
if (!r.parseString(out.sampleId)) return false;
} else if (key == "parent") {
if (!r.parseString(out.parentSampleId)) return false;
} else if (!r.skipValue()) {
return false;
}
r.skipWs();
if (r.consume(',')) continue;
if (r.consume('}')) return true;
return false;
}
}
bool parseRecordArray(json::Reader& r, OriginLedger& out) {
if (!r.consume('[')) return false;
r.skipWs();
if (r.consume(']')) return true;
for (;;) {
OriginRecord rec;
if (!parseRecord(r, rec)) return false;
// Through record() so the persisted invariants (dedup, reject empty/absolute)
// are re-asserted on load: a hand-edited or corrupt blob cannot smuggle an
// absolute or duplicate path in.
out.record(rec);
r.skipWs();
if (r.consume(',')) continue;
if (r.consume(']')) return true;
return false;
}
}
bool parseLedger(json::Reader& r, OriginLedger& out) {
if (!r.consume('{')) return false;
r.skipWs();
if (r.consume('}')) return true;
for (;;) {
std::string key;
if (!r.parseKey(key)) return false;
if (key == "records") {
if (!parseRecordArray(r, out)) return false;
} else if (key == "owned") {
std::vector<std::string> paths;
if (!r.parseStringArray(paths)) return false;
for (const std::string& p : paths) out.record(OriginRecord{p});
} else if (!r.skipValue()) {
return false;
}
r.skipWs();
if (r.consume(',')) continue;
if (r.consume('}')) return true;
return false;
}
}
} // namespace
std::optional<OriginLedger> OriginLedger::deserialize(const std::string& json) {
OriginLedger ledger;
::reasampler::json::Reader r(json);
if (!parseLedger(r, ledger)) return std::nullopt;
return ledger;
}
LedgerLoad loadLedger(const std::string& stored) {
LedgerLoad load;
if (stored.empty()) return load; // absent key -> Fresh, not an error
std::optional<OriginLedger> parsed = OriginLedger::deserialize(stored);
if (!parsed) {
load.status = LedgerStatus::Unreadable;
return load;
}
load.status = LedgerStatus::Loaded;
load.ledger = std::move(*parsed);
return load;
}
} // namespace reasampler::tracking
+104
View File
@@ -0,0 +1,104 @@
#pragma once
// origin_ledger — the one persisted record family behind file tracking: for every
// file the system itself created, what act created it and what it derives from.
// Supersedes the path-only owned manifest (same ext-state key, widened shape).
//
// Lineage is written at birth and never backfilled — record() on a path already
// present is a no-op, so a record says what was known when the file appeared or
// says nothing at all. Nothing here decides anything; tracking_authority does.
#include <cstddef>
#include <optional>
#include <string>
#include <vector>
namespace reasampler::tracking {
// The act that created the file. `Unknown` is the never-recorded case — a record
// lifted from a legacy path-only manifest, or one whose creator did not know.
// PERSISTED AS INTEGERS: never renumber an existing value, only append.
enum class OriginKind {
Unknown = 0,
Capture = 1,
Ingest = 2,
Recapture = 3, // regenerated in place from its recorded source recipe
Resample = 4, // baked from an instrument's own processing chain
};
// One system-created file's birth record. `relativePath` is the key and is ALWAYS
// project-relative (the same invariant as Sample.relativePath); an absolute path is
// rejected rather than relativized, because the pure model has no project root and
// guessing one could point at the wrong file.
struct OriginRecord {
std::string relativePath;
OriginKind kind = OriginKind::Unknown;
std::string sampleId; // bank id minted at birth; "" when never recorded
std::string parentSampleId; // the capture this derives from; "" = root / none
bool operator==(const OriginRecord& o) const;
bool operator!=(const OriginRecord& o) const { return !(*this == o); }
};
// Outcome of a record(). The op reports what happened rather than silently mutating
// on a bad request (mirrors BankModel::AddResult).
enum class RecordResult {
Recorded,
AlreadyPresent, // dedup no-op; the existing record is NOT overwritten
RejectedEmptyPath,
RejectedAbsolutePath,
};
// Insertion-ordered, path-keyed, deduplicated. Insertion order is preserved so
// serialize() round-trips byte-identically (deterministic ext-state).
//
// NOT a mirror of the bank index: removing or moving an index entry leaves the
// record alone. The ledger tracks files *created*; prune reconciles it against the
// index later.
class OriginLedger {
public:
OriginLedger() = default;
// A path already present is AlreadyPresent and leaves the stored record
// untouched — the no-backfill rule, enforced here rather than at call sites.
RecordResult record(const OriginRecord& rec);
// nullptr when the path was never recorded. Distinguishing that from an
// unreadable ledger is the loader's job (see LedgerStatus).
const OriginRecord* find(const std::string& relativePath) const;
bool contains(const std::string& relativePath) const;
// Prune's `owned` input, in insertion order.
std::vector<std::string> ownedPaths() const;
const std::vector<OriginRecord>& records() const { return records_; }
std::size_t size() const { return records_.size(); }
bool empty() const { return records_.empty(); }
bool operator==(const OriginLedger& o) const { return records_ == o.records_; }
// Lossless round-trip: deserialize(serialize(x)) == x. std::nullopt on malformed
// input — the caller must treat that as unreadable, never as empty.
std::string serialize() const;
static std::optional<OriginLedger> deserialize(const std::string& json);
private:
std::vector<OriginRecord> records_;
};
// The three states of a stored ledger, kept apart because never-recorded and
// unreadable demand opposite treatment: `Fresh` is a legitimate empty (a new
// project, or a bank predating the ledger) and blocks nothing; `Unreadable` is a
// present-but-corrupt blob and must block every destructive answer.
enum class LedgerStatus { Fresh, Loaded, Unreadable };
struct LedgerLoad {
LedgerStatus status = LedgerStatus::Fresh;
OriginLedger ledger; // empty unless status == Loaded
};
// Classifies a raw stored value. An empty string is the absent key (Fresh), not an
// error — an empty string is not valid JSON, so the two cases cannot be told apart
// by the parser alone.
LedgerLoad loadLedger(const std::string& stored);
} // namespace reasampler::tracking
+43
View File
@@ -0,0 +1,43 @@
#include "core/tracking/tracking_authority.h"
namespace reasampler::tracking {
ProtectionAnswer pruneProtection(const TrackingState& state) {
ProtectionAnswer answer;
// heldPaths is taken unconditionally: on an abort the usage fold returns its
// protect-all set, which is the widest (safest) answer available.
answer.heldPaths = state.usage.heldPaths;
if (state.usage.abortPrune) {
answer.blocked = true;
answer.unreadableUsageKeys = state.usage.offendingKeys;
}
if (state.ledgerStatus == LedgerStatus::Unreadable) {
answer.blocked = true;
answer.ledgerUnreadable = true;
return answer; // ownedPaths left empty -> (owned ∩ present) is empty
}
answer.ownedPaths = state.ledger.ownedPaths();
return answer;
}
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
const std::string& ownUsageKey) {
if (capturePath.empty()) return Answer::Indeterminate;
if (state.ledgerStatus == LedgerStatus::Unreadable) return Answer::Indeterminate;
if (state.usage.abortPrune) return Answer::Indeterminate;
for (const wire::CountedUsage& counted : state.usage.counted) {
const bool isOwn = !ownUsageKey.empty() && counted.key == ownUsageKey &&
!counted.record.unioned;
if (isOwn) continue;
for (const wire::UsageHold& hold : counted.record.holds)
if (hold.relativePath == capturePath) return Answer::Yes;
}
return Answer::No;
}
} // namespace reasampler::tracking
+65
View File
@@ -0,0 +1,65 @@
#pragma once
// tracking_authority — the ONE place the two safety-critical consumers are answered:
// prune's protected set and the resample's replace-vs-add decision. Both read the
// same TrackingState, so they cannot drift apart; every unreadable or ambiguous
// input resolves to the non-destructive side of its own question.
#include <string>
#include <vector>
#include "core/tracking/origin_ledger.h"
#include "core/wire/sample_usage.h"
namespace reasampler::tracking {
// A borrowed view of everything both consumers read, gathered once by the shell.
// References, not values: the ledger can hold thousands of records and both answers
// are computed from one gather. Never outlives the gather that built it.
struct TrackingState {
LedgerStatus ledgerStatus;
const OriginLedger& ledger;
const wire::UsageFoldResult& usage;
};
// Prune's answer. `blocked` means the protected set is unknowable and the prune must
// HALT — deleting with degraded protection is the data-loss direction. The two
// blocker fields say what to tell the operator; the caller composes the message
// (the pure core does not know ext-state key spellings).
//
// Both path lists stay populated on a block as belt-and-braces: heldPaths carries
// the usage fold's protect-all set, and ownedPaths is left EMPTY on an unreadable
// ledger, so a caller that ignored `blocked` still computes an empty orphan set.
struct ProtectionAnswer {
bool blocked = false;
bool ledgerUnreadable = false;
std::vector<std::string> unreadableUsageKeys;
std::vector<std::string> heldPaths; // union into prune's `referenced`
std::vector<std::string> ownedPaths; // prune's `owned`
};
ProtectionAnswer pruneProtection(const TrackingState& state);
// The resample's answer, per capture. `Indeterminate` is not a failure to compute —
// it is the recorded verdict that the state could not be read, and the caller must
// treat it exactly as `Yes` (never take the replace branch).
enum class Answer { No, Yes, Indeterminate };
// Does a usage tied to `capturePath` exist, other than the asking instance's own?
//
// * unreadable ledger, or any unreadable usage record -> Indeterminate.
// * otherwise Yes iff some counting live record holds the path.
//
// `ownUsageKey` is the asking instance's own "rsusage_<guid>" key, excluded from the
// scan so a bake does not see itself; empty counts every holder. A `unioned` record
// is NEVER excluded — it carries more than one incarnation's holds, so attributing
// it to a single owner could hide a sibling's tie.
//
// Never-recorded (no ledger record for the path) is a definite answer, not an
// abstention: a pre-existing capture nothing holds answers No.
//
// The universes cannot disagree: a Yes implies `capturePath` is in the same
// pruneProtection(state).heldPaths, which is itself a subset of what prune protects.
Answer tiedUsageExists(const TrackingState& state, const std::string& capturePath,
const std::string& ownUsageKey);
} // namespace reasampler::tracking
+13 -9
View File
@@ -58,7 +58,10 @@ This directory owns two cross-artifact contracts specifically:
delete). The guarantee: a capture held by any live instance can never be deleted; if
the prune cannot determine with certainty which captures are held, it aborts
entirely (deletes nothing). Over-protection is the accepted residual; under-protection
is a data-loss bug.
is a data-loss bug. The fold reports `counted` — the live records still attributed to
their `rsusage_*` keys — because the flattened path list cannot answer "who holds
this"; `counted` is empty whenever `abortPrune` is set, since attribution is exactly
what an unreadable record destroys.
- **Collision safety (`planUsagePublish`).** A persisted GUID is copyable (FX copy /
track duplication). `ownerNonce` — a per-lifetime nonce minted fresh in memory at
instance creation, never persisted — proves "exactly this incarnation wrote the key
@@ -67,13 +70,14 @@ This directory owns two cross-artifact contracts specifically:
Resolution always leans over-protect: same-nonce + not-unioned → clean replace;
same-track foreign nonce or unioned → union; cross-track foreign nonce → remint under
a fresh key. None of the three directions can under-protect.
- **Deferred follow-up (TODO.md, not this dispatch's scope):** `ownerNonce` is not
persisted, so after save→reopen an instance cannot recognize its own prior-session
usage record — it unions and marks the record `unioned` forever, so prune stops
reclaiming captures the instance once held but no longer uses (safe, but the bank
folder grows unbounded). Persisting the nonce is deferred because a persisted nonce
would be inherited by a Ctrl+D in-place FX duplicate, and a divergent clone must
still be detected and protected fail-safe without reintroducing the sibling-drop bug.
- **Deferred follow-up (TODO.md, deliberately NOT absorbed by the tracking
consolidation):** `ownerNonce` is not persisted, so after save→reopen an instance
cannot recognize its own prior-session usage record — it unions and marks the record
`unioned` forever, so prune stops reclaiming captures the instance once held but no
longer uses (safe, but the bank folder grows unbounded). This is a *completeness*
wart, not a safety one; every candidate fix examined so far trades it for a new
under-protection window, which the consolidation's own safety mandate forbids. See
`docs/TODO.md` for the constraint and the rejected session-epoch candidate.
## Modules
@@ -81,7 +85,7 @@ This directory owns two cross-artifact contracts specifically:
- `reasampler_uid.h` — SDK-free header owning the FOREVER-FROZEN VST3 class-UID integer macros (stable + beta pairs, `REASAMPLER_PROC_UID_*` / `REASAMPLER_PROC_UID_BETA_*`) and the `REASAMPLER_ACTIVE_UID_*` channel-selector macros. Split out of `reasampler_vst.h` so the pure extension side (`instrument_drop`) can derive the `.vstpreset` class-ID hex string without pulling in the VST3 SDK. Both `reasampler_vst.h` (runtime `FUID`) and `instrument_drop` (preset hex string) source from this single header — the binary identity and the preset-file identity cannot diverge.
- `assignment_request` — pure ingest-assign wire: typed request record carrying the drop payload from the `ingest` shell through to the VST3 bridge.
- `instrument_drop` — pure FX-drop payload builder: constructs a Steinberg-format `.vstpreset` image (channel-active class ID + the instrument's own component state, capture pre-selected) the shell applies via `TrackFX_SetPreset`; owns the `infoNamesFxHotspot` prefix classifier for `GetThingFromPoint` tokens. All-or-nothing contract — caller rolls back via `TrackFX_Delete` on any failure.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW.
- `sample_usage` — instance-usage wire: `UsageRecord`, `planUsagePublish` (fresh/heal/clean-replace/union/remint publish plan), `foldUsageRecords`/`usageHeldPaths` (liveness fold — protect-all when records exist but no instance is live; abort→protect-all on unreadable record; `counted` carries key-attributed live records), `identityMatches` (ReaSampler 9000 FX identity). REAPER-free, unit-tested. The mirror of `assignment_request` on the instrument→extension direction: the wire format and the two safety-critical decisions (what to write on publish, which records count at prune time) are pure so they are provable without a DAW. It lives here because it is a *wire format* with an instrument-side writer; the fold's output is consumed by `core/tracking`'s authority, which owns every consumer-facing decision built on it.
## Gotchas
+40 -18
View File
@@ -122,6 +122,21 @@ UsagePublishPlan planUsagePublish(const std::optional<std::string>& existing,
return plan;
}
namespace {
// The liveness rule, in one place so the path fold and the attribution fold can
// never disagree about which records counted. `protectAll` is the caller's
// zero-identified net (see usageHeldPaths).
bool recordCounts(const UsageRecord& rec,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive, bool protectAll) {
if (protectAll) return true;
if (rec.trackGuid.empty()) return anyInstanceLive;
return liveTrackGuids.count(rec.trackGuid) != 0;
}
} // namespace
std::vector<std::string> usageHeldPaths(
const std::vector<UsageRecord>& records,
const std::unordered_set<std::string>& liveTrackGuids,
@@ -133,11 +148,7 @@ std::vector<std::string> usageHeldPaths(
// paths rather than none (zero-identified must never degrade toward delete).
const bool protectAll = !records.empty() && !anyInstanceLive;
for (const UsageRecord& rec : records) {
const bool live = protectAll ||
(rec.trackGuid.empty()
? anyInstanceLive
: (liveTrackGuids.count(rec.trackGuid) != 0));
if (!live) continue;
if (!recordCounts(rec, liveTrackGuids, anyInstanceLive, protectAll)) continue;
for (const UsageHold& h : rec.holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second) out.push_back(h.relativePath);
@@ -147,23 +158,25 @@ std::vector<std::string> usageHeldPaths(
}
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::vector<DecodedUsage>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive) {
UsageFoldResult result;
std::vector<UsageRecord> records;
records.reserve(decoded.size());
for (const std::optional<UsageRecord>& rec : decoded) {
if (!rec) {
for (const DecodedUsage& entry : decoded) {
if (entry.record) continue;
// Present-but-unreadable record: it may protect anything, so halt.
// Belt-and-braces: also return the protect-all set (every readable
// record's paths, bypassing the liveness filter) so the fail-safe
// holds even if a future caller forgets to check abortPrune first.
result.abortPrune = true;
result.offendingKeys.push_back(entry.key);
}
if (result.abortPrune) {
// Belt-and-braces: return the protect-all set (every readable record's
// paths, bypassing the liveness filter) so the fail-safe holds even if a
// future caller forgets to check abortPrune first. `counted` stays empty —
// attribution is exactly what an unreadable record makes unknowable.
std::unordered_set<std::string> seen;
for (const std::optional<UsageRecord>& r : decoded) {
if (!r) continue;
for (const UsageHold& h : r->holds) {
for (const DecodedUsage& entry : decoded) {
if (!entry.record) continue;
for (const UsageHold& h : entry.record->holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second)
result.heldPaths.push_back(h.relativePath);
@@ -171,9 +184,18 @@ UsageFoldResult foldUsageRecords(
}
return result;
}
records.push_back(*rec);
}
std::vector<UsageRecord> records;
records.reserve(decoded.size());
for (const DecodedUsage& entry : decoded) records.push_back(*entry.record);
result.heldPaths = usageHeldPaths(records, liveTrackGuids, anyInstanceLive);
const bool protectAll = !records.empty() && !anyInstanceLive;
for (const DecodedUsage& entry : decoded) {
if (!recordCounts(*entry.record, liveTrackGuids, anyInstanceLive, protectAll))
continue;
result.counted.push_back(CountedUsage{entry.key, *entry.record});
}
return result;
}
+20 -5
View File
@@ -147,16 +147,31 @@ std::vector<std::string> usageHeldPaths(
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive);
// The prune-side entry fold over raw read/decode results, one element per
// enumerated rsusage_* key: nullopt = present but unreadable/undecodable. ANY
// nullopt sets abortPrune (halt, delete nothing); otherwise delegates to
// usageHeldPaths (including its protect-all net).
// One enumerated rsusage_* key and what came back from it: nullopt = present but
// unreadable/undecodable.
struct DecodedUsage {
std::string key; // "rsusage_<guid>"
std::optional<UsageRecord> record;
};
// A record that counted toward heldPaths, still attributed to its key. Lets a
// consumer ask "who holds this path" — the flattened path list cannot.
struct CountedUsage {
std::string key;
UsageRecord record;
};
// The prune-side entry fold. ANY unreadable record sets abortPrune (halt, delete
// nothing) and names its key; otherwise delegates to usageHeldPaths (including its
// protect-all net) and reports which records counted.
struct UsageFoldResult {
bool abortPrune = false;
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
std::vector<std::string> heldPaths;
std::vector<CountedUsage> counted; // empty on abort
};
UsageFoldResult foldUsageRecords(
const std::vector<std::optional<UsageRecord>>& decoded,
const std::vector<DecodedUsage>& decoded,
const std::unordered_set<std::string>& liveTrackGuids,
bool anyInstanceLive);
+3 -2
View File
@@ -26,11 +26,12 @@ is owned by other directories and only skinned here.
one Ctrl-Z.
- **The prune action is the ONLY file-deletion action in the system**; it opens no
undo point (file deletion is not REAPER-undoable). It halts on
`abortedUnreadableUsage` and prints the offending `rsusage_*` key names.
`blockedByTracking` and prints whichever blockers fired — the malformed ledger,
the offending `rsusage_*` key names, or both.
## Modules
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). **pS-usage:** `BANK_PRUNE_FOLDER` halts on `abortedUnreadableUsage` and prints the offending `rsusage_*` key names with clear instructions.
- `shell/actions` (`action_registry` / `design_view_actions` / `bank_actions` / `prune_action`) — the bindable action families, all routed via the `command_id`/`gaccel`/`hookcommand` contract. `action_registry` owns the shared registration plumbing (interned channel-qualified id strings; register and mirror-unregister present the identical pointer) **and the Q-W6 registration TABLE**: `main.cpp`'s own family (capture scopes, panel toggle, insert, batch, realtime, recapture, version) is one `ActionTableRow` array — suffix, phrase, flat function-pointer handler — that registration, hookcommand dispatch, and the unload mirror-unregister all iterate, so adding an action touches the table only (OCP). Bank mutations flow through the promptless `shell/bank_ops` verbs (`bankOp*` + `persistBankOp`, taking `ReaSamplerSession&`), which the panel menus and `bank_actions` consume as thin UX skins. **Every bank index verb wraps its mutation in a batched REAPER undo point (`Undo_BeginBlock2`/`EndBlock2`, `UNDO_STATE_MISCCFG`) so one bank operation is one Ctrl-Z.** The prune action (`prune_action`, `BANK_PRUNE_FOLDER`) is **the ONLY file-deletion action in the system**; it opens no undo point (file deletion is not REAPER-undoable). `BANK_PRUNE_FOLDER` halts on `blockedByTracking` and prints each blocker that fired, with recovery instructions.
- `drag_out_win` — OS drag-out shell: Windows OLE `DoDragDrop`/`CF_HDROP`, copy-only (`DROPEFFECT_MOVE` not offered); macOS/Linux via `SWELL_InitiateDragDropOfFileList`.
- `instrument_drop_win` — FX-button drop shell: resolves a screen point to a track + FX-surface hotspot, then adds a ReaSampler 9000 instance and applies the dragged capture's state via a transient `.vstpreset` + `TrackFX_SetPreset` (the former `TrackFX_SetNamedConfigParm` "vst_chunk" write was silently unappliable for VST3). Exposes `loadInstrumentOntoTrack` (inner half, no own undo block) and `performInstrumentDrop` (wraps in its own undo block). **Never captures, never writes the bank, never inserts a timeline item.**
- `ingest` — ingest-through-the-bank shell on the EXTENSION side: three surfaces — (1) arrange capture→bank→assign (bindable action), (2) Media-Explorer import→bank→instrument on the selected track, (3) file drop onto the bank panel→bank only. Only surface (1) writes the `assignment_request` ext-state wire. **ingest NEVER inserts a timeline item.**
+2 -2
View File
@@ -273,9 +273,9 @@ ImportResult importFileIntoActiveBank(const std::string& absoluteSourcePath) {
s.createdTimestamp = nowSec;
const AddResult r = book.activeIndex().add(s);
// Record as owned regardless of outcome — the tool WROTE the file, so prune must
// Record the birth regardless of outcome — the tool WROTE the file, so prune must
// attribute it even in the narrow Collapsed race below.
g_session->owned().add(paths.relativePath);
g_session->recordCreated(s, tracking::OriginKind::Ingest);
switch (r) {
case AddResult::Added:
+20 -10
View File
@@ -23,21 +23,31 @@ namespace reasampler {
void doBankPruneFolder(ReaSamplerSession& session) {
const reclaim::PruneReport report = session.pruneDryRun();
// FAIL-SAFE: an unreadable instance-usage record makes the protected set
// unknowable, so the prune HALTS outright rather than proceed with degraded
// protection.
if (report.abortedUnreadableUsage) {
// FAIL-SAFE: tracking state the authority could not read makes the protected
// set unknowable, so the prune HALTS outright rather than proceed with degraded
// protection. Both blockers can fire at once; report each one that did.
if (report.blockedByTracking) {
std::string msg =
"ReaSampler prune: ABORTED -- one or more instance usage records could not "
"be read or decoded. Nothing was deleted.\n"
"If the owning instance is still loaded it will republish its record on the "
"next poll tick, clearing the abort. If the instance no longer exists (the "
"key is an orphaned corrupt record), clear it manually via ReaScript:\n"
"ReaSampler prune: ABORTED -- the file-tracking state could not be read. "
"Nothing was deleted.\n";
if (report.ledgerUnreadable) {
msg += "The stored file-tracking ledger is malformed. It has been left "
"intact rather than overwritten, so it can be repaired or cleared:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"owned_files\", \"\")\n"
"Clearing it makes every existing bank file un-reclaimable (they stop "
"being attributable to ReaSampler); no file is lost.\n";
}
if (!report.unreadableUsageKeys.empty()) {
msg += "One or more instance usage records could not be read or decoded. "
"If the owning instance is still loaded it will republish its record "
"on the next poll tick, clearing the abort. If the instance no longer "
"exists (the key is an orphaned corrupt record), clear it manually:\n"
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
"Offending key(s):\n";
for (const std::string& key : report.offendingUsageKeys) {
for (const std::string& key : report.unreadableUsageKeys) {
msg += " " + key + "\n";
}
}
ShowConsoleMsg(msg.c_str());
return;
}
+3 -3
View File
@@ -4,9 +4,9 @@
// module. Registration/dispatch for its FOREVER-STABLE id (BANK_PRUNE_FOLDER) stay
// with bank_actions; one guarded body here.
//
// Contract (preserve exactly): dry-run first; abort outright on unreadable usage
// records (fail-safe); confirm-with-manifest before any deletion; opens NO undo
// point and writes NO ext state (file deletion is not REAPER-undoable).
// Contract (preserve exactly): dry-run first; abort outright when the tracking
// authority reports a block (fail-safe); confirm-with-manifest before any deletion;
// opens NO undo point and writes NO ext state (file deletion is not REAPER-undoable).
namespace reasampler {
+1 -1
View File
@@ -36,7 +36,7 @@ detail not covered there:
- `capture` — two CONCRETE backends with deliberately different lifecycles (no shared interface — the former `ICaptureBackend` was deleted in Q-W3, T4-26: one deriver, zero polymorphic call sites): `OfflineRenderBackend` (deterministic default, synchronous) and `RealtimeRecordBackend` (async begin/tick/abort). Input: `CaptureRequest`. Output: finished file + populated `Sample` handed to `bank_model`.
- `scope_resolve` (`shell/capture`) — scope/source resolution shared by every capture entry point (Q-W3 hoist out of `main.cpp`): razor-else-time range inference, selected-track/selected-item-owning-track collection with canonical GUIDs, and the M10 provenance-assembly inputs (read BEFORE the FX-bypass guard neutralizes the in-scope chain).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + owned-manifest record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `capture_orchestrator` (`shell/capture`) — single-capture orchestration + the realtime/insert action bodies (Q-W3 hoist, T4-02): `renderOffline` (one offline render under the scope's FX-bypass guard), `captureAndIndexOne` (render + provenance stamp + bank add + tracking-ledger record, unpersisted), `RunCapture`/`RunCaptureItemAssign`, `RunCaptureRealtimeTrack`/`RunCancelRealtime` (the realtime action bodies — the in-flight state lives in `realtime_lifecycle`), and `RunInsertSelected` (the ONE deliberate exception to capture-never-places).
- `capture_batch` (`shell/capture`) — the batch-capture family + re-capture-from-source (Q-W3 hoist, T4-02): `RunBatchCaptureItems` (one sample per selected item), `RunBatchCaptureRazor` (one sample per razor area), `RunRecaptureFromSource` (regenerate a provenanced sample from its recorded source's current state, bank-only). Every unit routes through `capture_orchestrator` so every precision invariant holds; persist is batched to one ext-state write per action.
- `realtime_lifecycle` (`shell/capture`) — the in-flight realtime-capture state machine + globals (Q-W3 hoist): the action starts it, `OnTimer` drives it per tick via `DriveRealtimeCapture` (a single-pointer-test idle fast path — load-bearing hot-path guardrail), `CommitRealtimeResult` lands a finished capture in the bank, `AbortRealtimeCaptureForUnload` tears down cleanly on extension unload.
- `capture_realtime_shell` (`shell/capture`) — the async realtime-record backend surface (Q-W6 split of the former fat `capture.h`): `RealtimeRecordBackend::begin`/`tick`/`abort`, transport-driven across timer ticks (a realtime record cannot block REAPER's UI for its own duration). Deliberately shares NO interface with the offline backend — the lifecycles genuinely differ (the former `ICaptureBackend` interface was deleted in Q-W3, T4-26).
+5 -4
View File
@@ -458,14 +458,15 @@ void RunRecaptureFromSource(ReaSamplerSession& session)
const bool changed = session.book().updateSampleInPlace(sampleId, updated);
if (changed)
{
// Record the regenerated file in the owned manifest; the superseded file
// becomes an orphan for prune to reclaim.
session.owned().add(updated.relativePath);
// Record the regenerated file's birth; the superseded file becomes an orphan
// for prune to reclaim. A re-capture onto the same path is a dedup no-op, so
// the original birth record — not this one — stays authoritative.
session.recordCreated(updated, tracking::OriginKind::Recapture);
// Regenerating the same id's audio is exactly why instances need the generation
// bump — they'd otherwise keep playing stale audio until reload. Bumped inside
// the undo block so undo rolls back the generation with the rest of the blob.
session.bumpBankGeneration();
const bool persisted = session.saveToActiveProject(); // book + manifest + generation + MarkProjectDirty
const bool persisted = session.saveToActiveProject(); // book + ledger + generation + MarkProjectDirty
Undo_EndBlock2(nullptr, persisted ? "ReaSampler: re-capture from source" : "",
persisted ? UNDO_STATE_MISCCFG : 0);
}
+7 -8
View File
@@ -188,7 +188,7 @@ CaptureResult renderOffline(CaptureScope scope,
// Renders ONE capture request under the scope's FX-bypass guard, stamps provenance,
// and adds the resulting Sample to the ACTIVE bank + records the created file in the
// owned-file manifest — WITHOUT persisting. The caller persists once (single-capture:
// tracking ledger — WITHOUT persisting. The caller persists once (single-capture:
// right after; batch: once at the end) so a batch does not write ext state N times.
//
// Provenance is read from the LIVE selection here, so a batch that transiently
@@ -250,11 +250,10 @@ CaptureResult captureAndIndexOne(ReaSamplerSession& session,
// AddResult tells a fresh add from a hash-dedup collapse, so the assign path (S8) can
// target the sample actually in the bank (the existing entry on a collapse).
const model::AddResult addResult = session.bank().add(res.sample);
// B-cap: record the created file in the owned-file manifest, at the same point the
// Sample is added. Recorded regardless of the index AddResult — even a hash-collapse
// still WROTE a file the tool owns, and the manifest dedups a repeat path itself
// (Phase R prune reconciles manifest vs index later).
session.owned().add(res.sample.relativePath);
// Record the birth at the same point the Sample is added, regardless of the index
// AddResult — even a hash-collapse still WROTE a file the tool owns, and the ledger
// dedups a repeat path itself (prune reconciles ledger vs index later).
session.recordCreated(res.sample, tracking::OriginKind::Capture);
// Resolve the LANDED bank-index id into res.sample.id for the S8 assign path: the new
// id on a fresh Added (already in res.sample.id); the EXISTING entry's id on a
@@ -297,8 +296,8 @@ std::string RunCapture(ReaSamplerSession& session, const CaptureActionDef& def)
}
// captureAndIndexOne has already stamped provenance, added the Sample to the ACTIVE
// bank, and recorded the created file in the owned-file manifest (WITHOUT persisting).
// Persist the updated book AND manifest into the active project's ext state (the
// bank, and recorded the created file in the tracking ledger (WITHOUT persisting).
// Persist the updated book AND ledger into the active project's ext state (the
// `banks` + `owned_files` keys) so the capture survives Save / close+reopen (M4) and
// travels with the .rpp. saveToActiveProject also clears the retired legacy key and
// calls MarkProjectDirty. Non-destructive: writes only our own ext-state keys.
+2 -2
View File
@@ -2,7 +2,7 @@
// Single-capture orchestration + the realtime/insert action bodies: renderOffline
// (one offline render under the scope's FxBypassGuard, shared by single-shot/
// batch/recapture), captureAndIndexOne (render + provenance + bank add +
// owned-manifest record, unpersisted), RunCapture/RunCaptureItemAssign,
// tracking-ledger record, unpersisted), RunCapture/RunCaptureItemAssign,
// RunCaptureRealtimeTrack/RunCancelRealtime (in-flight state lives in
// realtime_lifecycle), and RunInsertSelected — the one deliberate exception to
// capture-never-places.
@@ -34,7 +34,7 @@ CaptureResult renderOffline(CaptureScope scope,
const CaptureRequest& req);
// Renders one capture request, stamps provenance, adds the Sample to the
// active bank + owned-file manifest — without persisting (batch persists once
// active bank + tracking ledger — without persisting (batch persists once
// at the end). res.sample.id carries the landed bank-index id (fresh add or
// hash-dedup collapse target).
CaptureResult captureAndIndexOne(ReaSamplerSession& session,
+4 -5
View File
@@ -31,14 +31,13 @@ void CommitRealtimeResult(ReaSamplerSession& session, const CaptureResult& res)
return;
}
session.bank().add(res.sample);
// Record the file in the owned manifest regardless of the index AddResult — even
// a hash-collapse still wrote a file the tool owns; the manifest dedups a repeat
// path itself (prune reconciles manifest vs index).
session.owned().add(res.sample.relativePath);
// Record the birth regardless of the index AddResult — even a hash-collapse still
// wrote a file the tool owns; the ledger dedups a repeat path itself.
session.recordCreated(res.sample, tracking::OriginKind::Capture);
// A capture add changes what a live instance could play, so bump the generation
// before persisting to refresh instances.
session.bumpBankGeneration();
session.saveToActiveProject(); // persist book + manifest + generation + MarkProjectDirty
session.saveToActiveProject(); // persist book + ledger + generation + MarkProjectDirty
}
// Advances any in-flight realtime capture one tick. Detects a project switch
+29 -24
View File
@@ -4,10 +4,11 @@
The persist seam: project ext-state read/write (`session` / `ext_state_io`), the
prune path's filesystem half (`prune_fs`), and the extension-side instance-usage
scan (`usage_scan`) that feeds prune's referenced-set. Internal helpers shared only
within the persist TU family live in `persist_internal.h`. The pure orphan
computation is owned elsewhere (`core/reclaim`); the pure usage wire is owned
elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half only.
scan (`usage_scan`). Internal helpers shared only within the persist TU family live
in `persist_internal.h`. The pure orphan computation is owned elsewhere
(`core/reclaim`), the pure usage wire elsewhere again (`core/wire`), and every
tracking *decision* by `core/tracking`'s authority — this directory is the
REAPER/filesystem-facing half only, and it gathers rather than decides.
## Invariants
@@ -18,34 +19,37 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half
(orphan count, reclaimed size, and — for a small set — the files); actual
deletion is a confirmed second step. No periodic/background sweep.
- **Referenced-set is the union across ALL banks, pool included**, further unioned
(pS-usage) with every live instance's held paths via `usage_scan`
`prune_reconcile::mergeReferenced`. A file is an orphan iff no bank AND no live
instance references it.
with every live instance's held paths — supplied by `tracking::pruneProtection`,
never assembled here — via `prune_reconcile::mergeReferenced`. A file is an orphan
iff no bank AND no live instance references it.
- **Safest platform deletion available.** Trash-preferred, unlink fallback — Windows
routes through `SHFileOperationW` (`FOF_ALLOWUNDO`, verified against SDK
10.0.26100); macOS/Linux fall back to unlink (no portable SWELL trash surface).
`prune_fs` is the only module that calls this.
- **Manual, explicit trigger only** — a bindable action + a `bank_panel` button,
never a silent background sweep.
- **Instance-usage fail-safe (pS-usage):** a capture held by any live ReaSampler
9000 instance can never be deleted by prune. If any `rsusage_*` record is
unreadable or ambiguous, prune **aborts entirely and deletes nothing**
over-protection is the accepted residual, under-protection is a data-loss bug.
`usage_scan` decodes every `rsusage_*` key, enumerates every ReaSampler 9000 FX
instance (all tracks incl. master, normal + record/input chains, containers
recursively, take FX), and folds via the pure `sample_usage::foldUsageRecords` /
`usageHeldPaths` (a record with no live instance context protects all its paths —
identity-failure net, never degrades toward delete). This is read-only at
prune-scan time: `usage_scan` writes no ext-state.
- **`PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`**; dry-run,
orphan-set, and reclaim each independently abort (delete nothing) when usage
state is unreadable. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on this flag
and prints the offending keys.
- **Instance-usage fail-safe:** a capture held by any live ReaSampler 9000 instance
can never be deleted by prune. `usage_scan` decodes every `rsusage_*` key,
enumerates every ReaSampler 9000 FX instance (all tracks incl. master, normal +
record/input chains, containers recursively, take FX), and folds via the pure
`sample_usage::foldUsageRecords`. Read-only at prune-scan time: `usage_scan` writes
no ext-state.
- **A malformed tracking ledger is Unreadable, never "empty".** `ext_state_io` keeps
the `LedgerStatus` alongside the ledger, and `saveToActiveProject` SKIPS the
`owned_files` write while it is `Unreadable` — replacing a corrupt blob would
destroy the only record of every file created before the corruption, silently
turning them into permanently unreclaimable foreign files. Captures made during
such a session are recorded in memory but not persisted; they degrade to foreign
(untouchable), which is the safe direction.
- **`PruneReport` carries `blockedByTracking` + `ledgerUnreadable` /
`unreadableUsageKeys`**; dry-run, orphan-set, and reclaim each independently abort
(delete nothing) on a block. `BANK_PRUNE_FOLDER` (in `shell/actions`) halts on the
flag and prints whichever blockers fired.
## Modules
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, `OwnedManifest` JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `prune_fs` hosts the prune dry-run / full-set orphan queries (supplying `referencedPaths()` + `owned().paths()` to the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. **pS-usage:** the prune scan unions instance usage via `usage_scan`; `PruneReport` carries `abortedUnreadableUsage` + `offendingUsageKeys`; dry-run / orphan-set / reclaim each independently abort (delete nothing) when usage state is unreadable.
- `usage_scan` — extension-side prune-scan shell (pS-usage): at prune-scan time, enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and folds with `sample_usage::foldUsageRecords` / `usageHeldPaths` to produce the set of held paths — or `abortPrune` when any record is unreadable (fail-safe: an unreadable record may protect anything, so the prune halts). Feeds `prune_reconcile::mergeReferenced`. Read-only: writes no ext-state.
- `shell/persist` (`session` / `ext_state_io` / `prune_fs`) — the persist seam, split by responsibility (Q-W5; the former `persist.cpp` god-TU and its `persist.h` compatibility umbrella are both retired — callers include `shell/persist/session.h` / `ext_state_io.h` directly). `session` owns the `ReaSamplerSession` lifecycle: the poll identity-transition detection (load / Save-As / forked sibling / recycled pointer) and the `projectconfig`-driven deferred undo/redo reload. `ext_state_io` owns project ext state (`SetProjExtState`/`GetProjExtState`, namespace `"reasampler"`) ↔ `BankBook` JSON, `ViewModeModel` JSON, `TailSetting` JSON, the tracking ledger JSON, the writing-version stamp, GUID minting, and bank-folder relocation. `session` additionally owns `recordCreated`**the one writer of a birth record**, called at the same point the `Sample` is added, deriving lineage from that `Sample`'s own provenance. `prune_fs` hosts the prune dry-run / full-set orphan queries (gathering `referencedPaths()` plus `tracking::pruneProtection`'s two inputs for the `prune_reconcile` pure core) and is **the single file-deletion authority over user files in the bank folder** (`deleteOrphanFile` via `SHFileOperationW`); nothing else in the system deletes bank-folder bytes. Dry-run / orphan-set / reclaim each independently abort (delete nothing) when the authority reports a block.
- `usage_scan` — extension-side prune-scan shell: enumerates every `rsusage_*` ext-state key, decodes each `sample_usage` wire record, enumerates every ReaSampler 9000 FX instance across all tracks + master / normal + record chains / containers (recursive) / take FX, and returns the pure `sample_usage::foldUsageRecords` result verbatim. One of the two inputs `tracking::pruneProtection` reads; it decides nothing itself. Read-only: writes no ext-state.
- `persist_internal.h` — internal-only shared helpers for the persist TU family (`session` / `ext_state_io` / `prune_fs`); included only by those three TUs, never a public seam (mirror of the panel's `panel_state.h` / the editor's `editor_internal.h` precedent). Holds the former anonymous-namespace helpers more than one split TU needs (active-project + `.rpp` path lookup, project-dir derivation, growing `GetProjExtState` read, project-GUID minting, bank-folder relocation) — all definitions live in `ext_state_io.cpp`. REAPER-free header: the project handle crosses this seam as the same opaque `void*` the public `session` header already uses.
## Gotchas
@@ -55,6 +59,7 @@ elsewhere (`core/wire`) — this directory is the REAPER/filesystem-facing half
file.
- The pure usage wire (`sample_usage`: `UsageRecord`, `planUsagePublish`,
`foldUsageRecords`/`usageHeldPaths`, `identityMatches`) is documented under
`core/wire`, not here.
`core/wire`, and the ledger + the two consumer answers under `core/tracking`
neither belongs in this file.
- `persist_internal.h` is an internal seam, not a public header — do not include it
outside `session.cpp` / `ext_state_io.cpp` / `prune_fs.cpp`.
+25 -19
View File
@@ -161,10 +161,15 @@ bool ReaSamplerSession::saveToActiveProject() {
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtTailKey, tailJson.c_str());
// Written on every save so the manifest and the bank stay in lockstep on disk.
const std::string ownedJson = owned_.serialize();
// Written on every save so the ledger and the bank stay in lockstep on disk
// EXCEPT over a blob we could not read: replacing it would destroy the only
// record of every file created before the corruption, silently turning them
// into permanently unreclaimable foreign files.
if (trackingStatus_ != tracking::LedgerStatus::Unreadable) {
const std::string ledgerJson = tracking_.serialize();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ownedJson.c_str());
kProjExtOwnedKey, ledgerJson.c_str());
}
// stampVersion() (not appVersion()) is the numeric triple only, no "-beta"
// suffix, so the stamp is byte-identical to stable regardless of channel
@@ -229,21 +234,20 @@ capture::TailSetting loadTailSetting(ReaProject* proj) {
return *loaded;
}
// Absent/empty key -> empty manifest. Malformed JSON warns and falls back to
// empty; prune then attributes nothing until the next capture rebuilds it —
// degrades safety, never correctness.
model::OwnedFileManifest loadOwnedManifest(ReaProject* proj) {
if (!proj) return model::OwnedFileManifest{};
const std::string ownedJson =
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey);
if (ownedJson.empty()) return model::OwnedFileManifest{}; // no stored manifest -> empty
std::optional<model::OwnedFileManifest> loaded =
model::OwnedFileManifest::deserialize(ownedJson);
if (!loaded) {
ShowConsoleMsg("ReaSampler: stored owned-file manifest is malformed -- ignoring.\n");
return model::OwnedFileManifest{};
// Absent/empty key -> Fresh (a new project, or a bank predating the ledger).
// Malformed -> Unreadable, which halts the prune and suppresses the next write
// rather than degrading to an empty ledger that looks like "nothing was ever
// created".
tracking::LedgerLoad loadOriginLedger(ReaProject* proj) {
if (!proj) return tracking::LedgerLoad{};
tracking::LedgerLoad load = tracking::loadLedger(
getProjExtStateString(proj, projExtNamespace(), kProjExtOwnedKey));
if (load.status == tracking::LedgerStatus::Unreadable) {
ShowConsoleMsg("ReaSampler: the stored file-tracking ledger is malformed. Prune "
"is halted for this project and the stored value is left intact "
"for recovery.\n");
}
return std::move(*loaded);
return load;
}
} // namespace
@@ -255,13 +259,15 @@ void ReaSamplerSession::loadFromProject(void* proj, const std::string& projectDi
// consumeLoadSignal() on the same tick.
loadPending_ = true;
// view_/tail_/owned_ are all restored on EVERY load path: switching to a
// view_/tail_/tracking_ are all restored on EVERY load path: switching to a
// project with no stored state must reset to default, never inherit the
// previous project's. An undo/redo reload must re-read the restored
// values so they match the rolled-back state.
view_ = loadViewModel(static_cast<ReaProject*>(proj));
tail_ = loadTailSetting(static_cast<ReaProject*>(proj));
owned_ = loadOwnedManifest(static_cast<ReaProject*>(proj));
tracking::LedgerLoad ledger = loadOriginLedger(static_cast<ReaProject*>(proj));
trackingStatus_ = ledger.status;
tracking_ = std::move(ledger.ledger);
// An absent stamp classifies as PreVersioning, a malformed one as Unknown
// — both silent. proj == nullptr -> "" -> default.
+42 -35
View File
@@ -33,10 +33,11 @@
#include "shell/persist/persist_internal.h"
#include "shell/persist/session.h"
#include "shell/persist/usage_scan.h" // liveInstanceHeldPaths — instance holds join `referenced`
#include "shell/persist/usage_scan.h" // scanInstanceUsage — one of the authority's two inputs
#include "core/capture/capture_paths.h" // resolveBankFile / bankRelativeForName / kBankSubfolder
#include "core/reclaim/prune_reconcile.h" // the pure orphan decision + report tallies
#include "core/tracking/tracking_authority.h" // the one protection answer
namespace reasampler {
@@ -62,21 +63,22 @@ constexpr std::size_t kPruneListDisplayCap = 64;
// active/saved project, no project dir, or no folder yet.
// * orphans — the full orphan set, untruncated. The pure core decides.
// * sizeByRel — per-orphan on-disk byte size (0 when it could not be stat'd).
// * abortedUnreadableUsage — true iff a present rsusage_* record could not
// be read/decoded: `orphans` is left EMPTY, the prune must
// halt rather than proceed with degraded protection.
// * blocked — true iff the tracking authority could not answer: `orphans`
// is left EMPTY, the prune must halt rather than proceed with
// degraded protection.
struct PruneScan {
std::string bankDirAbs;
std::vector<std::string> orphans;
std::unordered_map<std::string, std::uint64_t> sizeByRel;
bool abortedUnreadableUsage = false;
std::vector<std::string> offendingUsageKeys; // non-empty iff abortedUnreadableUsage
bool blocked = false;
bool ledgerUnreadable = false;
std::vector<std::string> unreadableUsageKeys;
};
// Non-throwing: readActiveProject + resolveBankFile are pure/string; every filesystem
// call below uses an error_code form so no std::filesystem_error crosses REAPER's C ABI.
PruneScan scanPruneOrphans(const BankBook& book,
const model::OwnedFileManifest& owned) {
PruneScan scanPruneOrphans(const BankBook& book, const tracking::OriginLedger& ledger,
tracking::LedgerStatus ledgerStatus) {
PruneScan scan;
std::string rppPath;
@@ -97,7 +99,7 @@ PruneScan scanPruneOrphans(const BankBook& book,
// Enumerate into project-relative paths spelled the SAME way the capture
// path spells them, so the pure core's exact-string match lines up with
// referencedPaths() and the manifest. Non-recursive: the bank folder is
// referencedPaths() and the ledger. Non-recursive: the bank folder is
// flat. Manual iterator form (it.increment(ec)) keeps the loop
// non-throwing on a mid-iteration failure.
std::vector<std::string> present;
@@ -115,28 +117,30 @@ PruneScan scanPruneOrphans(const BankBook& book,
scan.sizeByRel[rel] = sz_ec ? 0 : static_cast<std::uint64_t>(sz);
}
// The decision lives in the pure core — read-only inputs from the book and
// manifest. referencedPaths() unions across the whole book; the referenced
// set additionally unions every LIVE ReaSampler 9000 instance's held
// captures (usage_scan + sample_usage decide liveness) — a capture any
// live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. liveInstanceHeldPaths is
// read-only; this shell only enumerates, resolves, and stats.
// The decision lives in the pure core; the tracking authority supplies both of
// its tracking-derived inputs so the prune and the resample can never disagree
// about what is protected. referencedPaths() unions across the whole book; the
// authority's heldPaths adds every live instance's captures on top — a capture
// any live instance holds can never be an orphan, even if its bank entry was
// deleted while the instance kept its ref. Read-only throughout: this shell
// only enumerates, resolves, and stats.
scan.bankDirAbs = bankDir;
const UsageScanResult usage = liveInstanceHeldPaths(proj);
if (usage.abortPrune) {
// FAIL-SAFE ABORT: a present rsusage_* record could not be read/decoded,
// so the protected set is unknowable. Compute NO orphans — every
// downstream consumer then deletes nothing. The key names let the
// action tell the user which keys to recover.
scan.abortedUnreadableUsage = true;
scan.offendingUsageKeys = usage.offendingKeys;
const wire::UsageFoldResult usage = scanInstanceUsage(proj);
const tracking::TrackingState state{ledgerStatus, ledger, usage};
const tracking::ProtectionAnswer protection = tracking::pruneProtection(state);
if (protection.blocked) {
// FAIL-SAFE ABORT: the protected set is unknowable. Compute NO orphans —
// every downstream consumer then deletes nothing. The blockers let the
// action tell the user what to recover.
scan.blocked = true;
scan.ledgerUnreadable = protection.ledgerUnreadable;
scan.unreadableUsageKeys = protection.unreadableUsageKeys;
return scan;
}
scan.orphans = reclaim::pruneOrphans(
present,
reclaim::mergeReferenced(book.referencedPaths(), usage.heldPaths),
owned.paths());
reclaim::mergeReferenced(book.referencedPaths(), protection.heldPaths),
protection.ownedPaths);
return scan;
}
@@ -199,19 +203,22 @@ bool deleteOrphanFile(const std::string& absPath, bool& outUsedTrash,
} // namespace
reclaim::PruneReport ReaSamplerSession::pruneDryRun() const {
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
reclaim::PruneReport report =
reclaim::buildPruneReport(scan.orphans, scan.sizeByRel, kPruneListDisplayCap);
// Surface the unreadable-usage abort so the action halts with an explicit
// message instead of reporting "no orphaned files" — the count IS zero,
// but the user must know the prune refused to run.
report.abortedUnreadableUsage = scan.abortedUnreadableUsage;
report.offendingUsageKeys = scan.offendingUsageKeys;
// Surface the block so the action halts with an explicit message instead of
// reporting "no orphaned files" — the count IS zero, but the user must know
// the prune refused to run.
report.blockedByTracking = scan.blocked;
report.ledgerUnreadable = scan.ledgerUnreadable;
report.unreadableUsageKeys = scan.unreadableUsageKeys;
return report;
}
std::vector<std::string> ReaSamplerSession::pruneOrphanSet() const {
return scanPruneOrphans(book_, owned_).orphans; // full set, untruncated
// Full set, untruncated; empty on a block, so a caller that skipped the report
// still confirms nothing.
return scanPruneOrphans(book_, tracking_, trackingStatus_).orphans;
}
reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
@@ -222,10 +229,10 @@ reclaim::PruneDeletionResult ReaSamplerSession::pruneReclaim(
// targets exactly `confirmed ∩ freshOrphans`, so a file that vanished or
// became referenced between confirm and delete is skipped, and a newly-
// appeared orphan not in `confirmed` is never swept. If this fresh scan
// hits an unreadable usage record it aborts with an EMPTY orphan set, so
// hits unreadable tracking state it aborts with an EMPTY orphan set, so
// the plan below intersects to empty and nothing is deleted — the
// fail-safe holds even in the confirm-to-delete window.
const PruneScan scan = scanPruneOrphans(book_, owned_);
const PruneScan scan = scanPruneOrphans(book_, tracking_, trackingStatus_);
if (scan.bankDirAbs.empty()) return result; // no project / no folder -> nothing
const std::vector<std::string> plan =
+13
View File
@@ -46,6 +46,19 @@ using persist_detail::projectDirOf;
using persist_detail::readActiveProject;
using persist_detail::relocateBankFolder;
void ReaSamplerSession::recordCreated(const model::Sample& sample,
tracking::OriginKind kind) {
tracking::OriginRecord rec;
rec.relativePath = sample.relativePath;
rec.kind = kind;
rec.sampleId = sample.id;
// The Sample's own provenance is where the parent was resolved; reading it here
// rather than re-deriving keeps one source for the lineage fact. Absent
// provenance is a root capture, not a gap.
if (sample.provenance) rec.parentSampleId = sample.provenance->parentSampleId;
tracking_.record(rec);
}
bool ReaSamplerSession::consumeLoadSignal() {
const bool pending = loadPending_;
loadPending_ = false;
+24 -12
View File
@@ -26,8 +26,8 @@
#include "core/capture/tail_control.h"
#include "core/model/bank_book.h"
#include "core/model/bank_model.h"
#include "core/model/owned_manifest.h"
#include "core/reclaim/prune_reconcile.h"
#include "core/tracking/origin_ledger.h"
#include "core/version/app_version.h"
#include "core/view/view_mode_model.h"
@@ -71,9 +71,18 @@ public:
capture::TailSetting& tail() { return tail_; }
const capture::TailSetting& tail() const { return tail_; }
// Project-relative files the capture path itself created; prune consumes it.
model::OwnedFileManifest& owned() { return owned_; }
const model::OwnedFileManifest& owned() const { return owned_; }
// Birth records for the files the system itself created. Read as a PAIR —
// the ledger alone cannot say whether an absent record means never-recorded
// or unreadable, and the two demand opposite treatment. This is the reach
// any consumer outside persist uses to build a tracking::TrackingState.
const tracking::OriginLedger& tracking() const { return tracking_; }
tracking::LedgerStatus trackingStatus() const { return trackingStatus_; }
// Record a system-created file at the moment it exists — the ONLY way a
// birth record is written, so lineage can never be backfilled from a later
// guess. Lineage is read off the Sample's own provenance, the same act that
// stamped it, so the two cannot disagree. A repeat path is a no-op.
void recordCreated(const model::Sample& sample, tracking::OriginKind kind);
// The version that last wrote the active project: PreVersioning (no
// stamp), Unknown (malformed), or Stamped.
@@ -93,11 +102,10 @@ public:
// a persist happened, so a caller can skip an undo block when nothing was written.
bool saveToActiveProject();
// Report-only prune dry-run: feeds the pure core with (present,
// referenced, owned), where `referenced` = book references union every
// live instance's held captures (usage_scan + sample_usage decide
// liveness). FAIL-SAFE: an unreadable usage record sets
// abortedUnreadableUsage with an EMPTY orphan set. Read-only throughout.
// Report-only prune dry-run: feeds the pure core with (present, referenced,
// owned) — `present` from the folder enumeration, the other two from the
// tracking authority. FAIL-SAFE: tracking state the authority cannot read
// sets blockedByTracking with an EMPTY orphan set. Read-only throughout.
reclaim::PruneReport pruneDryRun() const;
// The full (untruncated) orphan set, same compute as pruneDryRun. The
@@ -110,7 +118,7 @@ public:
// file that vanished or became referenced since confirm is skipped, and
// an orphan the user did not see is never swept. Trash-preferred
// (Windows Recycle Bin; unlink elsewhere). Does not modify the book or
// OwnedFileManifest, writes no ext-state. No-ops when nothing to delete;
// the ledger, writes no ext-state. No-ops when nothing to delete;
// does not prompt.
reclaim::PruneDeletionResult pruneReclaim(
const std::vector<std::string>& confirmed) const;
@@ -144,7 +152,11 @@ private:
BankBook book_;
ViewModeModel view_; // reset to default on a project with no stored view_state
capture::TailSetting tail_; // reset to default (None / 2s) with no stored tail key
model::OwnedFileManifest owned_; // reset to empty/stored on EVERY load path, never inherited
tracking::OriginLedger tracking_; // reset to empty/stored on EVERY load path, never inherited
// Unreadable is sticky for the project's session: it halts the prune AND
// suppresses the ledger write, so a corrupt blob survives for recovery
// instead of being silently replaced by a ledger missing every earlier file.
tracking::LedgerStatus trackingStatus_ = tracking::LedgerStatus::Fresh;
version::WritingVersion writingVersion_; // recovered per load; PreVersioning default
std::int64_t bankGeneration_ = 0; // recovered per load (absent -> 0); monotonic
@@ -159,7 +171,7 @@ private:
bool reloadRequested_ = false; // raised by requestReload; drained by poll
// Load the book from `proj`'s ext state (`banks`, else legacy
// `bank_index` migrated into the pool); also restores view_/tail_/owned_.
// `bank_index` migrated into the pool); also restores view_/tail_/tracking_.
void loadFromProject(void* proj, const std::string& projectDir);
};
+9 -18
View File
@@ -178,9 +178,9 @@ std::optional<std::string> readExtStateValue(ReaProject* proj, const char* key)
} // namespace
UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
UsageFoldResult scanInstanceUsage(void* projOpaque) {
ReaProject* proj = static_cast<ReaProject*>(projOpaque);
UsageScanResult result;
UsageFoldResult result;
// Enumerate rsusage_* keys, then read+decode via the growing reader
// (EnumProjExtState's fixed val buffer could truncate a large record).
@@ -199,19 +199,14 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
}
if (usageKeys.empty()) return result; // no instance ever published — skip the scan
std::vector<std::optional<UsageRecord>> decoded;
std::vector<DecodedUsage> decoded;
decoded.reserve(usageKeys.size());
for (std::size_t ki = 0; ki < usageKeys.size(); ++ki) {
const std::string& key = usageKeys[ki];
for (const std::string& key : usageKeys) {
const std::optional<std::string> value = readExtStateValue(proj, key.c_str());
if (!value) {
decoded.push_back(std::nullopt); // unreadable -> abort (pure fold)
result.offendingKeys.push_back(key);
continue;
}
const std::optional<UsageRecord> rec = decodeUsageRecord(*value);
if (!rec) result.offendingKeys.push_back(key);
decoded.push_back(rec); // undecodable nullopt -> abort
// Unreadable or undecodable both land as a nullopt record; the pure fold
// turns either into the abort and names the key.
decoded.push_back(DecodedUsage{
key, value ? decodeUsageRecord(*value) : std::nullopt});
}
// Enumerate live ReaSampler 9000 hosts; a track needs only one instance to
@@ -254,11 +249,7 @@ UsageScanResult liveInstanceHeldPaths(void* projOpaque) {
// The pure fold decides: abort on any unreadable record; protect-all when
// zero instances were identified; otherwise the per-record liveness rule.
const UsageFoldResult fold = foldUsageRecords(decoded, liveTrackGuids, anyLive);
result.abortPrune = fold.abortPrune;
result.heldPaths = fold.heldPaths;
if (!result.abortPrune) result.offendingKeys.clear(); // only meaningful on abort
return result;
return foldUsageRecords(decoded, liveTrackGuids, anyLive);
}
} // namespace reasampler
+8 -29
View File
@@ -1,45 +1,24 @@
#pragma once
// usage_scan — the extension-side shell of the instance-usage seam (see
// sample_usage.h for the pure core and fail-safe folds). At prune-scan time it
// answers one question: which project-relative bank paths are held by a live
// ReaSampler 9000 instance — or must the prune abort because a usage record
// could not be read?
//
// Three reads, no writes: (1) enumerate every "rsusage_<guid>" key and decode
// each record — unreadable/undecodable folds to abortPrune; (2) enumerate
// usage_scan — the extension-side shell of the instance-usage facet (see
// sample_usage.h for the pure core and its fail-safe folds). Three reads, no
// writes: enumerate every "rsusage_<guid>" key and decode each record; enumerate
// every ReaSampler 9000 FX instance (all tracks incl. master, normal +
// record/input chains, containers recursively, take FX) via
// sample_usage::identityMatches; (3) fold with the pure liveness rule — zero
// instances identified anywhere protects every record's paths.
// sample_usage::identityMatches; fold with the pure liveness rule.
//
// Feeds prune_reconcile::mergeReferenced in persist's scanPruneOrphans — a
// held capture can never be an orphan. abortPrune propagates to the action,
// which halts.
// The result is one of the two inputs tracking_authority reads — this shell
// gathers, it decides nothing.
//
// REAPER-facing: the .cpp includes reaper_plugin_functions.h WITHOUT
// REAPERAPI_IMPLEMENT (main.cpp owns the API pointers). The header stays
// REAPER-free (`proj` is the opaque ReaProject* passed as void*).
#include <string>
#include <vector>
#include "core/wire/sample_usage.h"
namespace reasampler {
// When abortPrune is true, a present rsusage_* record could not be read or
// decoded — the caller MUST halt the prune. offendingKeys names the exact
// keys that triggered the abort, so the action can print them for recovery
// (clear via ReaScript: reaper.SetProjExtState(0, "reasampler", "<key>", "")).
// heldPaths on abort is the protect-all set — a belt-and-braces fallback; the
// abort flag is authoritative. Otherwise heldPaths is every project-relative
// path held by a live instance, de-duped, in record order.
struct UsageScanResult {
bool abortPrune = false;
std::vector<std::string> offendingKeys; // non-empty iff abortPrune
std::vector<std::string> heldPaths;
};
// Scan `proj` (nullptr = active project). READ-ONLY: no ext-state write, no project
// mutation.
UsageScanResult liveInstanceHeldPaths(void* proj);
wire::UsageFoldResult scanInstanceUsage(void* proj);
} // namespace reasampler
+274
View File
@@ -0,0 +1,274 @@
// Standalone tests for reasampler::tracking::OriginLedger — no REAPER, no framework.
//
// The record family behind file tracking. Covers: the relative-paths-only invariant,
// dedup, insertion order, the JSON round-trip (incl. a golden byte literal), the
// no-backfill rule, the legacy path-only lift, and the three-way Fresh /
// Loaded / Unreadable classification that keeps never-recorded apart from
// unreadable.
#include "../src/core/tracking/origin_ledger.h"
#include <cstdio>
#include <string>
using namespace reasampler::tracking;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
static OriginRecord rec(const std::string& path, OriginKind kind,
const std::string& id = "", const std::string& parent = "") {
OriginRecord r;
r.relativePath = path;
r.kind = kind;
r.sampleId = id;
r.parentSampleId = parent;
return r;
}
// --- empty ledger ------------------------------------------------------------
static void testEmptyLedger() {
OriginLedger l;
CHECK(l.empty());
CHECK(l.size() == 0);
CHECK(l.ownedPaths().empty());
CHECK(!l.contains("anything.wav"));
CHECK(l.find("anything.wav") == nullptr);
const std::string json = l.serialize();
auto back = OriginLedger::deserialize(json);
CHECK(back.has_value());
CHECK(*back == l);
CHECK(back->empty());
}
// --- the relative-paths-only invariant ---------------------------------------
static void testRejectsEmptyAndAbsolutePaths() {
OriginLedger l;
CHECK(l.record(rec("", OriginKind::Capture)) == RecordResult::RejectedEmptyPath);
CHECK(l.record(rec("/abs/a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("\\\\server\\share\\a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("C:/bank/a.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.record(rec("C:rel.wav", OriginKind::Capture)) ==
RecordResult::RejectedAbsolutePath);
CHECK(l.empty()); // no mutation on any rejection
CHECK(l.record(rec("reasampler_bank/a.wav", OriginKind::Capture)) ==
RecordResult::Recorded);
CHECK(l.size() == 1);
}
// --- dedup + insertion order -------------------------------------------------
static void testDedupPreservesInsertionOrder() {
OriginLedger l;
CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::Recorded);
CHECK(l.record(rec("a.wav", OriginKind::Ingest)) == RecordResult::Recorded);
CHECK(l.record(rec("b.wav", OriginKind::Capture)) == RecordResult::AlreadyPresent);
const std::vector<std::string> paths = l.ownedPaths();
CHECK(paths.size() == 2);
CHECK(paths[0] == "b.wav"); // insertion order, not sorted
CHECK(paths[1] == "a.wav");
}
// --- lineage is written at birth and NEVER backfilled ------------------------
// A second record() for the same path must leave the stored record untouched, so a
// later, less-informed (or differently-informed) writer can never rewrite history.
static void testLineageIsNeverBackfilled() {
OriginLedger l;
CHECK(l.record(rec("child.wav", OriginKind::Resample, "S-child", "S-parent")) ==
RecordResult::Recorded);
// A later write with different lineage is refused outright.
CHECK(l.record(rec("child.wav", OriginKind::Capture, "S-other", "S-wrong")) ==
RecordResult::AlreadyPresent);
const OriginRecord* stored = l.find("child.wav");
CHECK(stored != nullptr);
CHECK(stored->kind == OriginKind::Resample);
CHECK(stored->sampleId == "S-child");
CHECK(stored->parentSampleId == "S-parent");
// The same holds in the other direction: a never-recorded (Unknown) record is
// not upgraded by a later lineage-bearing write.
OriginLedger legacy;
CHECK(legacy.record(rec("old.wav", OriginKind::Unknown)) == RecordResult::Recorded);
CHECK(legacy.record(rec("old.wav", OriginKind::Resample, "S1", "S0")) ==
RecordResult::AlreadyPresent);
CHECK(legacy.find("old.wav")->parentSampleId.empty());
}
// --- lineage present at creation, queryable immediately ----------------------
// The no-silent-gaps property at the record layer: nothing intervenes between
// record() and a successful find().
static void testLineageQueryableImmediatelyAfterRecording() {
OriginLedger l;
l.record(rec("bake-1.wav", OriginKind::Resample, "S-1", "S-src"));
const OriginRecord* r = l.find("bake-1.wav");
CHECK(r != nullptr);
CHECK(r->parentSampleId == "S-src");
// A second creation immediately after does not disturb the first.
l.record(rec("bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
CHECK(l.find("bake-1.wav")->parentSampleId == "S-src");
CHECK(l.find("bake-2.wav")->parentSampleId == "S-1");
CHECK(l.ownedPaths().size() == 2);
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTripWithLineage() {
OriginLedger l;
l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
l.record(rec("reasampler_bank/c.wav", OriginKind::Ingest, "S-c"));
l.record(rec("reasampler_bank/d.wav", OriginKind::Recapture, "S-d", "S-a"));
auto back = OriginLedger::deserialize(l.serialize());
CHECK(back.has_value());
CHECK(*back == l);
}
// Metacharacters must survive: a path or id is written through the escaper, so a
// quote or backslash cannot break the surrounding document.
static void testRoundTripWithJsonMetacharacters() {
OriginLedger l;
l.record(rec("bank/quote\".wav", OriginKind::Capture, "id\\with\\slashes"));
l.record(rec("bank/tab\tnewline\n.wav", OriginKind::Capture, "", "par\"ent"));
auto back = OriginLedger::deserialize(l.serialize());
CHECK(back.has_value());
CHECK(*back == l);
}
// Golden byte literal: pins the EXACT serialized bytes, so a format drift both
// writer and reader agree on still fails here.
static void testSerializeGoldenLiteral() {
OriginLedger l;
l.record(rec("reasampler_bank/a.wav", OriginKind::Capture, "S-a"));
l.record(rec("reasampler_bank/b.wav", OriginKind::Resample, "S-b", "S-a"));
const std::string expected =
"{\"v\":2,\"records\":["
"{\"path\":\"reasampler_bank/a.wav\",\"kind\":1,\"sample\":\"S-a\",\"parent\":\"\"},"
"{\"path\":\"reasampler_bank/b.wav\",\"kind\":4,\"sample\":\"S-b\",\"parent\":\"S-a\"}"
"]}";
CHECK(l.serialize() == expected);
}
// --- malformed input ---------------------------------------------------------
static void testMalformedParsesToNullopt() {
CHECK(!OriginLedger::deserialize("").has_value());
CHECK(!OriginLedger::deserialize("not json").has_value());
CHECK(!OriginLedger::deserialize("{\"v\":2,\"records\":[").has_value());
CHECK(!OriginLedger::deserialize("{\"records\":[{\"path\":]}").has_value());
CHECK(!OriginLedger::deserialize("[]").has_value());
// Unknown keys are tolerated (forward-compat), unknown kind values degrade to
// Unknown rather than failing the whole ledger — a vocabulary gap must not halt
// the prune.
auto tolerated = OriginLedger::deserialize(
"{\"v\":3,\"future\":{\"x\":1},\"records\":[{\"path\":\"a.wav\",\"kind\":99}]}");
CHECK(tolerated.has_value());
CHECK(tolerated->size() == 1);
CHECK(tolerated->find("a.wav")->kind == OriginKind::Unknown);
}
// A hand-edited or corrupt blob cannot smuggle an absolute or duplicate path past
// the load — record() re-asserts the invariants on the way in.
static void testLoadReassertsInvariants() {
auto loaded = OriginLedger::deserialize(
"{\"v\":2,\"records\":["
"{\"path\":\"a.wav\",\"kind\":1},"
"{\"path\":\"/etc/passwd\",\"kind\":1},"
"{\"path\":\"a.wav\",\"kind\":2},"
"{\"path\":\"\",\"kind\":1}]}");
CHECK(loaded.has_value());
CHECK(loaded->size() == 1);
CHECK(loaded->contains("a.wav"));
CHECK(!loaded->contains("/etc/passwd"));
CHECK(loaded->find("a.wav")->kind == OriginKind::Capture); // first wins
}
// --- the pre-existing bank lifts in ------------------------------------------
// A legacy path-only manifest keeps every protection it had (the paths stay owned,
// so prune still reclaims them and still refuses hand-dropped files) and gains NO
// invented lineage.
static void testLegacyPathOnlyManifestLiftsIn() {
const std::string legacy =
"{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}";
LedgerLoad load = loadLedger(legacy);
CHECK(load.status == LedgerStatus::Loaded);
CHECK(load.ledger.size() == 2);
const std::vector<std::string> paths = load.ledger.ownedPaths();
CHECK(paths.size() == 2);
CHECK(paths[0] == "reasampler_bank/a.wav");
CHECK(paths[1] == "reasampler_bank/b.wav");
for (const OriginRecord& r : load.ledger.records()) {
CHECK(r.kind == OriginKind::Unknown); // no invented origin
CHECK(r.sampleId.empty());
CHECK(r.parentSampleId.empty()); // no spurious lineage
}
// Re-saving upgrades the shape without losing or inventing anything.
auto resaved = OriginLedger::deserialize(load.ledger.serialize());
CHECK(resaved.has_value());
CHECK(*resaved == load.ledger);
}
// --- never-recorded vs unreadable --------------------------------------------
// The two absences demand opposite treatment, so they must be distinguishable at
// the load boundary — this is the only place that distinction is made.
static void testFreshLoadedUnreadableAreDistinct() {
// Absent key: an empty string is not valid JSON, so only the loader can tell
// "no key yet" from "corrupt value".
CHECK(loadLedger("").status == LedgerStatus::Fresh);
CHECK(loadLedger("").ledger.empty());
// A well-formed empty ledger is Loaded, not Fresh — it is a positive record
// that nothing has been created, not an absence.
const LedgerLoad emptyButStored = loadLedger(OriginLedger{}.serialize());
CHECK(emptyButStored.status == LedgerStatus::Loaded);
CHECK(emptyButStored.ledger.empty());
const LedgerLoad broken = loadLedger("{\"records\":[{oops");
CHECK(broken.status == LedgerStatus::Unreadable);
CHECK(broken.ledger.empty()); // never a partial value
OriginLedger real;
real.record(rec("a.wav", OriginKind::Capture, "S-a"));
const LedgerLoad good = loadLedger(real.serialize());
CHECK(good.status == LedgerStatus::Loaded);
CHECK(good.ledger == real);
}
int main() {
testEmptyLedger();
testRejectsEmptyAndAbsolutePaths();
testDedupPreservesInsertionOrder();
testLineageIsNeverBackfilled();
testLineageQueryableImmediatelyAfterRecording();
testRoundTripWithLineage();
testRoundTripWithJsonMetacharacters();
testSerializeGoldenLiteral();
testMalformedParsesToNullopt();
testLoadReassertsInvariants();
testLegacyPathOnlyManifestLiftsIn();
testFreshLoadedUnreadableAreDistinct();
if (g_fail == 0) std::printf("origin_ledger: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}
-185
View File
@@ -1,185 +0,0 @@
// Standalone tests for reasampler::OwnedFileManifest — no REAPER, no framework.
// The owned-file manifest seam (Phase B B-cap): a deduplicated, insertion-ordered
// set of project-relative files the capture path created, with JSON round-trip.
//
// Covers (brief-named): JSON round-trip, dedup of repeated adds, the empty manifest.
// Plus: the relative-paths-only invariant (reject empty / absolute), contains()
// semantics, insertion-order preservation, malformed-parse -> nullopt (the persist
// shell's warn+fallback hinges on it), and round-trip of paths with JSON metacharacters.
#include "../src/core/model/owned_manifest.h"
#include <cstdio>
#include <string>
using namespace reasampler;
using namespace reasampler::model;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// --- empty manifest ----------------------------------------------------------
static void testEmptyManifest() {
OwnedFileManifest m;
CHECK(m.empty());
CHECK(m.size() == 0);
CHECK(m.paths().empty());
CHECK(!m.contains("anything.wav"));
// An empty manifest serializes to a well-formed shape and round-trips to empty.
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->empty());
}
// Golden byte-literal (Q-W1 follow-up): pins the EXACT serialized bytes for a
// small fixture (two paths), not just self-consistent re-serialization — a
// format drift that both writer and reader agree on would slip past the
// round-trip tests but not this. The format is frozen as-shipped; the literal
// below is the captured current output.
static void testSerializeGoldenLiteral() {
OwnedFileManifest m;
m.add("reasampler_bank/a.wav");
m.add("reasampler_bank/b.wav");
CHECK(m.serialize() == "{\"owned\":[\"reasampler_bank/a.wav\",\"reasampler_bank/b.wav\"]}");
}
// --- add / contains / order --------------------------------------------------
static void testAddAndContains() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/a.wav") == ManifestAddResult::Added);
CHECK(m.add("reasampler_bank/b.wav") == ManifestAddResult::Added);
CHECK(m.size() == 2);
CHECK(m.contains("reasampler_bank/a.wav"));
CHECK(m.contains("reasampler_bank/b.wav"));
CHECK(!m.contains("reasampler_bank/c.wav"));
// Exact-string match — not a prefix / substring match.
CHECK(!m.contains("reasampler_bank/a"));
CHECK(!m.contains("a.wav"));
// Insertion order is preserved (deterministic ext-state).
CHECK(m.paths().size() == 2);
CHECK(m.paths()[0] == "reasampler_bank/a.wav");
CHECK(m.paths()[1] == "reasampler_bank/b.wav");
}
// --- dedup of repeated adds --------------------------------------------------
static void testDedupRepeatedAdds() {
OwnedFileManifest m;
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::Added);
// A repeat capture of an identical request must not double-record the file.
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.add("reasampler_bank/take.wav") == ManifestAddResult::AlreadyPresent);
CHECK(m.size() == 1);
CHECK(m.paths().size() == 1);
}
// --- relative-paths-only invariant -------------------------------------------
static void testRejectsEmptyAndAbsolute() {
OwnedFileManifest m;
CHECK(m.add("") == ManifestAddResult::RejectedEmptyPath);
// Every absolute form bank_model rejects, the manifest rejects too.
CHECK(m.add("/abs/take.wav") == ManifestAddResult::RejectedAbsolutePath); // POSIX root
CHECK(m.add("\\\\host\\share\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* UNC */
CHECK(m.add("C:/bank/x.wav") == ManifestAddResult::RejectedAbsolutePath); // Win drive /
CHECK(m.add("C:\\bank\\x.wav") == ManifestAddResult::RejectedAbsolutePath); /* Win drive backslash */
CHECK(m.add("C:x.wav") == ManifestAddResult::RejectedAbsolutePath); // drive-relative
// A rejected add never mutates.
CHECK(m.empty());
CHECK(!m.contains("/abs/take.wav"));
}
// --- JSON round-trip ---------------------------------------------------------
static void testRoundTrip() {
OwnedFileManifest m;
m.add("reasampler_bank/one.wav");
m.add("reasampler_bank/two.wav");
m.add("reasampler_bank/three.wav");
const std::string json = m.serialize();
auto back = OwnedFileManifest::deserialize(json);
CHECK(back.has_value());
CHECK(*back == m);
// Order + membership survive.
CHECK(back->paths().size() == 3);
CHECK(back->paths()[0] == "reasampler_bank/one.wav");
CHECK(back->paths()[2] == "reasampler_bank/three.wav");
// serialize(deserialize(serialize(x))) is stable.
CHECK(back->serialize() == json);
}
// A path carrying JSON metacharacters must survive the escape/unescape round-trip.
static void testRoundTripEscaping() {
OwnedFileManifest m;
m.add("reasampler_bank/od\"d name.wav"); // embedded quote
m.add("reasampler_bank/back\\slash.wav"); // embedded backslash
m.add("reasampler_bank/tab\tafter.wav"); // control char
auto back = OwnedFileManifest::deserialize(m.serialize());
CHECK(back.has_value());
CHECK(*back == m);
CHECK(back->contains("reasampler_bank/od\"d name.wav"));
CHECK(back->contains("reasampler_bank/back\\slash.wav"));
CHECK(back->contains("reasampler_bank/tab\tafter.wav"));
}
// --- malformed / tolerant parse ----------------------------------------------
static void testMalformedParse() {
// The persist shell's warn+fallback hinges on nullopt for a corrupt blob.
CHECK(!OwnedFileManifest::deserialize("").has_value()); // empty string
CHECK(!OwnedFileManifest::deserialize("not json").has_value());
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[").has_value()); // unterminated array
CHECK(!OwnedFileManifest::deserialize("{\"owned\":[1,2]}").has_value()); // non-string element
CHECK(!OwnedFileManifest::deserialize("{\"owned\":\"x\"}").has_value()); // wrong value type
// An explicit empty array parses to an empty manifest.
auto empty = OwnedFileManifest::deserialize("{\"owned\":[]}");
CHECK(empty.has_value());
CHECK(empty->empty());
// An unknown sibling key is tolerated (forward-compat) — the owned array still loads.
auto fwd = OwnedFileManifest::deserialize(
"{\"future\":{\"nested\":[1,2]},\"owned\":[\"reasampler_bank/x.wav\"]}");
CHECK(fwd.has_value());
CHECK(fwd->size() == 1);
CHECK(fwd->contains("reasampler_bank/x.wav"));
// A stored blob cannot smuggle a duplicate or absolute path past the load-time
// invariant re-assertion (deserialize routes each element through add()).
auto dupe = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/x.wav\",\"reasampler_bank/x.wav\"]}");
CHECK(dupe.has_value());
CHECK(dupe->size() == 1);
auto absolute = OwnedFileManifest::deserialize(
"{\"owned\":[\"reasampler_bank/ok.wav\",\"/etc/evil.wav\"]}");
CHECK(absolute.has_value());
CHECK(absolute->size() == 1);
CHECK(absolute->contains("reasampler_bank/ok.wav"));
CHECK(!absolute->contains("/etc/evil.wav"));
}
int main() {
testSerializeGoldenLiteral();
testEmptyManifest();
testAddAndContains();
testDedupRepeatedAdds();
testRejectsEmptyAndAbsolute();
testRoundTrip();
testRoundTripEscaping();
testMalformedParse();
if (g_fail == 0) std::printf("All tests passed.\n");
return g_fail ? 1 : 0;
}
+50 -17
View File
@@ -52,6 +52,12 @@ UsageRecord makeRecord(const std::string& trackGuid, const std::string& nonce,
return r;
}
// One enumerated key's read result. Keys are synthesized per index because the fold
// only uses them to name an offender; a nullopt record is the unreadable case.
DecodedUsage decodedOf(const std::string& key, std::optional<UsageRecord> rec) {
return DecodedUsage{key, std::move(rec)};
}
bool holdsContainPath(const std::vector<UsageHold>& holds, const std::string& path) {
for (const UsageHold& h : holds)
if (h.relativePath == path) return true;
@@ -349,36 +355,61 @@ static void testTakeFxAttributedRecordIsProtected() {
// delete nothing) — silently reduced protection is the delete direction.
static void testUnreadableRecordAbortsPrune() {
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // one unreadable record among readable ones
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // one unreadable among readable
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(fold.abortPrune);
// The offender is named so the action can tell the operator which key to recover.
CHECK(fold.offendingKeys.size() == 1);
CHECK(fold.offendingKeys[0] == "rsusage_BAD");
// Attribution is exactly what an unreadable record destroys — no record may be
// reported as counted while one is unreadable.
CHECK(fold.counted.empty());
// All readable -> no abort, normal liveness fold.
std::vector<std::optional<UsageRecord>> ok;
ok.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
// All readable -> no abort, normal liveness fold, and the live record is attributed.
std::vector<DecodedUsage> ok;
ok.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
const UsageFoldResult okFold =
foldUsageRecords(ok, std::unordered_set<std::string>{"{T1}"}, true);
CHECK(!okFold.abortPrune);
CHECK(okFold.offendingKeys.empty());
CHECK(okFold.heldPaths.size() == 1);
CHECK(okFold.heldPaths[0] == "pa.wav");
CHECK(okFold.counted.size() == 1);
CHECK(okFold.counted[0].key == "rsusage_1");
// Empty input (no records enumerated) -> empty, no abort.
const UsageFoldResult empty =
foldUsageRecords({}, std::unordered_set<std::string>{}, false);
CHECK(!empty.abortPrune);
CHECK(empty.heldPaths.empty());
CHECK(empty.counted.empty());
// Readable records + zero identified -> the protect-all net applies through the
// fold too (belt and braces with the abort).
std::vector<std::optional<UsageRecord>> unmatched;
unmatched.push_back(makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}}));
// fold too (belt and braces with the abort), and every record counts.
std::vector<DecodedUsage> unmatched;
unmatched.push_back(decodedOf("rsusage_9", makeRecord("{T9}", "N1", {UsageHold{"a", "pa.wav"}})));
const UsageFoldResult net =
foldUsageRecords(unmatched, std::unordered_set<std::string>{}, false);
CHECK(!net.abortPrune);
CHECK(net.heldPaths.size() == 1);
CHECK(net.counted.size() == 1);
}
// A dead-track record must be excluded from `counted` as well as from heldPaths —
// attribution and protection must name the same records or the two consumers drift.
static void testCountedMirrorsHeldPaths() {
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_LIVE", makeRecord("{LIVE}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_DEAD", makeRecord("{DEAD}", "N2", {UsageHold{"b", "pb.wav"}})));
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{"{LIVE}"}, true);
CHECK(fold.heldPaths.size() == 1);
CHECK(fold.heldPaths[0] == "pa.wav");
CHECK(fold.counted.size() == 1);
CHECK(fold.counted[0].key == "rsusage_LIVE");
}
// --- abort returns the protect-all set (belt-and-braces) ---------------------------
@@ -388,10 +419,10 @@ static void testUnreadableRecordAbortsPrune() {
// delete-ward).
static void testAbortFoldReturnsProtectAllSet() {
// Two readable records + one unreadable (nullopt) in between.
std::vector<std::optional<UsageRecord>> decoded;
decoded.push_back(makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}}));
decoded.push_back(std::nullopt); // triggers abort
decoded.push_back(makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}}));
std::vector<DecodedUsage> decoded;
decoded.push_back(decodedOf("rsusage_1", makeRecord("{T1}", "N1", {UsageHold{"a", "pa.wav"}})));
decoded.push_back(decodedOf("rsusage_BAD", std::nullopt)); // triggers abort
decoded.push_back(decodedOf("rsusage_2", makeRecord("{T2}", "N2", {UsageHold{"b", "pb.wav"}})));
// Live: only {T1} — so without protect-all, T2's path would be excluded.
const UsageFoldResult fold =
@@ -409,8 +440,8 @@ static void testAbortFoldReturnsProtectAllSet() {
CHECK(hasPB);
// All nullopt (every key unreadable): abort + empty heldPaths (nothing readable).
std::vector<std::optional<UsageRecord>> allNull;
allNull.push_back(std::nullopt);
std::vector<DecodedUsage> allNull;
allNull.push_back(decodedOf("rsusage_BAD", std::nullopt));
const UsageFoldResult allNullFold =
foldUsageRecords(allNull, std::unordered_set<std::string>{}, false);
CHECK(allNullFold.abortPrune);
@@ -445,8 +476,9 @@ static void testTruncatedWalkProtectsAll() {
CHECK(hasB);
// Belt-and-braces: the same scenario through foldUsageRecords also protects all.
std::vector<std::optional<UsageRecord>> decoded;
for (const UsageRecord& r : records) decoded.push_back(r);
std::vector<DecodedUsage> decoded;
for (std::size_t i = 0; i < records.size(); ++i)
decoded.push_back(decodedOf("rsusage_" + std::to_string(i), records[i]));
const UsageFoldResult fold =
foldUsageRecords(decoded, std::unordered_set<std::string>{}, false);
CHECK(!fold.abortPrune);
@@ -561,6 +593,7 @@ int main() {
testHeldPathsDedupAndEmptyPathSkip();
testTakeFxAttributedRecordIsProtected();
testUnreadableRecordAbortsPrune();
testCountedMirrorsHeldPaths();
testAbortFoldReturnsProtectAllSet();
testTruncatedWalkProtectsAll();
testIdentityMatcher();
+419
View File
@@ -0,0 +1,419 @@
// Standalone tests for reasampler::tracking's consolidated answers — no REAPER, no
// framework. This is the safety-critical file in the territory: it proves that the
// prune's protected set and the resample's replace-vs-add decision come out of ONE
// state, that neither ever answers destructively from ambiguity, and that the
// replace-vs-add universe is a strict subset of the prune-protection universe.
//
// The prune half is composed end-to-end against the real pure core
// (prune_reconcile + BankBook) rather than a mock, so "a tied usage can never reach
// the orphan set" is proven at the layer that actually deletes.
#include "../src/core/tracking/tracking_authority.h"
#include <algorithm>
#include <cstdio>
#include <string>
#include <unordered_set>
#include <vector>
#include "../src/core/model/bank_book.h"
#include "../src/core/reclaim/prune_reconcile.h"
using namespace reasampler;
using namespace reasampler::tracking;
using reasampler::wire::DecodedUsage;
using reasampler::wire::UsageFoldResult;
using reasampler::wire::UsageHold;
using reasampler::wire::UsageRecord;
static int g_fail = 0;
#define CHECK(cond) do { if(!(cond)) { \
std::printf("FAIL line %d: %s\n", __LINE__, #cond); ++g_fail; } } while(0)
// -- fixtures ----------------------------------------------------------------
static OriginRecord originOf(const std::string& path, OriginKind kind,
const std::string& id = "",
const std::string& parent = "") {
OriginRecord r;
r.relativePath = path;
r.kind = kind;
r.sampleId = id;
r.parentSampleId = parent;
return r;
}
static DecodedUsage usage(const std::string& key, const std::string& trackGuid,
const std::vector<UsageHold>& holds, bool unioned = false) {
UsageRecord rec;
rec.trackGuid = trackGuid;
rec.ownerNonce = key + "-nonce";
rec.unioned = unioned;
rec.holds = holds;
return DecodedUsage{key, rec};
}
static DecodedUsage unreadable(const std::string& key) {
return DecodedUsage{key, std::nullopt};
}
static UsageFoldResult foldLive(const std::vector<DecodedUsage>& decoded,
const std::vector<std::string>& liveTracks) {
const std::unordered_set<std::string> live(liveTracks.begin(), liveTracks.end());
return wire::foldUsageRecords(decoded, live, !live.empty());
}
static bool contains(const std::vector<std::string>& v, const std::string& s) {
return std::find(v.begin(), v.end(), s) != v.end();
}
// Adds a bank entry so the path lands in BankBook::referencedPaths().
static void addToBank(BankBook& book, const std::string& id, const std::string& path) {
model::Sample s;
s.id = id;
s.displayName = id;
s.relativePath = path;
book.activeIndex().add(s);
}
// -- 1. the prune protected set ----------------------------------------------
// Live-held protected; project-referenced protected; foreign untouchable;
// system-owned orphan reclaimable — all four from one computation.
static void testPruneProtectedSet() {
// On disk: a referenced capture, a live-held capture, a foreign file, and a
// system-owned orphan.
const std::vector<std::string> present = {
"bank/referenced.wav", "bank/held.wav", "bank/foreign.wav", "bank/orphan.wav"};
BankBook book;
addToBank(book, "S-ref", "bank/referenced.wav");
OriginLedger ledger;
ledger.record(originOf("bank/referenced.wav", OriginKind::Capture, "S-ref"));
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-orphan"));
// "bank/foreign.wav" is deliberately absent — the system did not create it.
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/orphan.wav")); // system-owned, unreferenced
CHECK(!contains(orphans, "bank/referenced.wav")); // a bank references it
CHECK(!contains(orphans, "bank/held.wav")); // a live instance holds it
CHECK(!contains(orphans, "bank/foreign.wav")); // never system-created
}
// A capture whose bank entry was REMOVED while an instance kept playing it is still
// protected — the held-path union, not the index, is what saves it.
static void testLiveHoldProtectsDeReferencedCapture() {
const std::vector<std::string> present = {"bank/held.wav"};
BankBook book; // no entry at all
OriginLedger ledger;
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-held", "bank/held.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
present, reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.empty());
}
// -- 2. unreadable tracking state blocks the prune ---------------------------
static void testUnreadableUsageBlocksPruneAndNamesIt() {
OriginLedger ledger;
ledger.record(originOf("bank/orphan.wav", OriginKind::Capture, "S-1"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-x", "bank/x.wav"}}),
unreadable("rsusage_BROKEN")},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(!answer.ledgerUnreadable);
CHECK(answer.unreadableUsageKeys.size() == 1);
CHECK(answer.unreadableUsageKeys[0] == "rsusage_BROKEN");
}
// An unreadable ledger blocks too, AND leaves ownedPaths empty — so a caller that
// ignored `blocked` still computes an empty orphan set rather than deleting.
static void testUnreadableLedgerBlocksAndYieldsNoOrphans() {
// Deliberately NON-empty: loadLedger() hands back an empty ledger on Unreadable,
// so an empty fixture here would assert nothing. The guard must suppress records
// it was given, not merely pass an empty vector through.
OriginLedger populated;
populated.record(originOf("bank/would-be-orphan.wav", OriginKind::Capture, "S-1"));
const UsageFoldResult fold = foldLive({}, {});
const TrackingState state{LedgerStatus::Unreadable, populated, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(answer.ledgerUnreadable);
CHECK(answer.ownedPaths.empty());
// Belt-and-braces: the orphan set computed from this answer is empty even
// though the file is present, unreferenced, and recorded as owned.
const std::vector<std::string> orphans = reclaim::pruneOrphans(
{"bank/would-be-orphan.wav"}, {}, answer.ownedPaths);
CHECK(orphans.empty());
}
// Both blockers at once must both be reported — the operator needs to fix both.
static void testBothBlockersReported() {
const OriginLedger empty;
const UsageFoldResult fold = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"});
const TrackingState state{LedgerStatus::Unreadable, empty, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(answer.blocked);
CHECK(answer.ledgerUnreadable);
CHECK(answer.unreadableUsageKeys.size() == 1);
}
// A record that exists but whose track hosts no identified instance still protects
// everything (the zero-identified net) — the prune runs, but reclaims nothing held.
static void testZeroIdentifiedInstancesStillProtects() {
OriginLedger ledger;
ledger.record(originOf("bank/held.wav", OriginKind::Capture, "S-held"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T-GONE}", {UsageHold{"S-held", "bank/held.wav"}})}, {});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
CHECK(contains(answer.heldPaths, "bank/held.wav"));
const std::vector<std::string> orphans =
reclaim::pruneOrphans({"bank/held.wav"},
reclaim::mergeReferenced({}, answer.heldPaths),
answer.ownedPaths);
CHECK(orphans.empty());
}
// -- 3. unreadable state never takes the replace branch ----------------------
static void testUnreadableStateNeverAnswersNo() {
OriginLedger ledger;
ledger.record(originOf("bank/a.wav", OriginKind::Capture, "S-a"));
// Unreadable usage record.
const UsageFoldResult brokenUsage = foldLive({unreadable("rsusage_BROKEN")}, {"{T1}"});
const TrackingState usageBad{LedgerStatus::Loaded, ledger, brokenUsage};
CHECK(tiedUsageExists(usageBad, "bank/a.wav", "") == Answer::Indeterminate);
// Unreadable ledger.
const OriginLedger empty;
const UsageFoldResult okUsage = foldLive({}, {});
const TrackingState ledgerBad{LedgerStatus::Unreadable, empty, okUsage};
CHECK(tiedUsageExists(ledgerBad, "bank/a.wav", "") == Answer::Indeterminate);
// An empty capture path is a caller error, not a licence to replace.
const TrackingState good{LedgerStatus::Loaded, ledger, okUsage};
CHECK(tiedUsageExists(good, "", "") == Answer::Indeterminate);
}
// -- 4. the tie itself --------------------------------------------------------
static void testTiedUsageYesNoAndSelfExclusion() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
ledger.record(originOf("bank/lonely.wav", OriginKind::Capture, "S-lonely"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}}),
usage("rsusage_OTHER", "{T2}", {UsageHold{"S-src", "bank/src.wav"}})},
{"{T1}", "{T2}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
// Counting every holder: a tie exists.
CHECK(tiedUsageExists(state, "bank/src.wav", "") == Answer::Yes);
// Excluding my own key still leaves the other instance's tie.
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
// Nobody holds this one.
CHECK(tiedUsageExists(state, "bank/lonely.wav", "") == Answer::No);
}
// Sole holder excluding itself: definitively No, so the bake may replace in place.
static void testSoleHolderExcludingItselfAnswersNo() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}})}, {"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::No);
}
// A `unioned` record carries more than one incarnation's holds, so it can never be
// attributed to a single owner — excluding it could hide a sibling's tie, which is
// the under-protecting direction.
static void testUnionedRecordIsNeverExcludedAsOwn() {
OriginLedger ledger;
ledger.record(originOf("bank/src.wav", OriginKind::Capture, "S-src"));
const UsageFoldResult fold = foldLive(
{usage("rsusage_ME", "{T1}", {UsageHold{"S-src", "bank/src.wav"}},
/*unioned=*/true)},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
CHECK(tiedUsageExists(state, "bank/src.wav", "rsusage_ME") == Answer::Yes);
}
// -- 5. no silent gaps --------------------------------------------------------
// A system-created file simulated through the same recordCreated path is tracked
// the instant it exists: an immediately-following prune sees it as owned (and so
// reclaimable, not foreign), and an immediately-following second creation is
// unaffected. Its lineage is queryable with no intervening save/load.
static void testCreateThenImmediatePruneAndSecondCreate() {
OriginLedger ledger;
const UsageFoldResult noUsage = foldLive({}, {});
// Create #1 — a resample carrying lineage from birth.
ledger.record(originOf("bank/bake-1.wav", OriginKind::Resample, "S-1", "S-src"));
{
const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
// Tracked instantly: it is owned, so it is reclaimable rather than foreign.
CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
const std::vector<std::string> orphans =
reclaim::pruneOrphans({"bank/bake-1.wav"}, {}, answer.ownedPaths);
CHECK(orphans.size() == 1);
// Lineage present with nothing in between.
CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
}
// Create #2 immediately after — both records intact, neither disturbed.
ledger.record(originOf("bank/bake-2.wav", OriginKind::Resample, "S-2", "S-1"));
{
const TrackingState state{LedgerStatus::Loaded, ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(contains(answer.ownedPaths, "bank/bake-1.wav"));
CHECK(contains(answer.ownedPaths, "bank/bake-2.wav"));
CHECK(ledger.find("bank/bake-1.wav")->parentSampleId == "S-src");
CHECK(ledger.find("bank/bake-2.wav")->parentSampleId == "S-1");
}
}
// -- 6. the pre-existing bank -------------------------------------------------
// Lifted from a legacy path-only manifest: protections intact, no spurious lineage,
// and the tie query gives a definite answer rather than abstaining.
static void testPreExistingBankKeepsProtectionsAndAnswersDefinitely() {
const LedgerLoad load = loadLedger("{\"owned\":[\"bank/legacy.wav\"]}");
CHECK(load.status == LedgerStatus::Loaded);
CHECK(load.ledger.find("bank/legacy.wav")->parentSampleId.empty());
// Foreign files stay untouchable and the legacy file is still reclaimable when
// nothing references it — exactly the protections it had before the lift.
const UsageFoldResult noUsage = foldLive({}, {});
const TrackingState state{load.status, load.ledger, noUsage};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> orphans = reclaim::pruneOrphans(
{"bank/legacy.wav", "bank/handdropped.wav"}, {}, answer.ownedPaths);
CHECK(orphans.size() == 1);
CHECK(contains(orphans, "bank/legacy.wav"));
CHECK(!contains(orphans, "bank/handdropped.wav"));
// Never-recorded is decidable, not indeterminate.
CHECK(tiedUsageExists(state, "bank/legacy.wav", "") == Answer::No);
CHECK(tiedUsageExists(state, "bank/handdropped.wav", "") == Answer::No);
// And a live hold on the legacy file still ties, lineage record or not.
const UsageFoldResult held = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S-legacy", "bank/legacy.wav"}})},
{"{T1}"});
const TrackingState heldState{load.status, load.ledger, held};
CHECK(tiedUsageExists(heldState, "bank/legacy.wav", "") == Answer::Yes);
}
// -- 7. the consumers cannot disagree ----------------------------------------
// Every Yes from the tie query is a path the prune protects, over a constructed
// record set that mixes live, dead, self-held and unheld paths. The reverse does
// NOT hold — that asymmetry is the point, so it is asserted too.
static void testTiedUniverseIsStrictSubsetOfProtectedUniverse() {
BankBook book;
addToBank(book, "S-ref", "bank/bank-only.wav");
OriginLedger ledger;
for (const char* p : {"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
"bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"})
ledger.record(originOf(p, OriginKind::Capture, std::string("S") + p));
const UsageFoldResult fold = foldLive(
{usage("rsusage_A", "{T1}", {UsageHold{"S1", "bank/live-a.wav"},
UsageHold{"S2", "bank/live-b.wav"}}),
usage("rsusage_ME", "{T1}", {UsageHold{"S3", "bank/self.wav"}}),
usage("rsusage_DEAD", "{T-GONE}", {UsageHold{"S4", "bank/dead.wav"}})},
{"{T1}"});
const TrackingState state{LedgerStatus::Loaded, ledger, fold};
const ProtectionAnswer answer = pruneProtection(state);
CHECK(!answer.blocked);
const std::vector<std::string> referenced =
reclaim::mergeReferenced(book.referencedPaths(), answer.heldPaths);
const std::vector<std::string> universe = {
"bank/live-a.wav", "bank/live-b.wav", "bank/self.wav",
"bank/dead.wav", "bank/bank-only.wav", "bank/unused.wav"};
std::size_t tiedCount = 0;
for (const std::string& path : universe) {
const Answer tied = tiedUsageExists(state, path, "rsusage_ME");
if (tied != Answer::Yes) continue;
++tiedCount;
// Yes => protected: the path is in the referenced union, so pruneOrphans
// cannot emit it even though it is present and owned.
CHECK(contains(referenced, path));
CHECK(reclaim::pruneOrphans({path}, referenced, answer.ownedPaths).empty());
}
CHECK(tiedCount == 2); // live-a, live-b — self is excluded, dead is not live
// Strictly narrower: bank-only.wav is protected (a bank references it) and
// self.wav is protected (a live instance holds it), yet neither is a tie.
CHECK(contains(referenced, "bank/bank-only.wav"));
CHECK(tiedUsageExists(state, "bank/bank-only.wav", "rsusage_ME") == Answer::No);
CHECK(contains(referenced, "bank/self.wav"));
CHECK(tiedUsageExists(state, "bank/self.wav", "rsusage_ME") == Answer::No);
}
int main() {
testPruneProtectedSet();
testLiveHoldProtectsDeReferencedCapture();
testUnreadableUsageBlocksPruneAndNamesIt();
testUnreadableLedgerBlocksAndYieldsNoOrphans();
testBothBlockersReported();
testZeroIdentifiedInstancesStillProtects();
testUnreadableStateNeverAnswersNo();
testTiedUsageYesNoAndSelfExclusion();
testSoleHolderExcludingItselfAnswersNo();
testUnionedRecordIsNeverExcludedAsOwn();
testCreateThenImmediatePruneAndSecondCreate();
testPreExistingBankKeepsProtectionsAndAnswersDefinitely();
testTiedUniverseIsStrictSubsetOfProtectedUniverse();
if (g_fail == 0) std::printf("tracking_authority: all tests passed\n");
return g_fail == 0 ? 0 : 1;
}