feat(registry): commands, watches, samples, the timeline and conditions

The app side of Crafter.Debug, minus the session:

- Command(name, description, handler): the handler takes nothing or
  one argument struct and returns void, a value, Result<T> or
  Async<T>. Arguments are decoded before it runs, so a mistyped call
  is answered with every mistake and never reaches it; a throwing
  handler becomes an Exception error instead of unwinding into the
  frame loop. This replaces 3DForts' three-file pattern per verb.
- Watch(path, variable | getter [, setter]) with bounds, help and
  read-only, and Sample(path, value) from any thread with a
  frame-stamped history. Paths resolve into values ("bodies[1].pos.y")
  and set the same way, writing a member writes the whole value back.
  Unknown paths and commands get a suggestion.
- Group: registrations under a prefix that end with their owner. Not
  movable, so neither is its owner: its watches refer to the owner's
  members, and a move would leave them pointing at the old object.
- Log, LogWarn, LogError, Check and Emit on one frame-stamped
  timeline, with source locations captured without macros. A check
  failing every frame is counted every time but stored sparsely, and
  the session's listener hears every failure.
- Condition: what `wait` and `pause --when` evaluate each frame. A
  condition that cannot be decided reports why instead of being false,
  which would make a wait hang.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Jorijn van der Graaf 2026-11-01 01:40:59 +01:00
commit 3500b73ecf
Signed by: jorijnvdgraaf
GPG key ID: 2937E59CDCC1BCFB
12 changed files with 1829 additions and 7 deletions

View file

@ -0,0 +1,131 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
module Crafter.Debug:Timeline_impl;
import std;
import Crafter.Debug.Core;
import :Registry;
import :Timeline;
using namespace Crafter::Debug;
using namespace Crafter::Debug::Detail;
namespace {
constexpr std::size_t capacity = 8192;
bool Stored(std::uint64_t failures) {
return failures <= 10 || std::has_single_bit(failures);
}
}
std::string_view Crafter::Debug::EntryKindName(EntryKind kind) {
switch (kind) {
case EntryKind::Log: return "log";
case EntryKind::Warn: return "warn";
case EntryKind::Error: return "error";
case EntryKind::Check: return "check";
case EntryKind::Event: return "event";
case EntryKind::Agent: return "agent";
case EntryKind::Pause: return "pause";
case EntryKind::Stdio: return "stdio";
}
return "unknown";
}
std::uint8_t Crafter::Debug::Detail::Level(EntryKind kind) {
switch (kind) {
case EntryKind::Warn: return 1;
case EntryKind::Error:
case EntryKind::Check: return 2;
default: return 0;
}
}
TimelineEntry Crafter::Debug::Detail::MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload) {
TimelineEntry entry;
entry.kind = kind;
entry.category = std::string(category);
entry.message = std::move(message);
entry.file = location.file_name();
entry.line = location.line();
entry.payload = std::move(payload);
return entry;
}
Timeline& Timeline::Instance() {
static Timeline timeline;
return timeline;
}
std::uint64_t Timeline::Append(TimelineEntry entry) {
entry.frame = CurrentFrame();
{
std::lock_guard lock(mutex);
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
entry.sequence = nextSequence++;
entries.push_back(entry);
if (entries.size() > capacity) entries.pop_front();
}
Notify(entry);
return entry.sequence;
}
void Timeline::RecordCheck(std::string_view category, std::string message, const std::source_location& location) {
TimelineEntry entry = MakeEntry(EntryKind::Check, category, std::move(message), location);
entry.frame = CurrentFrame();
{
std::lock_guard lock(mutex);
entry.seconds = std::chrono::duration<double>(std::chrono::steady_clock::now() - start).count();
SiteCount& site = checks[{ entry.file, entry.line }];
site.category = entry.category;
entry.count = ++site.failures;
if (Stored(entry.count)) {
entry.sequence = nextSequence++;
entries.push_back(entry);
if (entries.size() > capacity) entries.pop_front();
}
}
Notify(entry);
}
std::vector<TimelineEntry> Timeline::Query(const TimelineQuery& query) const {
std::vector<TimelineEntry> matched;
std::lock_guard lock(mutex);
for (const TimelineEntry& entry : entries) {
if (entry.sequence <= query.afterSequence) continue;
if (query.sinceFrame && entry.frame < *query.sinceFrame) continue;
if (Level(entry.kind) < query.minLevel) continue;
if (!query.kinds.empty() && std::ranges::find(query.kinds, entry.kind) == query.kinds.end()) continue;
if (!query.category.empty() && !GlobMatch(query.category, entry.category)) continue;
if (!query.grep.empty() && entry.message.find(query.grep) == std::string::npos && entry.category.find(query.grep) == std::string::npos) continue;
matched.push_back(entry);
}
if (query.last != 0 && matched.size() > query.last) matched.erase(matched.begin(), matched.end() - static_cast<std::ptrdiff_t>(query.last));
return matched;
}
std::uint64_t Timeline::LastSequence() const {
std::lock_guard lock(mutex);
return nextSequence - 1;
}
std::vector<CheckSite> Timeline::CheckSites() const {
std::lock_guard lock(mutex);
std::vector<CheckSite> sites;
for (const auto& [site, count] : checks) sites.push_back({ site.first, site.second, count.category, count.failures });
return sites;
}
void Timeline::SetListener(std::function<void(const TimelineEntry&)> callback) {
std::lock_guard lock(mutex);
listener = callback ? std::make_shared<const std::function<void(const TimelineEntry&)>>(std::move(callback)) : nullptr;
}
void Timeline::Notify(const TimelineEntry& entry) const {
std::shared_ptr<const std::function<void(const TimelineEntry&)>> current;
{
std::lock_guard lock(mutex);
current = listener;
}
if (current) (*current)(entry);
}