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
+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
+48 -26
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,33 +158,44 @@ 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;
for (const DecodedUsage& entry : decoded) {
if (entry.record) continue;
// Present-but-unreadable record: it may protect anything, so halt.
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 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);
}
}
return result;
}
std::vector<UsageRecord> records;
records.reserve(decoded.size());
for (const std::optional<UsageRecord>& rec : decoded) {
if (!rec) {
// 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;
std::unordered_set<std::string> seen;
for (const std::optional<UsageRecord>& r : decoded) {
if (!r) continue;
for (const UsageHold& h : r->holds) {
if (h.relativePath.empty()) continue;
if (seen.insert(h.relativePath).second)
result.heldPaths.push_back(h.relativePath);
}
}
return result;
}
records.push_back(*rec);
}
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:
+23 -13
View File
@@ -23,20 +23,30 @@ 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"
" reaper.SetProjExtState(0, \"reasampler\", \"<key>\", \"\")\n"
"Offending key(s):\n";
for (const std::string& key : report.offendingUsageKeys) {
msg += " " + key + "\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.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`.
+26 -20
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();
SetProjExtState(static_cast<ReaProject*>(proj), projExtNamespace(),
kProjExtOwnedKey, ownedJson.c_str());
// 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, 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.
+44 -37
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/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