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,51 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The conditions `crafter-debug wait` and `pause --when` take: comparisons of
// watch paths with literals and with each other, combined with && || ! and
// parentheses.
//
// fort1.core.hp <= 0
// scene == "Game" && !debug.paused
// bodies[3].pos.y < -10 || (lives == 0 && score > highScore)
//
// A lone operand tests truthiness: true, a nonzero number, a non-empty
// string, array or object. Numbers compare by value, strings in byte order;
// == and != work on any two values, and ordering a string against a number
// is an error rather than false, so a typo in a condition is reported
// instead of waiting forever. Parsed once when it arrives, evaluated on the
// Pump thread every frame.
export module Crafter.Debug:Condition;
import std;
import Crafter.Debug.Core;
export namespace Crafter::Debug {
class ConditionError : public std::runtime_error {
public:
std::size_t offset;
ConditionError(std::size_t offset, std::string_view what);
};
class Condition {
public:
// Throws ConditionError, with the offset of the problem.
static Condition Parse(std::string_view text);
// Looks a path up: a value, or why there is none.
using Resolver = std::function<std::expected<Value, std::string>(std::string_view path)>;
// True or false, or why it cannot be decided (an unknown path, or
// operands that cannot be ordered).
std::expected<bool, std::string> Evaluate(const Resolver& resolve) const;
const std::string& Text() const { return text; }
// The paths the condition reads, in order of appearance.
std::span<const std::string> Paths() const { return paths; }
struct Node;
private:
std::string text;
std::shared_ptr<const Node> root;
std::vector<std::string> paths;
};
}

View file

@ -0,0 +1,428 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// What an app offers the agent: commands it can call, watches it can read and
// set, and samples it can trace.
//
// struct AimArgs { Debug::Required<std::int32_t> slot; float yaw = 0; float pitch = 0; };
// Debug::Command("Player_SetAim", "Aim the slot's turret, in degrees.",
// [](const AimArgs& a) -> Debug::Result<> { ... });
// Debug::Watch("physics.gravity", Physics::gravity, { .min = -50, .max = 0, .help = "m/s^2" });
// Debug::Sample("xpbd.residual", residual);
//
// A command handler takes nothing, or one argument type that has a Codec (an
// aggregate, usually, or Value for raw JSON), and returns void, a value with
// a Codec, Result<T> or Async<T>. Arguments are decoded before the handler
// runs, so a mistyped call is answered with every mistake in it and never
// reaches the handler. Free functions register for the program's lifetime;
// a Group registers under a prefix and unregisters with its owner.
//
// Thread safety: registering, unregistering and listing are safe from any
// thread. Handlers, getters and setters are only ever called by the session,
// on the thread that calls Pump, so they read app state without locking.
// Sample may be called from any thread.
export module Crafter.Debug:Registry;
import std;
import Crafter.Debug.Core;
export namespace Crafter::Debug {
struct WatchOptions {
// Bounds a set must respect, for arithmetic watches.
std::optional<double> min;
std::optional<double> max;
// Shown by `crafter-debug help` and `get`: units, meaning.
std::string help;
// A watch of a variable is writable unless this is set.
bool readOnly = false;
};
}
// The type-erased registry. Not for app code: the templates below fill it.
export namespace Crafter::Debug::Detail {
// What calling a handler produced: a result now, or a poller that the
// session calls once per Pump until it yields (the handler returned
// an Async).
struct CommandOutcome {
std::optional<Result<Value>> done;
std::function<std::optional<Result<Value>>()> pending;
};
struct CommandEntry {
std::string name;
std::string description;
// Describe() of the argument type, or null for a command that takes none.
Value args;
// Describe() of the result type, or null for one that returns nothing.
Value returns;
std::function<CommandOutcome(const Value& params)> invoke;
std::uint64_t owner = 0;
};
struct WatchEntry {
std::string path;
Value type;
std::string help;
std::function<Value()> get;
// Empty for a read-only watch. Returns an error message, or nothing.
std::function<std::optional<std::string>(const Value& value)> set;
std::uint64_t owner = 0;
};
struct SamplePoint {
std::uint64_t frame;
Value value;
};
// A path resolved to a value: from a watch's getter or a sample's
// latest point, then into it along whatever the path names past the
// watch ("bodies[3].pos" against the watch "bodies").
struct Resolved {
Value value;
std::string error;
bool Ok() const { return error.empty(); }
};
class Registry {
public:
static Registry& Instance();
std::uint64_t NewOwner();
void AddCommand(CommandEntry entry);
void AddWatch(WatchEntry entry);
void AddSample(std::string_view path, Value value, std::uint64_t frame, std::uint64_t owner);
void RemoveOwner(std::uint64_t owner);
std::shared_ptr<const CommandEntry> FindCommand(std::string_view name) const;
// Sorted by name; `glob` filters ("*" any run, "?" one character).
std::vector<std::shared_ptr<const CommandEntry>> Commands(std::string_view glob = "*") const;
std::vector<std::shared_ptr<const WatchEntry>> Watches(std::string_view glob = "*") const;
std::vector<std::string> SamplePaths(std::string_view glob = "*") const;
std::vector<SamplePoint> SampleHistory(std::string_view path) const;
// Call on the thread that calls Pump: it runs getters.
Resolved Resolve(std::string_view path) const;
// Sets the watch the path names, or a part of its value: "fort1.core.pos.x"
// reads the watch "fort1.core.pos", replaces x and writes it back.
// Returns an error message, or nothing. Call on the Pump thread.
std::optional<std::string> Set(std::string_view path, const Value& value) const;
// The closest known command, watch or sample name to `name`, for
// "did you mean" in errors.
std::optional<std::string> SuggestCommand(std::string_view name) const;
std::optional<std::string> SuggestPath(std::string_view path) const;
private:
mutable std::mutex mutex;
std::map<std::string, std::shared_ptr<const CommandEntry>, std::less<>> commands;
std::map<std::string, std::shared_ptr<const WatchEntry>, std::less<>> watches;
struct Series {
std::deque<SamplePoint> points;
std::uint64_t owner = 0;
};
std::map<std::string, Series, std::less<>> samples;
std::atomic<std::uint64_t> nextOwner{ 1 };
};
// "*" matches any run of characters, "?" any one.
bool GlobMatch(std::string_view glob, std::string_view text);
// The frame Sample and the timeline stamp what they record with; the
// session sets it each Pump.
std::uint64_t CurrentFrame();
void SetFrame(std::uint64_t frame);
template<class>
struct FunctionTraits;
template<class R, class... A>
struct FunctionTraits<std::function<R(A...)>> {
using Return = R;
static constexpr std::size_t arity = sizeof...(A);
template<std::size_t I>
using Arg = std::tuple_element_t<I, std::tuple<A...>>;
};
// The value type a handler's return produces: void, T, Result<T>, Async<T>.
template<class R>
struct Produced {
using Type = R;
};
template<class T>
struct Produced<Result<T>> {
using Type = T;
};
template<class T>
struct Produced<Async<T>> {
using Type = T;
};
template<class R>
inline constexpr bool isAsync = false;
template<class T>
inline constexpr bool isAsync<Async<T>> = true;
template<class R>
inline constexpr bool isResult = false;
template<class T>
inline constexpr bool isResult<Result<T>> = true;
inline CommandOutcome Fail(std::string code, std::string message) {
return CommandOutcome{ .done = Result<Value>(Error{ std::move(code), std::move(message) }), .pending = {} };
}
template<class T>
Result<Value> ToValue(Result<T>&& result) {
if (!result.Ok()) return result.GetError();
if constexpr (std::is_void_v<T>) {
return Value();
} else {
return Debug::Encode(result.Get());
}
}
// Calls the handler through `call`, turning whatever it returns, or
// throws, into an outcome. A handler's exception must not unwind into
// the app's frame loop.
template<class Call>
CommandOutcome Run(Call&& call) {
using R = std::invoke_result_t<Call&>;
try {
if constexpr (std::is_void_v<R>) {
call();
return CommandOutcome{ .done = Result<Value>(Value()), .pending = {} };
} else if constexpr (isAsync<R>) {
using T = typename Produced<R>::Type;
return CommandOutcome{
.done = std::nullopt,
.pending = [async = call()]() mutable -> std::optional<Result<Value>> {
std::optional<Result<T>> polled = async.Poll();
if (!polled) return std::nullopt;
return ToValue(std::move(*polled));
},
};
} else if constexpr (isResult<R>) {
return CommandOutcome{ .done = ToValue(call()), .pending = {} };
} else {
return CommandOutcome{ .done = Result<Value>(Debug::Encode(call())), .pending = {} };
}
} catch (const std::exception& error) {
return Fail("Exception", std::format("the handler threw: {}", error.what()));
} catch (...) {
return Fail("Exception", "the handler threw something that is not a std::exception");
}
}
template<class F>
CommandEntry MakeCommand(std::string name, std::string_view description, F handler, std::uint64_t owner) {
using Traits = FunctionTraits<decltype(std::function(handler))>;
static_assert(Traits::arity <= 1, "a command handler takes no parameters, or one argument struct");
using R = typename Traits::Return;
using P = typename Produced<R>::Type;
CommandEntry entry;
entry.name = std::move(name);
entry.description = std::string(description);
entry.owner = owner;
if constexpr (!std::is_void_v<P>) entry.returns = Debug::Describe<std::remove_cvref_t<P>>();
if constexpr (Traits::arity == 0) {
entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome {
if (!params.IsNull() && !(params.IsObject() && params.Size() == 0)) {
return Fail("InvalidParams", "this command takes no arguments");
}
return Run([&] { return handler(); });
};
} else {
using Args = std::remove_cvref_t<typename Traits::template Arg<0>>;
entry.args = Debug::Describe<Args>();
entry.invoke = [handler = std::move(handler)](const Value& params) mutable -> CommandOutcome {
Args args{};
DecodeContext context;
if constexpr (Aggregate<Args>) {
Debug::Decode(params.IsNull() ? Value::MakeObject() : params, args, context);
} else {
Debug::Decode(params, args, context);
}
if (!context.Ok()) return Fail("InvalidParams", context.Report());
return Run([&] { return handler(args); });
};
}
return entry;
}
template<class T>
std::optional<std::string> CheckBounds(const T& value, const WatchOptions& options) {
if constexpr (std::is_arithmetic_v<T> && !std::is_same_v<T, bool>) {
const double number = static_cast<double>(value);
if ((options.min && number < *options.min) || (options.max && number > *options.max)) {
return std::format("{} is outside {}..{}", number, options.min ? std::format("{}", *options.min) : std::string(),
options.max ? std::format("{}", *options.max) : std::string());
}
}
return std::nullopt;
}
template<class T>
Value WatchType(const WatchOptions& options) {
Value type = Debug::Describe<T>();
if (options.min) type.Set("minimum", Value(*options.min));
if (options.max) type.Set("maximum", Value(*options.max));
return type;
}
template<class T>
WatchEntry MakeVariableWatch(std::string path, T& variable, const WatchOptions& options, std::uint64_t owner) {
WatchEntry entry;
entry.path = std::move(path);
entry.type = WatchType<T>(options);
entry.help = options.help;
entry.owner = owner;
entry.get = [&variable] { return Debug::Encode(variable); };
if constexpr (!std::is_const_v<T>) {
if (!options.readOnly) {
entry.set = [&variable, options](const Value& value) -> std::optional<std::string> {
T decoded = variable;
DecodeContext context;
Debug::Decode(value, decoded, context);
if (!context.Ok()) return context.Report();
if (auto bounds = CheckBounds(decoded, options)) return bounds;
variable = std::move(decoded);
return std::nullopt;
};
}
}
return entry;
}
template<class G, class S>
WatchEntry MakeComputedWatch(std::string path, G getter, std::optional<S> setter, const WatchOptions& options, std::uint64_t owner) {
using T = std::remove_cvref_t<std::invoke_result_t<G&>>;
WatchEntry entry;
entry.path = std::move(path);
entry.type = WatchType<T>(options);
entry.help = options.help;
entry.owner = owner;
entry.get = [getter]() mutable { return Debug::Encode(getter()); };
if (setter && !options.readOnly) {
entry.set = [getter, setter = std::move(*setter), options](const Value& value) mutable -> std::optional<std::string> {
T decoded = getter();
DecodeContext context;
Debug::Decode(value, decoded, context);
if (!context.Ok()) return context.Report();
if (auto bounds = CheckBounds(decoded, options)) return bounds;
using SR = std::invoke_result_t<S&, const T&>;
if constexpr (std::is_void_v<SR>) {
setter(decoded);
} else {
static_assert(std::is_same_v<SR, Result<>>, "a watch setter returns void or Debug::Result<>");
Result<> result = setter(decoded);
if (!result.Ok()) return std::format("{}: {}", result.GetError().code, result.GetError().message);
}
return std::nullopt;
};
}
return entry;
}
std::string Joined(std::string_view prefix, std::string_view name);
struct NoSetter {
template<class T>
void operator()(const T&) const {}
};
}
export namespace Crafter::Debug {
// Registers a command for the program's lifetime. See the comment at the top.
template<class F>
void Command(std::string_view name, std::string_view description, F&& handler) {
Detail::Registry::Instance().AddCommand(Detail::MakeCommand(std::string(name), description, std::forward<F>(handler), 0));
}
// Watches a variable: `get` reads it and `set` writes it, on the Pump
// thread. The variable must outlive the program, or use a Group.
template<class T>
requires (!std::invocable<T&>)
void Watch(std::string_view path, T& variable, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(std::string(path), variable, options, 0));
}
// Watches what a getter returns; read-only.
template<class G>
requires std::invocable<G&>
void Watch(std::string_view path, G getter, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional<Detail::NoSetter>(), options, 0));
}
// Watches a getter and writes through a setter taking the getter's type
// and returning void or Result<>.
template<class G, class S>
requires std::invocable<G&>
void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(std::string(path), std::move(getter), std::optional<S>(std::move(setter)), options, 0));
}
// Records a value under `path`, stamped with the current frame, from any
// thread: for locals in hot code that no watch can reach. `get` reads the
// latest, `trace` the history.
template<class T>
void Sample(std::string_view path, const T& value) {
Detail::Registry::Instance().AddSample(path, Debug::Encode(value), Detail::CurrentFrame(), 0);
}
// Registrations under a prefix that end with the Group: hold one as a
// member of what the commands and watches reach into.
//
// class Fort {
// Core core;
// Debug::Group debug;
// public:
// explicit Fort(std::int32_t slot) : debug(std::format("fort{}", slot)) {
// debug.Watch("core.hp", core.hp);
// }
// };
//
// Neither copyable nor movable, and so neither is a class that holds one:
// its watches refer to the members they were given, and a moved object
// would leave them pointing at the old one. Keep such objects in place
// (std::unique_ptr, std::deque, std::list) rather than in a std::vector.
class Group {
public:
explicit Group(std::string prefix);
~Group();
Group(const Group&) = delete;
Group& operator=(const Group&) = delete;
const std::string& Prefix() const { return prefix; }
template<class F>
void Command(std::string_view name, std::string_view description, F&& handler) {
Detail::Registry::Instance().AddCommand(Detail::MakeCommand(Detail::Joined(prefix, name), description, std::forward<F>(handler), owner));
}
template<class T>
requires (!std::invocable<T&>)
void Watch(std::string_view path, T& variable, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeVariableWatch(Detail::Joined(prefix, path), variable, options, owner));
}
template<class G>
requires std::invocable<G&>
void Watch(std::string_view path, G getter, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional<Detail::NoSetter>(), options, owner));
}
template<class G, class S>
requires std::invocable<G&>
void Watch(std::string_view path, G getter, S setter, WatchOptions options = {}) {
Detail::Registry::Instance().AddWatch(Detail::MakeComputedWatch(Detail::Joined(prefix, path), std::move(getter), std::optional<S>(std::move(setter)), options, owner));
}
template<class T>
void Sample(std::string_view path, const T& value) {
Detail::Registry::Instance().AddSample(Detail::Joined(prefix, path), Debug::Encode(value), Detail::CurrentFrame(), owner);
}
private:
std::string prefix;
std::uint64_t owner = 0;
};
}

View file

@ -0,0 +1,172 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// The timeline: what the app logged, the checks that failed, the events it
// emitted, and what the agent did, in one sequence stamped with frame
// numbers and source locations, so `crafter-debug log` shows cause next to
// effect:
//
// 1790 AGENT call Player_Fire {}
// 1838 WARN physics brace 17 strain 4.2 > limit Forts3D-Brace.cpp:212
//
// Debug::Log("net", "peer {} joined slot {}", peerId, slot); // also LogWarn, LogError
// Debug::Emit("MatchEnded", MatchEnded{ .winnerSlot = 1 });
// if (!Debug::Check(mass > 0, "physics", "body {} has mass {}", id, mass)) return;
//
// The format string is checked at compile time, as std::format's is, and the
// caller's location is captured without a macro (macros don't cross
// `import`). Every function here is safe from any thread. The timeline
// keeps the last 8192 entries.
export module Crafter.Debug:Timeline;
import std;
import Crafter.Debug.Core;
import :Registry;
export namespace Crafter::Debug {
enum class EntryKind : std::uint8_t {
Log,
Warn,
Error,
Check,
Event,
Agent,
Pause,
Stdio,
};
// "log", "warn", "error", "check", "event", "agent", "pause", "stdio".
std::string_view EntryKindName(EntryKind kind);
struct TimelineEntry {
// 0 for a check failure that was counted but not stored (see Check).
std::uint64_t sequence = 0;
std::uint64_t frame = 0;
// Since the timeline started, on the steady clock.
double seconds = 0;
EntryKind kind = EntryKind::Log;
// The log category, or an event's name.
std::string category;
std::string message;
std::string file;
std::uint32_t line = 0;
// An event's payload.
Value payload;
// For a check: its site's failures so far, this one included.
std::uint64_t count = 1;
};
// A format string and where it was written, captured at the call site.
template<class... A>
struct LocFmt {
std::format_string<A...> format;
std::source_location location;
template<class S>
requires std::convertible_to<const S&, std::string_view>
consteval LocFmt(const S& text, std::source_location where = std::source_location::current()) : format(text), location(where) {}
};
}
export namespace Crafter::Debug::Detail {
// 0 for information (log, event, agent, pause, stdio), 1 for warn, 2 for
// error and check: what `log --level` compares against.
std::uint8_t Level(EntryKind kind);
struct TimelineQuery {
// Only entries after this sequence number: what a follower has seen.
std::uint64_t afterSequence = 0;
std::optional<std::uint64_t> sinceFrame;
// The newest this many of the matches; 0 for all of them.
std::size_t last = 0;
std::uint8_t minLevel = 0;
// A glob on the category; empty for any.
std::string category;
// A substring of the message or the category; empty for any.
std::string grep;
// Empty for any kind.
std::vector<EntryKind> kinds;
};
struct CheckSite {
std::string file;
std::uint32_t line = 0;
std::string category;
std::uint64_t failures = 0;
};
class Timeline {
public:
static Timeline& Instance();
// Stamps the entry with its sequence, frame and time, stores it and
// tells the listener. Returns its sequence number.
std::uint64_t Append(TimelineEntry entry);
// A failed Check. Counted per site; stored while the site has failed
// at most ten times and then at every power of two, so a check that
// fails every frame cannot push everything else out. The listener
// hears every failure, stored or not.
void RecordCheck(std::string_view category, std::string message, const std::source_location& location);
std::vector<TimelineEntry> Query(const TimelineQuery& query) const;
std::uint64_t LastSequence() const;
std::vector<CheckSite> CheckSites() const;
// Called with every stored entry and every check failure, on the
// thread that recorded it, after the timeline's lock is released:
// the session uses it to pause on a check and to wake followers.
void SetListener(std::function<void(const TimelineEntry&)> listener);
private:
mutable std::mutex mutex;
std::deque<TimelineEntry> entries;
std::uint64_t nextSequence = 1;
struct SiteCount {
std::string category;
std::uint64_t failures = 0;
};
std::map<std::pair<std::string, std::uint32_t>, SiteCount> checks;
std::shared_ptr<const std::function<void(const TimelineEntry&)>> listener;
std::chrono::steady_clock::time_point start = std::chrono::steady_clock::now();
void Notify(const TimelineEntry& entry) const;
};
TimelineEntry MakeEntry(EntryKind kind, std::string_view category, std::string message, const std::source_location& location, Value payload = {});
}
export namespace Crafter::Debug {
template<class... A>
void Log(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Log, category, std::format(format.format, std::forward<A>(args)...), format.location));
}
template<class... A>
void LogWarn(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Warn, category, std::format(format.format, std::forward<A>(args)...), format.location));
}
template<class... A>
void LogError(std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Error, category, std::format(format.format, std::forward<A>(args)...), format.location));
}
// A soft assert: returns `ok`, and when it is false records the failure
// (the message is only formatted then, so a Check in hot code is cheap).
// An agent can make a failure pause the app (`pause --on-check`) or stop
// it in an attached debugger (`--trap`).
template<class... A>
bool Check(bool ok, std::string_view category, LocFmt<std::type_identity_t<A>...> format, A&&... args) {
if (!ok) Detail::Timeline::Instance().RecordCheck(category, std::format(format.format, std::forward<A>(args)...), format.location);
return ok;
}
// A structured event, which `crafter-debug wait --event <name>` waits for.
template<class T>
void Emit(std::string_view event, const T& payload, std::source_location location = std::source_location::current()) {
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location, Debug::Encode(payload)));
}
inline void Emit(std::string_view event, std::source_location location = std::source_location::current()) {
Detail::Timeline::Instance().Append(Detail::MakeEntry(EntryKind::Event, event, {}, location));
}
}

View file

@ -1,11 +1,15 @@
// SPDX-License-Identifier: LGPL-3.0-only
// SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
// Runtime debugging for AI agents: what an app imports. The session, the
// registry of commands and watches, the timeline and pause control follow in
// later phases; for now this re-exports the shared core.
// Runtime debugging for AI agents: what an app imports. Commands, watches
// and samples (:Registry), the timeline with Log, Check and Emit
// (:Timeline), and the conditions waits and pauses use (:Condition), over
// the core shared with the client. The session follows in a later phase.
export module Crafter.Debug;
export import Crafter.Debug.Core;
export import :Registry;
export import :Timeline;
export import :Condition;
import std;
export namespace Crafter::Debug {