diff --git a/CMakePresets.json b/CMakePresets.json index 4365987a..1ef28b34 100644 --- a/CMakePresets.json +++ b/CMakePresets.json @@ -75,7 +75,8 @@ "YAZE_TEST_ROM_PATH": "${sourceDir}/zelda3.sfc", "YAZE_WITH_JSON": "ON", "YAZE_WITH_GRPC": "ON", - "YAZE_BUILD_Z3ED": "ON" + "YAZE_BUILD_Z3ED": "ON", + "YAZE_BUILD_EMU": "ON" } }, { diff --git a/src/cli/handlers/agent/general_commands.cc b/src/cli/handlers/agent/general_commands.cc index f34f98dd..17e51269 100644 --- a/src/cli/handlers/agent/general_commands.cc +++ b/src/cli/handlers/agent/general_commands.cc @@ -1,8 +1,10 @@ #include "cli/handlers/agent/commands.h" #include +#include #include #include +#include #include #include #include @@ -154,7 +156,8 @@ absl::Status HandleRunCommand(const std::vector& arg_vec, if (!response_or.ok()) { return response_or.status(); } - std::vector commands = response_or.value().commands; + AgentResponse response = std::move(response_or.value()); + const std::vector& commands = response.commands; // 3. Generate a structured proposal from the commands Tile16ProposalGenerator generator; @@ -186,11 +189,77 @@ absl::Status HandleRunCommand(const std::vector& arg_vec, absl::StrCat("Failed to save sandbox ROM: ", save_status.message())); } - // 6. Save the proposal metadata for later use (accept/reject) - // For now, we'll just use the proposal generator's save function. - // A better approach would be to integrate with ProposalRegistry. - auto proposal_path = - RomSandboxManager::Instance().RootDirectory() / (proposal.id + ".json"); + // 6. Persist the proposal metadata and artifacts. + auto& registry = ProposalRegistry::Instance(); + + int executed_commands = 0; + for (const auto& command : commands) { + if (command.empty() || command[0] == '#') { + continue; + } + ++executed_commands; + } + + std::string description = absl::StrFormat( + "Tile16 overworld edits (%d change%s)", proposal.changes.size(), + proposal.changes.size() == 1 ? "" : "s"); + + ASSIGN_OR_RETURN( + auto metadata, + registry.CreateProposal(sandbox.id, sandbox.rom_path, prompt, description)); + + proposal.id = metadata.id; + + std::ostringstream diff_stream; + diff_stream << "Tile16 Proposal ID: " << metadata.id << "\n"; + diff_stream << "Sandbox ID: " << sandbox.id << "\n"; + diff_stream << "Sandbox ROM: " << sandbox.rom_path << "\n\n"; + diff_stream << "Changes (" << proposal.changes.size() << "):\n"; + for (const auto& change : proposal.changes) { + diff_stream << " - " << change.ToString() << "\n"; + } + + RETURN_IF_ERROR(registry.RecordDiff(metadata.id, diff_stream.str())); + + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, absl::StrCat("Prompt: ", prompt))); + + if (!response.text_response.empty()) { + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, absl::StrCat("AI Response: ", response.text_response))); + } + + if (!response.reasoning.empty()) { + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, absl::StrCat("Reasoning: ", response.reasoning))); + } + + if (!response.tool_calls.empty()) { + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, + absl::StrCat("Tool Calls: ", response.tool_calls.size()))); + } + + for (const auto& command : commands) { + if (command.empty() || command[0] == '#') { + continue; + } + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, absl::StrCat("Command: ", command))); + } + + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, + absl::StrCat("Sandbox ROM saved to ", sandbox.rom_path.string()))); + + RETURN_IF_ERROR( + registry.UpdateCommandStats(metadata.id, executed_commands)); + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, + absl::StrCat("Commands executed: ", executed_commands))); + + std::filesystem::path proposal_dir = metadata.log_path.parent_path(); + std::filesystem::path proposal_path = proposal_dir / "proposal.json"; auto save_proposal_status = generator.SaveProposal(proposal, proposal_path.string()); if (!save_proposal_status.ok()) { @@ -198,16 +267,22 @@ absl::Status HandleRunCommand(const std::vector& arg_vec, save_proposal_status.message())); } + RETURN_IF_ERROR(registry.AppendLog( + metadata.id, + absl::StrCat("Saved proposal JSON to ", proposal_path.string()))); + std::cout << "✅ Agent successfully planned and executed changes in a sandbox." << std::endl; - std::cout << " Proposal ID: " << proposal.id << std::endl; + std::cout << " Proposal ID: " << metadata.id << std::endl; std::cout << " Sandbox ROM: " << sandbox.rom_path << std::endl; - std::cout << " Proposal file: " << proposal_path << std::endl; + std::cout << " Proposal dir: " << proposal_dir << std::endl; + std::cout << " Diff file: " << metadata.diff_path << std::endl; + std::cout << " Log file: " << metadata.log_path << std::endl; std::cout << "\nTo review the changes, run:\n"; - std::cout << " z3ed agent diff --proposal-id " << proposal.id << std::endl; + std::cout << " z3ed agent diff --proposal-id " << metadata.id << std::endl; std::cout << "\nTo accept the changes, run:\n"; - std::cout << " z3ed agent accept --proposal-id " << proposal.id << std::endl; + std::cout << " z3ed agent accept --proposal-id " << metadata.id << std::endl; return absl::OkStatus(); } @@ -287,6 +362,14 @@ absl::Status HandleDiffCommand(Rom& rom, const std::vector& args) { std::cout << "Commands Executed: " << proposal.commands_executed << "\n"; std::cout << "Bytes Changed: " << proposal.bytes_changed << "\n\n"; + if (!proposal.sandbox_rom_path.empty()) { + std::cout << "Sandbox ROM: " << proposal.sandbox_rom_path << "\n"; + } + std::cout << "Proposal directory: " + << proposal.log_path.parent_path() << "\n"; + std::cout << "Diff file: " << proposal.diff_path << "\n"; + std::cout << "Log file: " << proposal.log_path << "\n\n"; + if (std::filesystem::exists(proposal.diff_path)) { std::cout << "--- Diff Content ---\n"; std::ifstream diff_file(proposal.diff_path); @@ -490,45 +573,90 @@ absl::Status HandleChatCommand(Rom& rom) { absl::Status HandleAcceptCommand(const std::vector& arg_vec, Rom& rom) { - if (arg_vec.empty() || arg_vec[0] != "--proposal-id") { + std::optional proposal_id; + for (size_t i = 0; i < arg_vec.size(); ++i) { + const std::string& token = arg_vec[i]; + if (absl::StartsWith(token, "--proposal-id=")) { + proposal_id = token.substr(14); + break; + } + if (token == "--proposal-id" && i + 1 < arg_vec.size()) { + proposal_id = arg_vec[i + 1]; + break; + } + } + + if (!proposal_id.has_value() || proposal_id->empty()) { return absl::InvalidArgumentError( "Usage: agent accept --proposal-id "); } - std::string proposal_id = arg_vec[1]; - // 1. Load the proposal from disk. - Tile16ProposalGenerator generator; - auto proposal_path = - RomSandboxManager::Instance().RootDirectory() / (proposal_id + ".json"); - auto proposal_or = generator.LoadProposal(proposal_path.string()); - if (!proposal_or.ok()) { - return absl::InternalError( - absl::StrCat("Failed to load proposal file '", proposal_path.string(), - "': ", proposal_or.status().message())); + auto& registry = ProposalRegistry::Instance(); + ASSIGN_OR_RETURN(auto metadata, registry.GetProposal(*proposal_id)); + + if (metadata.status == ProposalRegistry::ProposalStatus::kAccepted) { + std::cout << "Proposal '" << *proposal_id << "' is already accepted." + << std::endl; + return absl::OkStatus(); } - auto proposal = proposal_or.value(); - // 2. Ensure the main ROM is loaded. - RETURN_IF_ERROR(EnsureRomLoaded(rom, "agent accept --proposal-id ")); + if (metadata.sandbox_rom_path.empty()) { + return absl::FailedPreconditionError(absl::StrCat( + "Proposal '", *proposal_id, + "' is missing sandbox ROM metadata. Cannot accept.")); + } - // 3. Apply the proposal to the main ROM. - auto apply_status = generator.ApplyProposal(proposal, &rom); - if (!apply_status.ok()) { + if (!std::filesystem::exists(metadata.sandbox_rom_path)) { + return absl::NotFoundError(absl::StrCat( + "Sandbox ROM not found at ", metadata.sandbox_rom_path.string())); + } + + RETURN_IF_ERROR( + EnsureRomLoaded(rom, "agent accept --proposal-id ")); + + Rom sandbox_rom; + auto sandbox_load_status = sandbox_rom.LoadFromFile( + metadata.sandbox_rom_path.string(), RomLoadOptions::CliDefaults()); + if (!sandbox_load_status.ok()) { return absl::InternalError(absl::StrCat( - "Failed to apply proposal to main ROM: ", apply_status.message())); + "Failed to load sandbox ROM: ", sandbox_load_status.message())); + } + + if (rom.size() != sandbox_rom.size()) { + rom.Expand(static_cast(sandbox_rom.size())); + } + + auto copy_status = rom.WriteVector(0, sandbox_rom.vector()); + if (!copy_status.ok()) { + return absl::InternalError(absl::StrCat( + "Failed to copy sandbox ROM data: ", copy_status.message())); } - // 4. Save the changes to the main ROM file. auto save_status = rom.SaveToFile({.save_new = false}); if (!save_status.ok()) { return absl::InternalError(absl::StrCat( "Failed to save changes to main ROM: ", save_status.message())); } - std::cout << "✅ Proposal '" << proposal_id << "' accepted and applied to '" - << rom.filename() << "'." << std::endl; + RETURN_IF_ERROR(registry.UpdateStatus( + *proposal_id, ProposalRegistry::ProposalStatus::kAccepted)); + RETURN_IF_ERROR(registry.AppendLog( + *proposal_id, + absl::StrCat("Proposal accepted and applied to ", rom.filename()))); - // TODO: Clean up sandbox and proposal files. + if (!metadata.sandbox_id.empty()) { + auto remove_status = + RomSandboxManager::Instance().RemoveSandbox(metadata.sandbox_id); + if (!remove_status.ok()) { + std::cerr << "Warning: Failed to remove sandbox '" << metadata.sandbox_id + << "': " << remove_status.message() << "\n"; + } + } + + std::cout << "✅ Proposal '" << *proposal_id << "' accepted and applied to '" + << rom.filename() << "'." << std::endl; + std::cout << " Source sandbox ROM: " << metadata.sandbox_rom_path + << std::endl; return absl::OkStatus(); } diff --git a/src/cli/handlers/agent/test_commands.cc b/src/cli/handlers/agent/test_commands.cc index 20394fc8..ebb35792 100644 --- a/src/cli/handlers/agent/test_commands.cc +++ b/src/cli/handlers/agent/test_commands.cc @@ -1,12 +1,21 @@ #include "cli/handlers/agent/commands.h" +#include +#include #include +#include #include #include #include "absl/status/status.h" +#include "absl/status/statusor.h" +#include "absl/strings/numbers.h" #include "absl/strings/str_cat.h" +#include "absl/strings/str_format.h" +#include "absl/time/time.h" #include "cli/handlers/agent/common.h" +#include "nlohmann/json.hpp" +#include "util/macro.h" #ifdef YAZE_WITH_GRPC #include "cli/service/gui/gui_automation_client.h" @@ -19,6 +28,100 @@ namespace agent { #ifdef YAZE_WITH_GRPC +namespace { + +struct RecordingState { + std::string recording_id; + std::string host = "localhost"; + int port = 50052; + std::string output_path; +}; + +std::filesystem::path RecordingStateFilePath() { + std::error_code ec; + std::filesystem::path base = + std::filesystem::temp_directory_path(ec); + if (ec) { + base = std::filesystem::current_path(); + } + return base / "yaze" / "agent" / "recording_state.json"; +} + +absl::Status SaveRecordingState(const RecordingState& state) { + auto path = RecordingStateFilePath(); + std::error_code ec; + std::filesystem::create_directories(path.parent_path(), ec); + nlohmann::json json; + json["recording_id"] = state.recording_id; + json["host"] = state.host; + json["port"] = state.port; + json["output_path"] = state.output_path; + + std::ofstream out(path, std::ios::out | std::ios::trunc); + if (!out.is_open()) { + return absl::InternalError(absl::StrCat("Failed to write recording state to ", + path.string())); + } + out << json.dump(2); + if (!out.good()) { + return absl::InternalError( + absl::StrCat("Failed to flush recording state to ", path.string())); + } + return absl::OkStatus(); +} + +absl::StatusOr LoadRecordingState() { + auto path = RecordingStateFilePath(); + std::ifstream in(path); + if (!in.is_open()) { + return absl::NotFoundError("No active recording session found. Run 'z3ed agent test record start' first."); + } + + nlohmann::json json; + try { + in >> json; + } catch (const nlohmann::json::parse_error& error) { + return absl::InternalError( + absl::StrCat("Failed to parse recording state at ", path.string(), + ": ", error.what())); + } + + RecordingState state; + state.recording_id = json.value("recording_id", ""); + state.host = json.value("host", "localhost"); + state.port = json.value("port", 50052); + state.output_path = json.value("output_path", ""); + + if (state.recording_id.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("Recording state at ", path.string(), + " is missing a recording_id")); + } + + return state; +} + +absl::Status ClearRecordingState() { + auto path = RecordingStateFilePath(); + std::error_code ec; + std::filesystem::remove(path, ec); + if (ec && ec != std::errc::no_such_file_or_directory) { + return absl::InternalError(absl::StrCat("Failed to clear recording state: ", + ec.message())); + } + return absl::OkStatus(); +} + +std::string DefaultRecordingOutputPath() { + absl::Time now = absl::Now(); + return absl::StrCat("tests/gui/recording-", + absl::FormatTime("%Y%m%dT%H%M%S", now, + absl::LocalTimeZone()), + ".json"); +} + +} // namespace + // Forward declarations for subcommand handlers absl::Status HandleTestRunCommand(const std::vector& args); absl::Status HandleTestReplayCommand(const std::vector& args); @@ -307,7 +410,10 @@ absl::Status HandleTestResultsCommand(const std::vector& args) { absl::Status HandleTestRecordCommand(const std::vector& args) { if (args.empty()) { return absl::InvalidArgumentError( - "Usage: agent test record [--output ]"); + "Usage: agent test record [options]\n" + " start [--output ] [--description ] [--session ]\n" + " [--host ] [--port ]\n" + " stop [--validate] [--discard] [--host ] [--port ]"); } std::string action = args[0]; @@ -315,10 +421,167 @@ absl::Status HandleTestRecordCommand(const std::vector& args) { return absl::InvalidArgumentError("Record action must be 'start' or 'stop'"); } - // TODO: Implement recording functionality - return absl::UnimplementedError( - "Test recording is not yet implemented.\n" - "This feature will allow capturing GUI interactions for replay."); + if (action == "start") { + std::string host = "localhost"; + int port = 50052; + std::string description; + std::string session_name; + std::string output_path; + + for (size_t i = 1; i < args.size(); ++i) { + const std::string& token = args[i]; + if (token == "--output" && i + 1 < args.size()) { + output_path = args[++i]; + } else if (token == "--description" && i + 1 < args.size()) { + description = args[++i]; + } else if (token == "--session" && i + 1 < args.size()) { + session_name = args[++i]; + } else if (token == "--host" && i + 1 < args.size()) { + host = args[++i]; + } else if (token == "--port" && i + 1 < args.size()) { + std::string port_value = args[++i]; + int parsed_port = 0; + if (!absl::SimpleAtoi(port_value, &parsed_port)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid --port value: ", port_value)); + } + port = parsed_port; + } + } + + if (output_path.empty()) { + output_path = DefaultRecordingOutputPath(); + } + + std::filesystem::path absolute_output = + std::filesystem::absolute(output_path); + std::error_code ec; + std::filesystem::create_directories(absolute_output.parent_path(), ec); + + GuiAutomationClient client(absl::StrCat(host, ":", port)); + RETURN_IF_ERROR(client.Connect()); + + if (session_name.empty()) { + session_name = std::filesystem::path(output_path).stem().string(); + } + + ASSIGN_OR_RETURN(auto start_result, + client.StartRecording(absolute_output.string(), + session_name, description)); + if (!start_result.success) { + return absl::InternalError( + absl::StrCat("Harness rejected start-recording request: ", + start_result.message)); + } + + RecordingState state; + state.recording_id = start_result.recording_id; + state.host = host; + state.port = port; + state.output_path = absolute_output.string(); + RETURN_IF_ERROR(SaveRecordingState(state)); + + std::cout << "\n=== Recording Session Started ===\n"; + std::cout << "Recording ID: " << start_result.recording_id << "\n"; + std::cout << "Server: " << host << ":" << port << "\n"; + std::cout << "Output: " << absolute_output << "\n"; + if (!description.empty()) { + std::cout << "Description: " << description << "\n"; + } + if (start_result.started_at.has_value()) { + std::cout << "Started: " + << absl::FormatTime("%Y-%m-%d %H:%M:%S", + *start_result.started_at, + absl::LocalTimeZone()) + << "\n"; + } + std::cout << "\nPress Ctrl+C to abort the recording session.\n"; + + return absl::OkStatus(); + } + + // Stop + bool validate = false; + bool discard = false; + std::optional host_override; + std::optional port_override; + + for (size_t i = 1; i < args.size(); ++i) { + const std::string& token = args[i]; + if (token == "--validate") { + validate = true; + } else if (token == "--discard") { + discard = true; + } else if (token == "--host" && i + 1 < args.size()) { + host_override = args[++i]; + } else if (token == "--port" && i + 1 < args.size()) { + std::string port_value = args[++i]; + int parsed_port = 0; + if (!absl::SimpleAtoi(port_value, &parsed_port)) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid --port value: ", port_value)); + } + port_override = parsed_port; + } + } + + if (discard && validate) { + return absl::InvalidArgumentError( + "Cannot use --validate and --discard together"); + } + + ASSIGN_OR_RETURN(auto state, LoadRecordingState()); + if (host_override.has_value()) { + state.host = *host_override; + } + if (port_override.has_value()) { + state.port = *port_override; + } + + GuiAutomationClient client(absl::StrCat(state.host, ":", state.port)); + RETURN_IF_ERROR(client.Connect()); + + ASSIGN_OR_RETURN(auto stop_result, + client.StopRecording(state.recording_id, discard)); + if (!stop_result.success) { + return absl::InternalError( + absl::StrCat("Stop recording failed: ", stop_result.message)); + } + RETURN_IF_ERROR(ClearRecordingState()); + + std::cout << "\n=== Recording Session Completed ===\n"; + std::cout << "Recording ID: " << state.recording_id << "\n"; + std::cout << "Server: " << state.host << ":" << state.port << "\n"; + std::cout << "Steps captured: " << stop_result.step_count << "\n"; + std::cout << "Duration: " << stop_result.duration.count() << " ms\n"; + if (!stop_result.message.empty()) { + std::cout << "Message: " << stop_result.message << "\n"; + } + if (!discard && !stop_result.output_path.empty()) { + std::cout << "Output saved to: " << stop_result.output_path << "\n"; + } + + if (discard) { + std::cout << "Recording discarded; no script file was produced." << std::endl; + return absl::OkStatus(); + } + + if (!validate || stop_result.output_path.empty()) { + std::cout << std::endl; + return absl::OkStatus(); + } + + std::cout << "\nReplaying recorded script to validate...\n"; + ASSIGN_OR_RETURN(auto replay_result, + client.ReplayTest(stop_result.output_path, false, {})); + if (!replay_result.success) { + return absl::InternalError( + absl::StrCat("Replay failed: ", replay_result.message)); + } + + std::cout << "Replay succeeded. Steps executed: " + << replay_result.steps_executed << "\n"; + return absl::OkStatus(); } #endif // YAZE_WITH_GRPC diff --git a/src/cli/service/gui/gui_automation_client.cc b/src/cli/service/gui/gui_automation_client.cc index d2cc8e6b..b6d76eba 100644 --- a/src/cli/service/gui/gui_automation_client.cc +++ b/src/cli/service/gui/gui_automation_client.cc @@ -186,6 +186,74 @@ absl::StatusOr GuiAutomationClient::ReplayTest( #endif } +absl::StatusOr GuiAutomationClient::StartRecording( + const std::string& output_path, const std::string& session_name, + const std::string& description) { +#ifdef YAZE_WITH_GRPC + if (!stub_) { + return absl::FailedPreconditionError("Not connected. Call Connect() first."); + } + + yaze::test::StartRecordingRequest request; + request.set_output_path(output_path); + request.set_session_name(session_name); + request.set_description(description); + + yaze::test::StartRecordingResponse response; + grpc::ClientContext context; + grpc::Status status = stub_->StartRecording(&context, request, &response); + + if (!status.ok()) { + return absl::InternalError( + absl::StrCat("StartRecording RPC failed: ", status.error_message())); + } + + StartRecordingResult result; + result.success = response.success(); + result.message = response.message(); + result.recording_id = response.recording_id(); + result.started_at = OptionalTimeFromMillis(response.started_at_ms()); + return result; +#else + return absl::UnimplementedError("gRPC not available"); +#endif +} + +absl::StatusOr GuiAutomationClient::StopRecording( + const std::string& recording_id, bool discard) { +#ifdef YAZE_WITH_GRPC + if (!stub_) { + return absl::FailedPreconditionError("Not connected. Call Connect() first."); + } + if (recording_id.empty()) { + return absl::InvalidArgumentError("recording_id must not be empty"); + } + + yaze::test::StopRecordingRequest request; + request.set_recording_id(recording_id); + request.set_discard(discard); + + yaze::test::StopRecordingResponse response; + grpc::ClientContext context; + grpc::Status status = stub_->StopRecording(&context, request, &response); + + if (!status.ok()) { + return absl::InternalError( + absl::StrCat("StopRecording RPC failed: ", status.error_message())); + } + + StopRecordingResult result; + result.success = response.success(); + result.message = response.message(); + result.output_path = response.output_path(); + result.step_count = response.step_count(); + result.duration = std::chrono::milliseconds(response.duration_ms()); + return result; +#else + return absl::UnimplementedError("gRPC not available"); +#endif +} + absl::StatusOr GuiAutomationClient::Click( const std::string& target, ClickType type) { #ifdef YAZE_WITH_GRPC diff --git a/src/cli/service/gui/gui_automation_client.h b/src/cli/service/gui/gui_automation_client.h index 49d032de..d12e4355 100644 --- a/src/cli/service/gui/gui_automation_client.h +++ b/src/cli/service/gui/gui_automation_client.h @@ -134,6 +134,21 @@ struct ReplayTestResult { std::vector logs; }; +struct StartRecordingResult { + bool success = false; + std::string message; + std::string recording_id; + std::optional started_at; +}; + +struct StopRecordingResult { + bool success = false; + std::string message; + std::string output_path; + int step_count = 0; + std::chrono::milliseconds duration{0}; +}; + enum class WidgetTypeFilter { kUnspecified, kAll, @@ -303,6 +318,15 @@ class GuiAutomationClient { const std::string& script_path, bool ci_mode, const std::map& parameter_overrides = {}); + absl::StatusOr StartRecording( + const std::string& output_path, + const std::string& session_name, + const std::string& description); + + absl::StatusOr StopRecording( + const std::string& recording_id, + bool discard = false); + /** * @brief Check if client is connected */ diff --git a/src/cli/service/planning/proposal_registry.cc b/src/cli/service/planning/proposal_registry.cc index 26df5476..96b0f055 100644 --- a/src/cli/service/planning/proposal_registry.cc +++ b/src/cli/service/planning/proposal_registry.cc @@ -8,10 +8,13 @@ #include "absl/status/status.h" #include "absl/status/statusor.h" +#include "absl/strings/ascii.h" +#include "absl/strings/match.h" #include "absl/strings/str_cat.h" #include "absl/strings/str_format.h" #include "absl/time/time.h" +#include "nlohmann/json.hpp" #include "util/macro.h" namespace yaze { @@ -31,6 +34,40 @@ std::filesystem::path DetermineDefaultRoot() { return temp_dir / "yaze" / "proposals"; } +std::string StatusToString(ProposalRegistry::ProposalStatus status) { + switch (status) { + case ProposalRegistry::ProposalStatus::kAccepted: + return "accepted"; + case ProposalRegistry::ProposalStatus::kRejected: + return "rejected"; + case ProposalRegistry::ProposalStatus::kPending: + default: + return "pending"; + } +} + +ProposalRegistry::ProposalStatus ParseStatus(absl::string_view value) { + std::string lower = absl::AsciiStrToLower(value); + if (absl::StartsWith(lower, "accept")) { + return ProposalRegistry::ProposalStatus::kAccepted; + } + if (absl::StartsWith(lower, "reject")) { + return ProposalRegistry::ProposalStatus::kRejected; + } + return ProposalRegistry::ProposalStatus::kPending; +} + +int64_t TimeToMillis(absl::Time time) { + return absl::ToUnixMillis(time); +} + +std::optional OptionalTimeFromMillis(int64_t millis) { + if (millis <= 0) { + return std::nullopt; + } + return absl::FromUnixMillis(millis); +} + } // namespace ProposalRegistry& ProposalRegistry::Instance() { @@ -65,92 +102,169 @@ absl::Status ProposalRegistry::EnsureRootExistsLocked() { absl::Status ProposalRegistry::LoadProposalsFromDiskLocked() { std::error_code ec; - - // Check if root directory exists + if (!std::filesystem::exists(root_directory_, ec)) { - return absl::OkStatus(); // No proposals to load + return absl::OkStatus(); } - // Iterate over all directories in the root for (const auto& entry : std::filesystem::directory_iterator(root_directory_, ec)) { if (ec) { - continue; // Skip entries that cause errors - } - - if (!entry.is_directory()) { - continue; // Skip non-directories + break; } - std::string proposal_id = entry.path().filename().string(); - - // Skip if already loaded (shouldn't happen, but be defensive) + if (!entry.is_directory()) { + continue; + } + + const std::string proposal_id = entry.path().filename().string(); if (proposals_.find(proposal_id) != proposals_.end()) { continue; } - // Reconstruct metadata from directory contents - // Since we don't have a metadata.json file, we need to infer what we can - std::filesystem::path log_path = entry.path() / "execution.log"; - std::filesystem::path diff_path = entry.path() / "diff.txt"; - - // Check if log file exists to determine if this is a valid proposal - if (!std::filesystem::exists(log_path, ec)) { - continue; // Not a valid proposal directory - } + ProposalMetadata metadata; + bool metadata_loaded = false; + const std::filesystem::path metadata_path = entry.path() / "metadata.json"; - // Extract timestamp from proposal ID (format: proposal-20251001T200215-1) - absl::Time created_at = absl::Now(); // Default to now if parsing fails - if (proposal_id.starts_with("proposal-")) { - std::string time_str = proposal_id.substr(9, 15); // Extract YYYYMMDDTHHmmSS - std::string error; - if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) { - // Successfully parsed time + if (std::filesystem::exists(metadata_path, ec) && !ec) { + std::ifstream metadata_file(metadata_path); + if (metadata_file.is_open()) { + try { + nlohmann::json metadata_json; + metadata_file >> metadata_json; + + metadata.id = metadata_json.value("id", proposal_id); + if (metadata.id.empty()) { + metadata.id = proposal_id; + } + metadata.sandbox_id = metadata_json.value("sandbox_id", ""); + + if (metadata_json.contains("sandbox_directory") && + metadata_json["sandbox_directory"].is_string()) { + metadata.sandbox_directory = + std::filesystem::path(metadata_json["sandbox_directory"].get()); + } else { + metadata.sandbox_directory.clear(); + } + + if (metadata_json.contains("sandbox_rom_path") && + metadata_json["sandbox_rom_path"].is_string()) { + metadata.sandbox_rom_path = + std::filesystem::path(metadata_json["sandbox_rom_path"].get()); + } else { + metadata.sandbox_rom_path.clear(); + } + + metadata.description = metadata_json.value("description", ""); + metadata.prompt = metadata_json.value("prompt", ""); + metadata.status = ParseStatus(metadata_json.value("status", "pending")); + + int64_t created_at_millis = metadata_json.value( + "created_at_millis", TimeToMillis(absl::Now())); + metadata.created_at = absl::FromUnixMillis(created_at_millis); + + int64_t reviewed_at_millis = metadata_json.value( + "reviewed_at_millis", 0); + metadata.reviewed_at = OptionalTimeFromMillis(reviewed_at_millis); + + std::string diff_path = metadata_json.value("diff_path", std::string("diff.txt")); + std::string log_path = metadata_json.value("log_path", std::string("execution.log")); + metadata.diff_path = entry.path() / diff_path; + metadata.log_path = entry.path() / log_path; + + metadata.bytes_changed = metadata_json.value("bytes_changed", 0); + metadata.commands_executed = metadata_json.value("commands_executed", 0); + + metadata.screenshots.clear(); + if (metadata_json.contains("screenshots") && + metadata_json["screenshots"].is_array()) { + for (const auto& screenshot : metadata_json["screenshots"]) { + if (screenshot.is_string()) { + metadata.screenshots.emplace_back(entry.path() / + screenshot.get()); + } + } + } + + if (metadata.sandbox_directory.empty() && + !metadata.sandbox_rom_path.empty()) { + metadata.sandbox_directory = metadata.sandbox_rom_path.parent_path(); + } + + metadata_loaded = true; + } catch (const std::exception& ex) { + std::cerr << "Warning: Failed to parse metadata for proposal " + << proposal_id << ": " << ex.what() << "\n"; + } } } - // Get file modification time as a fallback - auto ftime = std::filesystem::last_write_time(log_path, ec); - if (!ec) { - auto sctp = std::chrono::time_point_cast( - ftime - std::filesystem::file_time_type::clock::now() + - std::chrono::system_clock::now()); - auto time_t_value = std::chrono::system_clock::to_time_t(sctp); - created_at = absl::FromTimeT(time_t_value); - } + if (!metadata_loaded) { + std::filesystem::path log_path = entry.path() / "execution.log"; + if (!std::filesystem::exists(log_path, ec) || ec) { + continue; + } - // Create minimal metadata for this proposal - ProposalMetadata metadata{ - .id = proposal_id, - .sandbox_id = "", // Unknown - not stored in logs - .description = "Loaded from disk", - .prompt = "", // Unknown - not stored in logs - .status = ProposalStatus::kPending, - .created_at = created_at, - .reviewed_at = std::nullopt, - .diff_path = diff_path, - .log_path = log_path, - .screenshots = {}, - .bytes_changed = 0, - .commands_executed = 0, - }; + std::filesystem::path diff_path = entry.path() / "diff.txt"; - // Count diff size if it exists - if (std::filesystem::exists(diff_path, ec) && !ec) { - metadata.bytes_changed = static_cast( - std::filesystem::file_size(diff_path, ec)); - } + absl::Time created_at = absl::Now(); + if (proposal_id.starts_with("proposal-")) { + std::string time_str = proposal_id.substr(9, 15); + std::string error; + if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) { + // Parsed successfully. + } + } - // Scan for screenshots - for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) { - if (ec) continue; - if (file.path().extension() == ".png" || - file.path().extension() == ".jpg" || - file.path().extension() == ".jpeg") { - metadata.screenshots.push_back(file.path()); + auto ftime = std::filesystem::last_write_time(log_path, ec); + if (!ec) { + auto sctp = std::chrono::time_point_cast( + ftime - std::filesystem::file_time_type::clock::now() + + std::chrono::system_clock::now()); + auto time_t_value = std::chrono::system_clock::to_time_t(sctp); + created_at = absl::FromTimeT(time_t_value); + } + + metadata = ProposalMetadata{ + .id = proposal_id, + .sandbox_id = "", + .sandbox_directory = std::filesystem::path(), + .sandbox_rom_path = std::filesystem::path(), + .description = "Loaded from disk", + .prompt = "", + .status = ProposalStatus::kPending, + .created_at = created_at, + .reviewed_at = std::nullopt, + .diff_path = diff_path, + .log_path = log_path, + .screenshots = {}, + .bytes_changed = 0, + .commands_executed = 0, + }; + + if (std::filesystem::exists(diff_path, ec) && !ec) { + metadata.bytes_changed = static_cast( + std::filesystem::file_size(diff_path, ec)); + } + + for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) { + if (ec) { + break; + } + if (file.path().extension() == ".png" || file.path().extension() == ".jpg" || + file.path().extension() == ".jpeg") { + metadata.screenshots.push_back(file.path()); + } + } + + // Create a metadata file for legacy proposals so future loads are fast. + absl::Status write_status = WriteMetadataLocked(metadata); + if (!write_status.ok()) { + std::cerr << "Warning: Failed to persist metadata for legacy proposal " + << proposal_id << ": " << write_status.message() << "\n"; } } - proposals_[proposal_id] = metadata; + proposals_[metadata.id] = metadata; } return absl::OkStatus(); @@ -171,6 +285,7 @@ std::filesystem::path ProposalRegistry::ProposalDirectory( absl::StatusOr ProposalRegistry::CreateProposal(absl::string_view sandbox_id, + const std::filesystem::path& sandbox_rom_path, absl::string_view prompt, absl::string_view description) { std::unique_lock lock(mutex_); @@ -191,6 +306,10 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id, proposals_[id] = ProposalMetadata{ .id = id, .sandbox_id = std::string(sandbox_id), + .sandbox_directory = sandbox_rom_path.empty() + ? std::filesystem::path() + : sandbox_rom_path.parent_path(), + .sandbox_rom_path = sandbox_rom_path, .description = std::string(description), .prompt = std::string(prompt), .status = ProposalStatus::kPending, @@ -203,6 +322,8 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id, .commands_executed = 0, }; + RETURN_IF_ERROR(WriteMetadataLocked(proposals_.at(id))); + return proposals_.at(id); } @@ -227,6 +348,8 @@ absl::Status ProposalRegistry::RecordDiff(const std::string& proposal_id, // Update bytes_changed metric (rough estimate based on diff size) it->second.bytes_changed = static_cast(diff_content.size()); + RETURN_IF_ERROR(WriteMetadataLocked(it->second)); + return absl::OkStatus(); } @@ -272,6 +395,21 @@ absl::Status ProposalRegistry::AddScreenshot( } it->second.screenshots.push_back(screenshot_path); + RETURN_IF_ERROR(WriteMetadataLocked(it->second)); + return absl::OkStatus(); +} + +absl::Status ProposalRegistry::UpdateCommandStats(const std::string& proposal_id, + int commands_executed) { + std::lock_guard lock(mutex_); + auto it = proposals_.find(proposal_id); + if (it == proposals_.end()) { + return absl::NotFoundError( + absl::StrCat("Proposal not found: ", proposal_id)); + } + + it->second.commands_executed = commands_executed; + RETURN_IF_ERROR(WriteMetadataLocked(it->second)); return absl::OkStatus(); } @@ -286,7 +424,7 @@ absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id, it->second.status = status; it->second.reviewed_at = absl::Now(); - + RETURN_IF_ERROR(WriteMetadataLocked(it->second)); return absl::OkStatus(); } @@ -354,6 +492,70 @@ ProposalRegistry::GetLatestPendingProposal() const { return *latest; } +absl::Status ProposalRegistry::WriteMetadataLocked( + const ProposalMetadata& metadata) const { + std::filesystem::path proposal_dir = ProposalDirectory(metadata.id); + std::error_code ec; + if (!std::filesystem::exists(proposal_dir, ec) || ec) { + return absl::NotFoundError( + absl::StrCat("Proposal directory missing for ", metadata.id)); + } + + auto relative_to_proposal = [&](const std::filesystem::path& path) { + if (path.empty()) { + return std::string(); + } + std::error_code relative_error; + auto relative_path = std::filesystem::relative(path, proposal_dir, relative_error); + if (!relative_error) { + return relative_path.generic_string(); + } + return path.generic_string(); + }; + + nlohmann::json metadata_json; + metadata_json["id"] = metadata.id; + metadata_json["sandbox_id"] = metadata.sandbox_id; + if (!metadata.sandbox_directory.empty()) { + metadata_json["sandbox_directory"] = metadata.sandbox_directory.generic_string(); + } + if (!metadata.sandbox_rom_path.empty()) { + metadata_json["sandbox_rom_path"] = metadata.sandbox_rom_path.generic_string(); + } + metadata_json["description"] = metadata.description; + metadata_json["prompt"] = metadata.prompt; + metadata_json["status"] = StatusToString(metadata.status); + metadata_json["created_at_millis"] = TimeToMillis(metadata.created_at); + metadata_json["reviewed_at_millis"] = metadata.reviewed_at.has_value() + ? TimeToMillis(*metadata.reviewed_at) + : int64_t{0}; + metadata_json["diff_path"] = relative_to_proposal(metadata.diff_path); + metadata_json["log_path"] = relative_to_proposal(metadata.log_path); + metadata_json["bytes_changed"] = metadata.bytes_changed; + metadata_json["commands_executed"] = metadata.commands_executed; + + nlohmann::json screenshots_json = nlohmann::json::array(); + for (const auto& screenshot : metadata.screenshots) { + screenshots_json.push_back(relative_to_proposal(screenshot)); + } + metadata_json["screenshots"] = std::move(screenshots_json); + + std::ofstream metadata_file(proposal_dir / "metadata.json", std::ios::out); + if (!metadata_file.is_open()) { + return absl::InternalError(absl::StrCat( + "Failed to write metadata file for proposal ", metadata.id)); + } + + metadata_file << metadata_json.dump(2); + metadata_file.close(); + if (!metadata_file) { + return absl::InternalError(absl::StrCat( + "Failed to flush metadata file for proposal ", metadata.id)); + } + + return absl::OkStatus(); +} + absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) { std::lock_guard lock(mutex_); auto it = proposals_.find(proposal_id); diff --git a/src/cli/service/planning/proposal_registry.h b/src/cli/service/planning/proposal_registry.h index af2319ef..6ff1ff06 100644 --- a/src/cli/service/planning/proposal_registry.h +++ b/src/cli/service/planning/proposal_registry.h @@ -37,6 +37,8 @@ class ProposalRegistry { struct ProposalMetadata { std::string id; std::string sandbox_id; + std::filesystem::path sandbox_directory; + std::filesystem::path sandbox_rom_path; std::string description; std::string prompt; // Original agent prompt that created this proposal ProposalStatus status; @@ -65,6 +67,7 @@ class ProposalRegistry { // is created under the root, and metadata is initialized. absl::StatusOr CreateProposal( absl::string_view sandbox_id, + const std::filesystem::path& sandbox_rom_path, absl::string_view prompt, absl::string_view description); @@ -82,6 +85,11 @@ class ProposalRegistry { absl::Status AddScreenshot(const std::string& proposal_id, const std::filesystem::path& screenshot_path); + // Updates the number of commands executed for a proposal. Used to track + // how many CLI commands ran when generating the proposal. + absl::Status UpdateCommandStats(const std::string& proposal_id, + int commands_executed); + // Updates the proposal status (pending -> accepted/rejected) and sets // the review timestamp. absl::Status UpdateStatus(const std::string& proposal_id, @@ -111,6 +119,7 @@ class ProposalRegistry { absl::Status LoadProposalsFromDiskLocked(); std::string GenerateProposalIdLocked(); std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const; + absl::Status WriteMetadataLocked(const ProposalMetadata& metadata) const; std::filesystem::path root_directory_; mutable std::mutex mutex_; diff --git a/src/cli/service/planning/tile16_proposal_generator.cc b/src/cli/service/planning/tile16_proposal_generator.cc index fa46539f..2bde633d 100644 --- a/src/cli/service/planning/tile16_proposal_generator.cc +++ b/src/cli/service/planning/tile16_proposal_generator.cc @@ -1,12 +1,15 @@ #include "cli/service/planning/tile16_proposal_generator.h" -#include #include +#include #include "absl/strings/match.h" #include "absl/strings/str_split.h" #include "absl/strings/str_cat.h" +#include "absl/strings/numbers.h" #include "app/zelda3/overworld/overworld.h" +#include "nlohmann/json.hpp" +#include "util/macro.h" namespace yaze { namespace cli { @@ -54,9 +57,175 @@ std::string Tile16Proposal::ToJson() const { return json.str(); } -absl::StatusOr Tile16Proposal::FromJson(const std::string& /* json */) { - // TODO: Implement JSON parsing using nlohmann/json when available - return absl::UnimplementedError("JSON parsing not yet implemented"); +namespace { + +absl::StatusOr ParseTileValue(const nlohmann::json& json, + const char* field) { + if (!json.contains(field)) { + return absl::InvalidArgumentError( + absl::StrCat("Missing field '", field, "' in proposal change")); + } + + if (json[field].is_number_integer()) { + int value = json[field].get(); + if (value < 0 || value > 0xFFFF) { + return absl::InvalidArgumentError( + absl::StrCat("Tile value for '", field, + "' out of range: ", value)); + } + return static_cast(value); + } + + if (json[field].is_string()) { + std::string value = json[field].get(); + if (value.empty()) { + return absl::InvalidArgumentError( + absl::StrCat("Tile value for '", field, "' is empty")); + } + + // Support hex strings in 0xFFFF format or plain decimal strings + if (absl::StartsWith(value, "0x") || absl::StartsWith(value, "0X")) { + if (value.size() <= 2) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid hex tile value for '", field, + "': ", json[field].get())); + } + value = value.substr(2); + unsigned int parsed = 0; + if (!absl::SimpleHexAtoi(value, &parsed) || parsed > 0xFFFF) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid hex tile value for '", field, + "': ", json[field].get())); + } + return static_cast(parsed); + } + + unsigned int parsed = 0; + if (!absl::SimpleAtoi(value, &parsed) || parsed > 0xFFFF) { + return absl::InvalidArgumentError( + absl::StrCat("Invalid tile value for '", field, + "': ", json[field].get())); + } + return static_cast(parsed); + } + + return absl::InvalidArgumentError( + absl::StrCat("Unsupported JSON type for tile field '", field, "'")); +} + +Tile16Proposal::Status ParseStatus(absl::string_view status_text) { + if (absl::StartsWith(status_text, "accept")) { + return Tile16Proposal::Status::ACCEPTED; + } + if (absl::StartsWith(status_text, "reject")) { + return Tile16Proposal::Status::REJECTED; + } + if (absl::StartsWith(status_text, "apply")) { + return Tile16Proposal::Status::APPLIED; + } + return Tile16Proposal::Status::PENDING; +} + +} // namespace + +absl::StatusOr Tile16Proposal::FromJson( + const std::string& json_text) { + nlohmann::json json; + try { + json = nlohmann::json::parse(json_text); + } catch (const nlohmann::json::parse_error& error) { + return absl::InvalidArgumentError( + absl::StrCat("Failed to parse proposal JSON: ", error.what())); + } + + Tile16Proposal proposal; + + if (!json.contains("id") || !json["id"].is_string()) { + return absl::InvalidArgumentError( + "Proposal JSON must include string field 'id'"); + } + proposal.id = json["id"].get(); + + if (!json.contains("prompt") || !json["prompt"].is_string()) { + return absl::InvalidArgumentError( + "Proposal JSON must include string field 'prompt'"); + } + proposal.prompt = json["prompt"].get(); + + if (json.contains("ai_service") && json["ai_service"].is_string()) { + proposal.ai_service = json["ai_service"].get(); + } + + if (json.contains("reasoning") && json["reasoning"].is_string()) { + proposal.reasoning = json["reasoning"].get(); + } + + if (json.contains("status")) { + if (!json["status"].is_string()) { + return absl::InvalidArgumentError( + "Proposal 'status' must be a string value"); + } + proposal.status = ParseStatus(json["status"].get()); + } else { + proposal.status = Status::PENDING; + } + + if (json.contains("changes")) { + if (!json["changes"].is_array()) { + return absl::InvalidArgumentError( + "Proposal 'changes' field must be an array"); + } + + for (const auto& change_json : json["changes"]) { + if (!change_json.is_object()) { + return absl::InvalidArgumentError( + "Each change entry must be a JSON object"); + } + + Tile16Change change; + if (!change_json.contains("map_id") || + !change_json["map_id"].is_number_integer()) { + return absl::InvalidArgumentError( + "Tile change missing integer field 'map_id'"); + } + change.map_id = change_json["map_id"].get(); + + if (!change_json.contains("x") || + !change_json["x"].is_number_integer()) { + return absl::InvalidArgumentError( + "Tile change missing integer field 'x'"); + } + change.x = change_json["x"].get(); + + if (!change_json.contains("y") || + !change_json["y"].is_number_integer()) { + return absl::InvalidArgumentError( + "Tile change missing integer field 'y'"); + } + change.y = change_json["y"].get(); + + ASSIGN_OR_RETURN(change.old_tile, + ParseTileValue(change_json, "old_tile")); + ASSIGN_OR_RETURN(change.new_tile, + ParseTileValue(change_json, "new_tile")); + + proposal.changes.push_back(change); + } + } + + if (proposal.changes.empty()) { + return absl::InvalidArgumentError( + "Proposal JSON did not include any tile16 changes"); + } + + proposal.created_at = std::chrono::system_clock::now(); + if (json.contains("created_at_ms") && json["created_at_ms"].is_number()) { + auto millis = json["created_at_ms"].get(); + proposal.created_at = std::chrono::system_clock::time_point( + std::chrono::milliseconds(millis)); + } + + return proposal; } std::string Tile16ProposalGenerator::GenerateProposalId() const { diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 1ea4215b..1c007c3f 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -267,7 +267,7 @@ endif() target_link_libraries(yaze_test yaze_agent) endif() - if(YAZE_BUILD_EMU AND NOT YAZE_WITH_GRPC AND TARGET yaze_emulator) + if(YAZE_BUILD_EMU AND TARGET yaze_emulator) target_link_libraries(yaze_test yaze_emulator) endif() else()