feat(cli): Enhance CLI with resource catalog and sandbox management
- Added resource catalog for introspecting CLI resources, including schemas for palettes, ROMs, patches, overworlds, dungeons, and agents. - Implemented serialization methods for resource schemas in both JSON and YAML formats. - Introduced RomSandboxManager to manage sandboxed ROM copies, allowing for safe experimentation with ROM modifications. - Updated ModernCLI to include new commands for palette management and enhanced help output. - Added unit tests for resource catalog serialization and schema validation.
This commit is contained in:
444
src/cli/service/resource_catalog.cc
Normal file
444
src/cli/service/resource_catalog.cc
Normal file
@@ -0,0 +1,444 @@
|
||||
#include "cli/service/resource_catalog.h"
|
||||
|
||||
#include <sstream>
|
||||
|
||||
#include "absl/strings/str_cat.h"
|
||||
#include "absl/strings/str_join.h"
|
||||
#include "absl/strings/string_view.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
namespace {
|
||||
|
||||
ResourceSchema MakePaletteSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "palette";
|
||||
schema.description =
|
||||
"Palette manipulation commands covering export, import, and color editing.";
|
||||
|
||||
ResourceAction export_action;
|
||||
export_action.name = "export";
|
||||
export_action.synopsis = "z3ed palette export --group <group> --id <id> --to <file>";
|
||||
export_action.stability = "experimental";
|
||||
export_action.arguments = {
|
||||
ResourceArgument{"--group", "integer", true, "Palette group id (0-31)."},
|
||||
ResourceArgument{"--id", "integer", true, "Palette index inside the group."},
|
||||
ResourceArgument{"--to", "path", true, "Destination file path for binary export."},
|
||||
};
|
||||
export_action.effects = {
|
||||
"Reads ROM palette buffer and writes binary palette data to disk."};
|
||||
|
||||
ResourceAction import_action;
|
||||
import_action.name = "import";
|
||||
import_action.synopsis =
|
||||
"z3ed palette import --group <group> --id <id> --from <file>";
|
||||
import_action.stability = "experimental";
|
||||
import_action.arguments = {
|
||||
ResourceArgument{"--group", "integer", true, "Palette group id (0-31)."},
|
||||
ResourceArgument{"--id", "integer", true, "Palette index inside the group."},
|
||||
ResourceArgument{"--from", "path", true, "Source binary palette file."},
|
||||
};
|
||||
import_action.effects = {
|
||||
"Writes imported palette bytes into ROM buffer and marks project dirty."};
|
||||
|
||||
schema.actions = {export_action, import_action};
|
||||
return schema;
|
||||
}
|
||||
|
||||
ResourceSchema MakeRomSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "rom";
|
||||
schema.description = "ROM validation, diffing, and snapshot helpers.";
|
||||
|
||||
ResourceAction validate_action;
|
||||
validate_action.name = "validate";
|
||||
validate_action.synopsis = "z3ed rom validate --rom <file>";
|
||||
validate_action.stability = "stable";
|
||||
validate_action.arguments = {
|
||||
ResourceArgument{"--rom", "path", true, "Path to ROM file configured via global flag."},
|
||||
};
|
||||
validate_action.effects = {
|
||||
"Reads ROM from disk, verifies checksum, and reports header status."};
|
||||
validate_action.returns = {
|
||||
{"report", "object",
|
||||
"Structured validation summary with checksum and header results."}};
|
||||
|
||||
ResourceAction diff_action;
|
||||
diff_action.name = "diff";
|
||||
diff_action.synopsis = "z3ed rom diff <rom_a> <rom_b>";
|
||||
diff_action.stability = "beta";
|
||||
diff_action.arguments = {
|
||||
ResourceArgument{"rom_a", "path", true, "Reference ROM path."},
|
||||
ResourceArgument{"rom_b", "path", true, "Candidate ROM path."},
|
||||
};
|
||||
diff_action.effects = {
|
||||
"Reads two ROM images, compares bytes, and streams differences to stdout."};
|
||||
diff_action.returns = {
|
||||
{"differences", "integer", "Count of mismatched bytes between ROMs."}};
|
||||
|
||||
ResourceAction generate_action;
|
||||
generate_action.name = "generate-golden";
|
||||
generate_action.synopsis =
|
||||
"z3ed rom generate-golden <rom_file> <golden_file>";
|
||||
generate_action.stability = "experimental";
|
||||
generate_action.arguments = {
|
||||
ResourceArgument{"rom_file", "path", true, "Source ROM to snapshot."},
|
||||
ResourceArgument{"golden_file", "path", true, "Output path for golden image."},
|
||||
};
|
||||
generate_action.effects = {
|
||||
"Writes out exact ROM image for tooling baselines and diff workflows."};
|
||||
generate_action.returns = {
|
||||
{"artifact", "path", "Absolute path to the generated golden image."}};
|
||||
|
||||
schema.actions = {validate_action, diff_action, generate_action};
|
||||
return schema;
|
||||
}
|
||||
|
||||
ResourceSchema MakePatchSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "patch";
|
||||
schema.description =
|
||||
"Patch authoring and application commands covering BPS and Asar flows.";
|
||||
|
||||
ResourceAction apply_action;
|
||||
apply_action.name = "apply";
|
||||
apply_action.synopsis = "z3ed patch apply <rom_file> <bps_patch>";
|
||||
apply_action.stability = "beta";
|
||||
apply_action.arguments = {
|
||||
ResourceArgument{"rom_file", "path", true,
|
||||
"Source ROM image that will receive the patch."},
|
||||
ResourceArgument{"bps_patch", "path", true,
|
||||
"BPS patch to apply to the ROM."},
|
||||
};
|
||||
apply_action.effects = {
|
||||
"Loads ROM from disk, applies a BPS patch, and writes `patched.sfc`."};
|
||||
apply_action.returns = {
|
||||
{"artifact", "path",
|
||||
"Absolute path to the patched ROM image produced on success."}};
|
||||
|
||||
ResourceAction asar_action;
|
||||
asar_action.name = "apply-asar";
|
||||
asar_action.synopsis = "z3ed patch apply-asar <patch.asm>";
|
||||
asar_action.stability = "prototype";
|
||||
asar_action.arguments = {
|
||||
ResourceArgument{"patch.asm", "path", true,
|
||||
"Assembly patch consumed by the bundled Asar runtime."},
|
||||
ResourceArgument{"--rom", "path", false,
|
||||
"ROM path supplied via global --rom flag."},
|
||||
};
|
||||
asar_action.effects = {
|
||||
"Invokes Asar against the active ROM buffer and applies assembled changes."};
|
||||
asar_action.returns = {
|
||||
{"log", "string", "Assembler diagnostics emitted during application."}};
|
||||
|
||||
ResourceAction create_action;
|
||||
create_action.name = "create";
|
||||
create_action.synopsis =
|
||||
"z3ed patch create --source <rom> --target <rom> --out <patch.bps>";
|
||||
create_action.stability = "experimental";
|
||||
create_action.arguments = {
|
||||
ResourceArgument{"--source", "path", true,
|
||||
"Baseline ROM used when computing the patch."},
|
||||
ResourceArgument{"--target", "path", true,
|
||||
"Modified ROM to diff against the baseline."},
|
||||
ResourceArgument{"--out", "path", true,
|
||||
"Output path for the generated BPS patch."},
|
||||
};
|
||||
create_action.effects = {
|
||||
"Compares source and target images to synthesize a distributable BPS patch."};
|
||||
create_action.returns = {
|
||||
{"artifact", "path", "File system path to the generated patch."}};
|
||||
|
||||
schema.actions = {apply_action, asar_action, create_action};
|
||||
return schema;
|
||||
}
|
||||
|
||||
ResourceSchema MakeOverworldSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "overworld";
|
||||
schema.description = "Overworld tile inspection and manipulation commands.";
|
||||
|
||||
ResourceAction get_tile;
|
||||
get_tile.name = "get-tile";
|
||||
get_tile.synopsis = "z3ed overworld get-tile --map <map_id> --x <x> --y <y>";
|
||||
get_tile.stability = "stable";
|
||||
get_tile.arguments = {
|
||||
ResourceArgument{"--map", "integer", true, "Overworld map identifier (0-63)."},
|
||||
ResourceArgument{"--x", "integer", true, "Tile x coordinate."},
|
||||
ResourceArgument{"--y", "integer", true, "Tile y coordinate."},
|
||||
};
|
||||
get_tile.returns = {
|
||||
{"tile", "integer",
|
||||
"Tile id located at the supplied coordinates."}};
|
||||
|
||||
ResourceAction set_tile;
|
||||
set_tile.name = "set-tile";
|
||||
set_tile.synopsis =
|
||||
"z3ed overworld set-tile --map <map_id> --x <x> --y <y> --tile <tile_id>";
|
||||
set_tile.stability = "experimental";
|
||||
set_tile.arguments = {
|
||||
ResourceArgument{"--map", "integer", true, "Overworld map identifier (0-63)."},
|
||||
ResourceArgument{"--x", "integer", true, "Tile x coordinate."},
|
||||
ResourceArgument{"--y", "integer", true, "Tile y coordinate."},
|
||||
ResourceArgument{"--tile", "integer", true, "Tile id to write."},
|
||||
};
|
||||
set_tile.effects = {
|
||||
"Mutates overworld tile map and enqueues render invalidation."};
|
||||
|
||||
schema.actions = {get_tile, set_tile};
|
||||
return schema;
|
||||
}
|
||||
|
||||
ResourceSchema MakeDungeonSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "dungeon";
|
||||
schema.description = "Dungeon room export and inspection utilities.";
|
||||
|
||||
ResourceAction export_action;
|
||||
export_action.name = "export";
|
||||
export_action.synopsis = "z3ed dungeon export <room_id>";
|
||||
export_action.stability = "prototype";
|
||||
export_action.arguments = {
|
||||
ResourceArgument{"room_id", "integer", true,
|
||||
"Dungeon room identifier to inspect."},
|
||||
};
|
||||
export_action.effects = {
|
||||
"Loads the active ROM via --rom and prints metadata for the requested room."};
|
||||
export_action.returns = {
|
||||
{"metadata", "object",
|
||||
"Structured room summary including blockset, spriteset, palette, and layout."}};
|
||||
|
||||
ResourceAction list_objects_action;
|
||||
list_objects_action.name = "list-objects";
|
||||
list_objects_action.synopsis = "z3ed dungeon list-objects <room_id>";
|
||||
list_objects_action.stability = "prototype";
|
||||
list_objects_action.arguments = {
|
||||
ResourceArgument{"room_id", "integer", true,
|
||||
"Dungeon room identifier whose objects should be listed."},
|
||||
};
|
||||
list_objects_action.effects = {
|
||||
"Streams parsed dungeon object records for the requested room to stdout."};
|
||||
list_objects_action.returns = {
|
||||
{"objects", "array",
|
||||
"Collection of tile object records with ids, coordinates, and layers."}};
|
||||
|
||||
schema.actions = {export_action, list_objects_action};
|
||||
return schema;
|
||||
}
|
||||
|
||||
ResourceSchema MakeAgentSchema() {
|
||||
ResourceSchema schema;
|
||||
schema.resource = "agent";
|
||||
schema.description =
|
||||
"Agent workflow helpers including planning, diffing, and schema discovery.";
|
||||
|
||||
ResourceAction describe_action;
|
||||
describe_action.name = "describe";
|
||||
describe_action.synopsis = "z3ed agent describe --resource <name>";
|
||||
describe_action.stability = "prototype";
|
||||
describe_action.arguments = {
|
||||
ResourceArgument{"--resource", "string", false,
|
||||
"Optional resource name to filter results."},
|
||||
};
|
||||
describe_action.returns = {
|
||||
{"schema", "object",
|
||||
"JSON schema describing resource arguments and semantics."}};
|
||||
|
||||
schema.actions = {describe_action};
|
||||
return schema;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
const ResourceCatalog& ResourceCatalog::Instance() {
|
||||
static ResourceCatalog* instance = new ResourceCatalog();
|
||||
return *instance;
|
||||
}
|
||||
|
||||
ResourceCatalog::ResourceCatalog()
|
||||
: resources_({MakeRomSchema(), MakePatchSchema(), MakePaletteSchema(),
|
||||
MakeOverworldSchema(), MakeDungeonSchema(), MakeAgentSchema()}) {}
|
||||
|
||||
absl::StatusOr<ResourceSchema> ResourceCatalog::GetResource(absl::string_view name) const {
|
||||
for (const auto& resource : resources_) {
|
||||
if (resource.resource == name) {
|
||||
return resource;
|
||||
}
|
||||
}
|
||||
return absl::NotFoundError(absl::StrCat("Resource not found: ", name));
|
||||
}
|
||||
|
||||
const std::vector<ResourceSchema>& ResourceCatalog::AllResources() const { return resources_; }
|
||||
|
||||
std::string ResourceCatalog::SerializeResource(const ResourceSchema& schema) const {
|
||||
return SerializeResources({schema});
|
||||
}
|
||||
|
||||
std::string ResourceCatalog::SerializeResources(const std::vector<ResourceSchema>& schemas) const {
|
||||
std::vector<std::string> entries;
|
||||
entries.reserve(schemas.size());
|
||||
for (const auto& resource : schemas) {
|
||||
std::vector<std::string> action_entries;
|
||||
action_entries.reserve(resource.actions.size());
|
||||
for (const auto& action : resource.actions) {
|
||||
std::vector<std::string> arg_entries;
|
||||
arg_entries.reserve(action.arguments.size());
|
||||
for (const auto& arg : action.arguments) {
|
||||
arg_entries.push_back(absl::StrCat(
|
||||
"{\"flag\":\"", EscapeJson(arg.flag),
|
||||
"\",\"type\":\"", EscapeJson(arg.type),
|
||||
"\",\"required\":", arg.required ? "true" : "false",
|
||||
",\"description\":\"", EscapeJson(arg.description), "\"}"));
|
||||
}
|
||||
std::vector<std::string> effect_entries;
|
||||
effect_entries.reserve(action.effects.size());
|
||||
for (const auto& effect : action.effects) {
|
||||
effect_entries.push_back(absl::StrCat("\"", EscapeJson(effect), "\""));
|
||||
}
|
||||
std::vector<std::string> return_entries;
|
||||
return_entries.reserve(action.returns.size());
|
||||
for (const auto& ret : action.returns) {
|
||||
return_entries.push_back(absl::StrCat(
|
||||
"{\"field\":\"", EscapeJson(ret.field),
|
||||
"\",\"type\":\"", EscapeJson(ret.type),
|
||||
"\",\"description\":\"", EscapeJson(ret.description), "\"}"));
|
||||
}
|
||||
action_entries.push_back(absl::StrCat(
|
||||
"{\"name\":\"", EscapeJson(action.name),
|
||||
"\",\"synopsis\":\"", EscapeJson(action.synopsis),
|
||||
"\",\"stability\":\"", EscapeJson(action.stability),
|
||||
"\",\"arguments\":[", absl::StrJoin(arg_entries, ","), "],",
|
||||
"\"effects\":[", absl::StrJoin(effect_entries, ","), "],",
|
||||
"\"returns\":[", absl::StrJoin(return_entries, ","), "]}"));
|
||||
}
|
||||
entries.push_back(absl::StrCat(
|
||||
"{\"resource\":\"", EscapeJson(resource.resource),
|
||||
"\",\"description\":\"", EscapeJson(resource.description),
|
||||
"\",\"actions\":[", absl::StrJoin(action_entries, ","), "]}"));
|
||||
}
|
||||
return absl::StrCat("{\"resources\":[", absl::StrJoin(entries, ","), "]}");
|
||||
}
|
||||
|
||||
std::string ResourceCatalog::EscapeJson(absl::string_view value) {
|
||||
std::string out;
|
||||
out.reserve(value.size());
|
||||
for (char c : value) {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
out += "\\\\";
|
||||
break;
|
||||
case '\"':
|
||||
out += "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
out += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
out += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
out += "\\t";
|
||||
break;
|
||||
default:
|
||||
out += c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ResourceCatalog::SerializeResourcesAsYaml(
|
||||
const std::vector<ResourceSchema>& schemas,
|
||||
absl::string_view version,
|
||||
absl::string_view last_updated) const {
|
||||
std::string out;
|
||||
absl::StrAppend(&out, "# Auto-generated resource catalogue\n");
|
||||
absl::StrAppend(&out, "version: ", EscapeYaml(version), "\n");
|
||||
absl::StrAppend(&out, "last_updated: ", EscapeYaml(last_updated), "\n");
|
||||
absl::StrAppend(&out, "resources:\n");
|
||||
|
||||
for (const auto& resource : schemas) {
|
||||
absl::StrAppend(&out, " - name: ", EscapeYaml(resource.resource), "\n");
|
||||
absl::StrAppend(&out, " description: ", EscapeYaml(resource.description), "\n");
|
||||
|
||||
if (resource.actions.empty()) {
|
||||
absl::StrAppend(&out, " actions: []\n");
|
||||
continue;
|
||||
}
|
||||
|
||||
absl::StrAppend(&out, " actions:\n");
|
||||
for (const auto& action : resource.actions) {
|
||||
absl::StrAppend(&out, " - name: ", EscapeYaml(action.name), "\n");
|
||||
absl::StrAppend(&out, " synopsis: ", EscapeYaml(action.synopsis), "\n");
|
||||
absl::StrAppend(&out, " stability: ", EscapeYaml(action.stability), "\n");
|
||||
|
||||
if (action.arguments.empty()) {
|
||||
absl::StrAppend(&out, " args: []\n");
|
||||
} else {
|
||||
absl::StrAppend(&out, " args:\n");
|
||||
for (const auto& arg : action.arguments) {
|
||||
absl::StrAppend(&out, " - flag: ", EscapeYaml(arg.flag), "\n");
|
||||
absl::StrAppend(&out, " type: ", EscapeYaml(arg.type), "\n");
|
||||
absl::StrAppend(&out, " required: ", arg.required ? "true\n" : "false\n");
|
||||
absl::StrAppend(&out, " description: ", EscapeYaml(arg.description), "\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (action.effects.empty()) {
|
||||
absl::StrAppend(&out, " effects: []\n");
|
||||
} else {
|
||||
absl::StrAppend(&out, " effects:\n");
|
||||
for (const auto& effect : action.effects) {
|
||||
absl::StrAppend(&out, " - ", EscapeYaml(effect), "\n");
|
||||
}
|
||||
}
|
||||
|
||||
if (action.returns.empty()) {
|
||||
absl::StrAppend(&out, " returns: []\n");
|
||||
} else {
|
||||
absl::StrAppend(&out, " returns:\n");
|
||||
for (const auto& ret : action.returns) {
|
||||
absl::StrAppend(&out, " - field: ", EscapeYaml(ret.field), "\n");
|
||||
absl::StrAppend(&out, " type: ", EscapeYaml(ret.type), "\n");
|
||||
absl::StrAppend(&out, " description: ", EscapeYaml(ret.description), "\n");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
std::string ResourceCatalog::EscapeYaml(absl::string_view value) {
|
||||
std::string out;
|
||||
out.reserve(value.size() + 2);
|
||||
out.push_back('"');
|
||||
for (char c : value) {
|
||||
switch (c) {
|
||||
case '\\':
|
||||
out += "\\\\";
|
||||
break;
|
||||
case '"':
|
||||
out += "\\\"";
|
||||
break;
|
||||
case '\n':
|
||||
out += "\\n";
|
||||
break;
|
||||
case '\r':
|
||||
out += "\\r";
|
||||
break;
|
||||
case '\t':
|
||||
out += "\\t";
|
||||
break;
|
||||
default:
|
||||
out.push_back(c);
|
||||
break;
|
||||
}
|
||||
}
|
||||
out.push_back('"');
|
||||
return out;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
71
src/cli/service/resource_catalog.h
Normal file
71
src/cli/service/resource_catalog.h
Normal file
@@ -0,0 +1,71 @@
|
||||
#ifndef YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_
|
||||
#define YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "absl/strings/string_view.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
struct ResourceArgument {
|
||||
std::string flag;
|
||||
std::string type;
|
||||
bool required;
|
||||
std::string description;
|
||||
};
|
||||
|
||||
struct ResourceAction {
|
||||
std::string name;
|
||||
std::string synopsis;
|
||||
std::string stability;
|
||||
std::vector<ResourceArgument> arguments;
|
||||
std::vector<std::string> effects;
|
||||
struct ReturnValue {
|
||||
std::string field;
|
||||
std::string type;
|
||||
std::string description;
|
||||
};
|
||||
std::vector<ReturnValue> returns;
|
||||
};
|
||||
|
||||
struct ResourceSchema {
|
||||
std::string resource;
|
||||
std::string description;
|
||||
std::vector<ResourceAction> actions;
|
||||
};
|
||||
|
||||
// ResourceCatalog exposes a machine-readable description of CLI resources so that
|
||||
// both humans and AI agents can introspect capabilities at runtime.
|
||||
class ResourceCatalog {
|
||||
public:
|
||||
static const ResourceCatalog& Instance();
|
||||
|
||||
absl::StatusOr<ResourceSchema> GetResource(absl::string_view name) const;
|
||||
const std::vector<ResourceSchema>& AllResources() const;
|
||||
|
||||
// Serialize helpers for `z3ed agent describe`. These return compact JSON
|
||||
// strings so we avoid introducing a hard dependency on nlohmann::json.
|
||||
std::string SerializeResource(const ResourceSchema& schema) const;
|
||||
std::string SerializeResources(const std::vector<ResourceSchema>& schemas) const;
|
||||
std::string SerializeResourcesAsYaml(
|
||||
const std::vector<ResourceSchema>& schemas,
|
||||
absl::string_view version,
|
||||
absl::string_view last_updated) const;
|
||||
|
||||
private:
|
||||
ResourceCatalog();
|
||||
|
||||
static std::string EscapeJson(absl::string_view value);
|
||||
static std::string EscapeYaml(absl::string_view value);
|
||||
|
||||
std::vector<ResourceSchema> resources_;
|
||||
};
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
|
||||
#endif // YAZE_SRC_CLI_SERVICE_RESOURCE_CATALOG_H_
|
||||
212
src/cli/service/rom_sandbox_manager.cc
Normal file
212
src/cli/service/rom_sandbox_manager.cc
Normal file
@@ -0,0 +1,212 @@
|
||||
#include "cli/service/rom_sandbox_manager.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cstdlib>
|
||||
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "absl/strings/str_cat.h"
|
||||
#include "absl/strings/str_format.h"
|
||||
#include "absl/time/time.h"
|
||||
|
||||
#include "util/macro.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
namespace {
|
||||
|
||||
std::filesystem::path DetermineDefaultRoot() {
|
||||
if (const char* env_root = std::getenv("YAZE_SANDBOX_ROOT")) {
|
||||
return std::filesystem::path(env_root);
|
||||
}
|
||||
std::error_code ec;
|
||||
auto temp_dir = std::filesystem::temp_directory_path(ec);
|
||||
if (ec) {
|
||||
// Fallback to current working directory if temp is unavailable.
|
||||
return std::filesystem::current_path() / "yaze" / "sandboxes";
|
||||
}
|
||||
return temp_dir / "yaze" / "sandboxes";
|
||||
}
|
||||
|
||||
std::filesystem::path ResolveUniqueDirectory(
|
||||
const std::filesystem::path& root,
|
||||
absl::string_view id) {
|
||||
return root / std::string(id);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RomSandboxManager& RomSandboxManager::Instance() {
|
||||
static RomSandboxManager* instance = new RomSandboxManager();
|
||||
return *instance;
|
||||
}
|
||||
|
||||
RomSandboxManager::RomSandboxManager()
|
||||
: root_directory_(DetermineDefaultRoot()) {}
|
||||
|
||||
void RomSandboxManager::SetRootDirectory(const std::filesystem::path& root) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
root_directory_ = root;
|
||||
(void)EnsureRootExistsLocked();
|
||||
}
|
||||
|
||||
const std::filesystem::path& RomSandboxManager::RootDirectory() const {
|
||||
return root_directory_;
|
||||
}
|
||||
|
||||
absl::Status RomSandboxManager::EnsureRootExistsLocked() {
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::exists(root_directory_, ec)) {
|
||||
if (!std::filesystem::create_directories(root_directory_, ec) && ec) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Failed to create sandbox root at ", root_directory_.string(),
|
||||
": ", ec.message()));
|
||||
}
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
std::string RomSandboxManager::GenerateSandboxIdLocked() {
|
||||
absl::Time now = absl::Now();
|
||||
std::string time_component = absl::FormatTime("%Y%m%dT%H%M%S", now,
|
||||
absl::LocalTimeZone());
|
||||
++sequence_;
|
||||
return absl::StrCat(time_component, "-", sequence_);
|
||||
}
|
||||
|
||||
absl::StatusOr<RomSandboxManager::SandboxMetadata>
|
||||
RomSandboxManager::CreateSandbox(Rom& rom, absl::string_view description) {
|
||||
if (!rom.is_loaded()) {
|
||||
return absl::FailedPreconditionError(
|
||||
"Cannot create sandbox: ROM is not loaded");
|
||||
}
|
||||
|
||||
std::filesystem::path source_path(rom.filename());
|
||||
if (source_path.empty()) {
|
||||
return absl::FailedPreconditionError(
|
||||
"Cannot create sandbox: ROM filename is empty");
|
||||
}
|
||||
|
||||
std::unique_lock<std::mutex> lock(mutex_);
|
||||
RETURN_IF_ERROR(EnsureRootExistsLocked());
|
||||
|
||||
std::string id = GenerateSandboxIdLocked();
|
||||
std::filesystem::path sandbox_dir =
|
||||
ResolveUniqueDirectory(root_directory_, id);
|
||||
lock.unlock();
|
||||
|
||||
std::error_code ec;
|
||||
if (!std::filesystem::create_directories(sandbox_dir, ec) && ec) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Failed to create sandbox directory at ", sandbox_dir.string(),
|
||||
": ", ec.message()));
|
||||
}
|
||||
|
||||
std::filesystem::path sandbox_rom_path = sandbox_dir / source_path.filename();
|
||||
|
||||
Rom::SaveSettings settings;
|
||||
settings.filename = sandbox_rom_path.string();
|
||||
settings.save_new = false;
|
||||
settings.backup = false;
|
||||
settings.z3_save = true;
|
||||
|
||||
absl::Status save_status = rom.SaveToFile(settings);
|
||||
if (!save_status.ok()) {
|
||||
std::error_code cleanup_ec;
|
||||
std::filesystem::remove_all(sandbox_dir, cleanup_ec);
|
||||
return save_status;
|
||||
}
|
||||
|
||||
lock.lock();
|
||||
sandboxes_[id] = SandboxMetadata{
|
||||
.id = id,
|
||||
.directory = sandbox_dir,
|
||||
.rom_path = sandbox_rom_path,
|
||||
.source_rom = source_path.string(),
|
||||
.description = std::string(description),
|
||||
.created_at = absl::Now(),
|
||||
};
|
||||
active_sandbox_id_ = id;
|
||||
|
||||
return sandboxes_.at(id);
|
||||
}
|
||||
|
||||
absl::StatusOr<RomSandboxManager::SandboxMetadata>
|
||||
RomSandboxManager::ActiveSandbox() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
if (!active_sandbox_id_.has_value()) {
|
||||
return absl::NotFoundError("No active sandbox");
|
||||
}
|
||||
auto it = sandboxes_.find(*active_sandbox_id_);
|
||||
if (it == sandboxes_.end()) {
|
||||
return absl::NotFoundError("Active sandbox metadata missing");
|
||||
}
|
||||
return it->second;
|
||||
}
|
||||
|
||||
absl::StatusOr<std::filesystem::path>
|
||||
RomSandboxManager::ActiveSandboxRomPath() const {
|
||||
ASSIGN_OR_RETURN(auto meta, ActiveSandbox());
|
||||
return meta.rom_path;
|
||||
}
|
||||
|
||||
std::vector<RomSandboxManager::SandboxMetadata>
|
||||
RomSandboxManager::ListSandboxes() const {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
std::vector<SandboxMetadata> list;
|
||||
list.reserve(sandboxes_.size());
|
||||
for (const auto& [_, metadata] : sandboxes_) {
|
||||
list.push_back(metadata);
|
||||
}
|
||||
std::sort(list.begin(), list.end(),
|
||||
[](const SandboxMetadata& a, const SandboxMetadata& b) {
|
||||
return a.created_at < b.created_at;
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
absl::Status RomSandboxManager::RemoveSandbox(const std::string& id) {
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
auto it = sandboxes_.find(id);
|
||||
if (it == sandboxes_.end()) {
|
||||
return absl::NotFoundError("Sandbox not found");
|
||||
}
|
||||
std::error_code ec;
|
||||
std::filesystem::remove_all(it->second.directory, ec);
|
||||
if (ec) {
|
||||
return absl::InternalError(absl::StrCat(
|
||||
"Failed to remove sandbox directory: ", ec.message()));
|
||||
}
|
||||
sandboxes_.erase(it);
|
||||
if (active_sandbox_id_.has_value() && *active_sandbox_id_ == id) {
|
||||
active_sandbox_id_.reset();
|
||||
}
|
||||
return absl::OkStatus();
|
||||
}
|
||||
|
||||
absl::StatusOr<int> RomSandboxManager::CleanupOlderThan(absl::Duration max_age) {
|
||||
std::vector<std::string> to_remove;
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(mutex_);
|
||||
absl::Time threshold = absl::Now() - max_age;
|
||||
for (const auto& [id, metadata] : sandboxes_) {
|
||||
if (metadata.created_at < threshold) {
|
||||
to_remove.push_back(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int removed = 0;
|
||||
for (const auto& id : to_remove) {
|
||||
absl::Status status = RemoveSandbox(id);
|
||||
if (!status.ok()) {
|
||||
return status;
|
||||
}
|
||||
++removed;
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
92
src/cli/service/rom_sandbox_manager.h
Normal file
92
src/cli/service/rom_sandbox_manager.h
Normal file
@@ -0,0 +1,92 @@
|
||||
#ifndef YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_
|
||||
#define YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_
|
||||
|
||||
#include <filesystem>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
#include "absl/base/thread_annotations.h"
|
||||
#include "absl/status/status.h"
|
||||
#include "absl/status/statusor.h"
|
||||
#include "absl/strings/string_view.h"
|
||||
#include "absl/time/time.h"
|
||||
|
||||
#include "app/rom.h"
|
||||
|
||||
namespace yaze {
|
||||
namespace cli {
|
||||
|
||||
// RomSandboxManager coordinates creation and lifecycle management of sandboxed
|
||||
// ROM copies. Agent workflows operate on sandboxes so that proposals can be
|
||||
// reviewed before landing in the primary project ROM. The manager currently
|
||||
// tracks sandboxes in-memory for the running process and persists files to a
|
||||
// configurable root directory on disk.
|
||||
class RomSandboxManager {
|
||||
public:
|
||||
struct SandboxMetadata {
|
||||
std::string id;
|
||||
std::filesystem::path directory;
|
||||
std::filesystem::path rom_path;
|
||||
std::string source_rom;
|
||||
std::string description;
|
||||
absl::Time created_at;
|
||||
};
|
||||
|
||||
static RomSandboxManager& Instance();
|
||||
|
||||
// Set the root directory used for new sandboxes. Must be called before any
|
||||
// sandboxes are created. If not set, a default rooted at the system temporary
|
||||
// directory is used (or the value of the YAZE_SANDBOX_ROOT environment
|
||||
// variable when present).
|
||||
void SetRootDirectory(const std::filesystem::path& root);
|
||||
|
||||
const std::filesystem::path& RootDirectory() const;
|
||||
|
||||
// Creates a new sandbox by copying the provided ROM into a unique directory
|
||||
// under the root. The new sandbox becomes the active sandbox for the current
|
||||
// process. Metadata is returned describing the sandbox on success.
|
||||
absl::StatusOr<SandboxMetadata> CreateSandbox(Rom& rom,
|
||||
absl::string_view description);
|
||||
|
||||
// Returns the metadata for the active sandbox if one exists.
|
||||
absl::StatusOr<SandboxMetadata> ActiveSandbox() const;
|
||||
|
||||
// Returns the absolute path to the active sandbox ROM copy. Equivalent to
|
||||
// ActiveSandbox()->rom_path but with more descriptive errors when unset.
|
||||
absl::StatusOr<std::filesystem::path> ActiveSandboxRomPath() const;
|
||||
|
||||
// List all sandboxes tracked during the current process lifetime. This will
|
||||
// include the active sandbox (if any) and previously created sandboxes that
|
||||
// have not been cleaned up.
|
||||
std::vector<SandboxMetadata> ListSandboxes() const;
|
||||
|
||||
// Removes the sandbox identified by |id| from the index and deletes its on
|
||||
// disk directory. If the sandbox is currently active the active sandbox is
|
||||
// cleared. Missing sandboxes result in a NotFound status.
|
||||
absl::Status RemoveSandbox(const std::string& id);
|
||||
|
||||
// Deletes any sandboxes that are older than |max_age|, returning the number
|
||||
// of sandboxes removed. This is currently best-effort; individual removals
|
||||
// may produce errors which are aggregated into the returned status.
|
||||
absl::StatusOr<int> CleanupOlderThan(absl::Duration max_age);
|
||||
|
||||
private:
|
||||
RomSandboxManager();
|
||||
|
||||
absl::Status EnsureRootExistsLocked();
|
||||
std::string GenerateSandboxIdLocked();
|
||||
|
||||
std::filesystem::path root_directory_;
|
||||
mutable std::mutex mutex_;
|
||||
std::unordered_map<std::string, SandboxMetadata> sandboxes_;
|
||||
std::optional<std::string> active_sandbox_id_;
|
||||
int sequence_ ABSL_GUARDED_BY(mutex_) = 0;
|
||||
};
|
||||
|
||||
} // namespace cli
|
||||
} // namespace yaze
|
||||
|
||||
#endif // YAZE_SRC_CLI_SERVICE_ROM_SANDBOX_MANAGER_H_
|
||||
Reference in New Issue
Block a user