feat: Enhance ROM loading options and proposal management

- Introduced `RomLoadOptions` struct to manage various loading configurations for ROM files, including options for stripping headers, populating metadata, and loading Zelda 3 content.
- Updated `Rom::LoadFromFile` and `Rom::LoadFromData` methods to accept `RomLoadOptions`, allowing for more flexible ROM loading behavior.
- Implemented `MaybeStripSmcHeader` function to conditionally remove SMC headers from ROM data.
- Added new command handler `RomInfo` to display basic ROM information, including title and size.
- Created `ProposalRegistry` class to manage agent-generated proposals, including creation, logging, and status updates.
- Enhanced CLI commands to support proposal listing and detailed diff viewing, improving user interaction with agent-generated modifications.
- Updated resource catalog to include new actions for ROM info and agent proposal management.
This commit is contained in:
scawful
2025-10-01 18:18:48 -04:00
parent 04a4d04f4e
commit 02c6985201
13 changed files with 1373 additions and 72 deletions

View File

@@ -0,0 +1,294 @@
#include "cli/service/proposal_registry.h"
#include <algorithm>
#include <cstdlib>
#include <fstream>
#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_PROPOSAL_ROOT")) {
return std::filesystem::path(env_root);
}
std::error_code ec;
auto temp_dir = std::filesystem::temp_directory_path(ec);
if (ec) {
return std::filesystem::current_path() / "yaze" / "proposals";
}
return temp_dir / "yaze" / "proposals";
}
} // namespace
ProposalRegistry& ProposalRegistry::Instance() {
static ProposalRegistry* instance = new ProposalRegistry();
return *instance;
}
ProposalRegistry::ProposalRegistry()
: root_directory_(DetermineDefaultRoot()) {}
void ProposalRegistry::SetRootDirectory(const std::filesystem::path& root) {
std::lock_guard<std::mutex> lock(mutex_);
root_directory_ = root;
(void)EnsureRootExistsLocked();
}
const std::filesystem::path& ProposalRegistry::RootDirectory() const {
return root_directory_;
}
absl::Status ProposalRegistry::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 proposal root at ", root_directory_.string(),
": ", ec.message()));
}
}
return absl::OkStatus();
}
std::string ProposalRegistry::GenerateProposalIdLocked() {
absl::Time now = absl::Now();
std::string time_component = absl::FormatTime("%Y%m%dT%H%M%S", now,
absl::LocalTimeZone());
++sequence_;
return absl::StrCat("proposal-", time_component, "-", sequence_);
}
std::filesystem::path ProposalRegistry::ProposalDirectory(
absl::string_view proposal_id) const {
return root_directory_ / std::string(proposal_id);
}
absl::StatusOr<ProposalRegistry::ProposalMetadata>
ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
absl::string_view prompt,
absl::string_view description) {
std::unique_lock<std::mutex> lock(mutex_);
RETURN_IF_ERROR(EnsureRootExistsLocked());
std::string id = GenerateProposalIdLocked();
std::filesystem::path proposal_dir = ProposalDirectory(id);
lock.unlock();
std::error_code ec;
if (!std::filesystem::create_directories(proposal_dir, ec) && ec) {
return absl::InternalError(absl::StrCat(
"Failed to create proposal directory at ", proposal_dir.string(),
": ", ec.message()));
}
lock.lock();
proposals_[id] = ProposalMetadata{
.id = id,
.sandbox_id = std::string(sandbox_id),
.description = std::string(description),
.prompt = std::string(prompt),
.status = ProposalStatus::kPending,
.created_at = absl::Now(),
.reviewed_at = std::nullopt,
.diff_path = proposal_dir / "diff.txt",
.log_path = proposal_dir / "execution.log",
.screenshots = {},
.bytes_changed = 0,
.commands_executed = 0,
};
return proposals_.at(id);
}
absl::Status ProposalRegistry::RecordDiff(const std::string& proposal_id,
absl::string_view diff_content) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
std::ofstream diff_file(it->second.diff_path, std::ios::out);
if (!diff_file.is_open()) {
return absl::InternalError(absl::StrCat(
"Failed to open diff file: ", it->second.diff_path.string()));
}
diff_file << diff_content;
diff_file.close();
// Update bytes_changed metric (rough estimate based on diff size)
it->second.bytes_changed = static_cast<int>(diff_content.size());
return absl::OkStatus();
}
absl::Status ProposalRegistry::AppendLog(const std::string& proposal_id,
absl::string_view log_entry) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
std::ofstream log_file(it->second.log_path,
std::ios::out | std::ios::app);
if (!log_file.is_open()) {
return absl::InternalError(absl::StrCat(
"Failed to open log file: ", it->second.log_path.string()));
}
log_file << absl::FormatTime("[%Y-%m-%d %H:%M:%S] ", absl::Now(),
absl::LocalTimeZone())
<< log_entry << "\n";
log_file.close();
return absl::OkStatus();
}
absl::Status ProposalRegistry::AddScreenshot(
const std::string& proposal_id,
const std::filesystem::path& screenshot_path) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
// Verify screenshot exists
std::error_code ec;
if (!std::filesystem::exists(screenshot_path, ec)) {
return absl::NotFoundError(
absl::StrCat("Screenshot file not found: ", screenshot_path.string()));
}
it->second.screenshots.push_back(screenshot_path);
return absl::OkStatus();
}
absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id,
ProposalStatus status) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
it->second.status = status;
it->second.reviewed_at = absl::Now();
return absl::OkStatus();
}
absl::StatusOr<ProposalRegistry::ProposalMetadata>
ProposalRegistry::GetProposal(const std::string& proposal_id) const {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
return it->second;
}
std::vector<ProposalRegistry::ProposalMetadata>
ProposalRegistry::ListProposals(std::optional<ProposalStatus> filter_status) const {
std::lock_guard<std::mutex> lock(mutex_);
std::vector<ProposalMetadata> result;
for (const auto& [id, metadata] : proposals_) {
if (!filter_status.has_value() || metadata.status == *filter_status) {
result.push_back(metadata);
}
}
// Sort by creation time (newest first)
std::sort(result.begin(), result.end(),
[](const ProposalMetadata& a, const ProposalMetadata& b) {
return a.created_at > b.created_at;
});
return result;
}
absl::StatusOr<ProposalRegistry::ProposalMetadata>
ProposalRegistry::GetLatestPendingProposal() const {
std::lock_guard<std::mutex> lock(mutex_);
const ProposalMetadata* latest = nullptr;
for (const auto& [id, metadata] : proposals_) {
if (metadata.status == ProposalStatus::kPending) {
if (!latest || metadata.created_at > latest->created_at) {
latest = &metadata;
}
}
}
if (!latest) {
return absl::NotFoundError("No pending proposals found");
}
return *latest;
}
absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) {
std::lock_guard<std::mutex> lock(mutex_);
auto it = proposals_.find(proposal_id);
if (it == proposals_.end()) {
return absl::NotFoundError(
absl::StrCat("Proposal not found: ", proposal_id));
}
std::filesystem::path proposal_dir = ProposalDirectory(proposal_id);
std::error_code ec;
std::filesystem::remove_all(proposal_dir, ec);
if (ec) {
return absl::InternalError(absl::StrCat(
"Failed to remove proposal directory: ", ec.message()));
}
proposals_.erase(it);
return absl::OkStatus();
}
absl::StatusOr<int> ProposalRegistry::CleanupOlderThan(absl::Duration max_age) {
std::lock_guard<std::mutex> lock(mutex_);
absl::Time cutoff = absl::Now() - max_age;
int removed_count = 0;
std::vector<std::string> to_remove;
for (const auto& [id, metadata] : proposals_) {
if (metadata.created_at < cutoff) {
to_remove.push_back(id);
}
}
for (const auto& id : to_remove) {
std::filesystem::path proposal_dir = ProposalDirectory(id);
std::error_code ec;
std::filesystem::remove_all(proposal_dir, ec);
// Continue even if removal fails
proposals_.erase(id);
++removed_count;
}
return removed_count;
}
} // namespace cli
} // namespace yaze

View File

@@ -0,0 +1,123 @@
#ifndef YAZE_SRC_CLI_SERVICE_PROPOSAL_REGISTRY_H_
#define YAZE_SRC_CLI_SERVICE_PROPOSAL_REGISTRY_H_
#include <filesystem>
#include <memory>
#include <mutex>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include "absl/status/status.h"
#include "absl/status/statusor.h"
#include "absl/strings/string_view.h"
#include "absl/time/time.h"
namespace yaze {
namespace cli {
// ProposalRegistry tracks agent-generated ROM modification proposals,
// storing metadata, diffs, execution logs, and optional screenshots for
// human review. Each proposal is associated with a sandbox ROM copy.
//
// Proposals follow a lifecycle:
// 1. Created - agent generates a proposal during `agent run`
// 2. Reviewed - user inspects via `agent diff` or TUI
// 3. Accepted - changes committed to main ROM via `agent commit`
// 4. Rejected - sandbox cleaned up via `agent revert`
class ProposalRegistry {
public:
enum class ProposalStatus {
kPending, // Created but not yet reviewed
kAccepted, // User accepted the changes
kRejected, // User rejected the changes
};
struct ProposalMetadata {
std::string id;
std::string sandbox_id;
std::string description;
std::string prompt; // Original agent prompt that created this proposal
ProposalStatus status;
absl::Time created_at;
std::optional<absl::Time> reviewed_at;
// File paths relative to proposal directory
std::filesystem::path diff_path;
std::filesystem::path log_path;
std::vector<std::filesystem::path> screenshots;
// Statistics
int bytes_changed;
int commands_executed;
};
static ProposalRegistry& Instance();
// Set the root directory for storing proposal data. If not set, uses
// YAZE_PROPOSAL_ROOT env var or defaults to system temp directory.
void SetRootDirectory(const std::filesystem::path& root);
const std::filesystem::path& RootDirectory() const;
// Creates a new proposal linked to the given sandbox. The proposal directory
// is created under the root, and metadata is initialized.
absl::StatusOr<ProposalMetadata> CreateProposal(
absl::string_view sandbox_id,
absl::string_view prompt,
absl::string_view description);
// Records a diff between original and modified ROM for the proposal.
// The diff content is written to a file within the proposal directory.
absl::Status RecordDiff(const std::string& proposal_id,
absl::string_view diff_content);
// Appends log output from command execution to the proposal's log file.
absl::Status AppendLog(const std::string& proposal_id,
absl::string_view log_entry);
// Adds a screenshot path to the proposal metadata. Screenshots should
// be copied into the proposal directory before calling this.
absl::Status AddScreenshot(const std::string& proposal_id,
const std::filesystem::path& screenshot_path);
// Updates the proposal status (pending -> accepted/rejected) and sets
// the review timestamp.
absl::Status UpdateStatus(const std::string& proposal_id,
ProposalStatus status);
// Returns the metadata for a specific proposal.
absl::StatusOr<ProposalMetadata> GetProposal(
const std::string& proposal_id) const;
// Lists all proposals, optionally filtered by status.
std::vector<ProposalMetadata> ListProposals(
std::optional<ProposalStatus> filter_status = std::nullopt) const;
// Returns the most recently created proposal that is still pending.
absl::StatusOr<ProposalMetadata> GetLatestPendingProposal() const;
// Removes a proposal and its associated files from disk and memory.
absl::Status RemoveProposal(const std::string& proposal_id);
// Deletes all proposals older than the specified age.
absl::StatusOr<int> CleanupOlderThan(absl::Duration max_age);
private:
ProposalRegistry();
absl::Status EnsureRootExistsLocked();
std::string GenerateProposalIdLocked();
std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const;
std::filesystem::path root_directory_;
mutable std::mutex mutex_;
std::unordered_map<std::string, ProposalMetadata> proposals_;
int sequence_ = 0;
};
} // namespace cli
} // namespace yaze
#endif // YAZE_SRC_CLI_SERVICE_PROPOSAL_REGISTRY_H_

View File

@@ -51,6 +51,20 @@ ResourceSchema MakeRomSchema() {
schema.resource = "rom";
schema.description = "ROM validation, diffing, and snapshot helpers.";
ResourceAction info_action;
info_action.name = "info";
info_action.synopsis = "z3ed rom info --rom <file>";
info_action.stability = "stable";
info_action.arguments = {
ResourceArgument{"--rom", "path", true, "Path to ROM file configured via global flag."},
};
info_action.effects = {
"Reads ROM from disk and displays basic information (title, size, filename)."};
info_action.returns = {
{"title", "string", "ROM internal title from header."},
{"size", "integer", "ROM file size in bytes."},
{"filename", "string", "Full path to the ROM file."}};
ResourceAction validate_action;
validate_action.name = "validate";
validate_action.synopsis = "z3ed rom validate --rom <file>";
@@ -91,7 +105,7 @@ ResourceSchema MakeRomSchema() {
generate_action.returns = {
{"artifact", "path", "Absolute path to the generated golden image."}};
schema.actions = {validate_action, diff_action, generate_action};
schema.actions = {info_action, validate_action, diff_action, generate_action};
return schema;
}
@@ -231,7 +245,7 @@ ResourceSchema MakeAgentSchema() {
ResourceSchema schema;
schema.resource = "agent";
schema.description =
"Agent workflow helpers including planning, diffing, and schema discovery.";
"Agent workflow helpers including planning, diffing, listing, and schema discovery.";
ResourceAction describe_action;
describe_action.name = "describe";
@@ -245,7 +259,31 @@ ResourceSchema MakeAgentSchema() {
{"schema", "object",
"JSON schema describing resource arguments and semantics."}};
schema.actions = {describe_action};
ResourceAction list_action;
list_action.name = "list";
list_action.synopsis = "z3ed agent list";
list_action.stability = "prototype";
list_action.arguments = {};
list_action.effects = {{"reads", "proposal_registry"}};
list_action.returns = {
{"proposals", "array",
"List of all proposals with ID, status, prompt, and metadata."}};
ResourceAction diff_action;
diff_action.name = "diff";
diff_action.synopsis = "z3ed agent diff [--proposal-id <id>]";
diff_action.stability = "prototype";
diff_action.arguments = {
ResourceArgument{"--proposal-id", "string", false,
"Optional proposal ID to view specific proposal. Defaults to latest pending."},
};
diff_action.effects = {{"reads", "proposal_registry"}, {"reads", "sandbox"}};
diff_action.returns = {
{"diff", "string", "Unified diff showing changes to ROM."},
{"log", "string", "Execution log of commands run."},
{"metadata", "object", "Proposal metadata including status and timestamps."}};
schema.actions = {describe_action, list_action, diff_action};
return schema;
}