Implement recording functionality in agent test commands
- Added RecordingState structure to manage recording session details. - Implemented SaveRecordingState and LoadRecordingState functions to handle recording state persistence. - Enhanced HandleTestRecordCommand to support starting and stopping recording sessions with various options. - Integrated gRPC calls for starting and stopping recordings in GuiAutomationClient. - Updated ProposalRegistry to include sandbox directory and ROM path in ProposalMetadata. - Implemented JSON parsing for Tile16Proposal to handle proposal creation from JSON input. - Updated CMakeLists.txt to ensure proper linking of emulator targets.
This commit is contained in:
@@ -75,7 +75,8 @@
|
|||||||
"YAZE_TEST_ROM_PATH": "${sourceDir}/zelda3.sfc",
|
"YAZE_TEST_ROM_PATH": "${sourceDir}/zelda3.sfc",
|
||||||
"YAZE_WITH_JSON": "ON",
|
"YAZE_WITH_JSON": "ON",
|
||||||
"YAZE_WITH_GRPC": "ON",
|
"YAZE_WITH_GRPC": "ON",
|
||||||
"YAZE_BUILD_Z3ED": "ON"
|
"YAZE_BUILD_Z3ED": "ON",
|
||||||
|
"YAZE_BUILD_EMU": "ON"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
#include "cli/handlers/agent/commands.h"
|
#include "cli/handlers/agent/commands.h"
|
||||||
|
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <filesystem>
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
#include <optional>
|
#include <optional>
|
||||||
|
#include <sstream>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <utility>
|
#include <utility>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
@@ -154,7 +156,8 @@ absl::Status HandleRunCommand(const std::vector<std::string>& arg_vec,
|
|||||||
if (!response_or.ok()) {
|
if (!response_or.ok()) {
|
||||||
return response_or.status();
|
return response_or.status();
|
||||||
}
|
}
|
||||||
std::vector<std::string> commands = response_or.value().commands;
|
AgentResponse response = std::move(response_or.value());
|
||||||
|
const std::vector<std::string>& commands = response.commands;
|
||||||
|
|
||||||
// 3. Generate a structured proposal from the commands
|
// 3. Generate a structured proposal from the commands
|
||||||
Tile16ProposalGenerator generator;
|
Tile16ProposalGenerator generator;
|
||||||
@@ -186,11 +189,77 @@ absl::Status HandleRunCommand(const std::vector<std::string>& arg_vec,
|
|||||||
absl::StrCat("Failed to save sandbox ROM: ", save_status.message()));
|
absl::StrCat("Failed to save sandbox ROM: ", save_status.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Save the proposal metadata for later use (accept/reject)
|
// 6. Persist the proposal metadata and artifacts.
|
||||||
// For now, we'll just use the proposal generator's save function.
|
auto& registry = ProposalRegistry::Instance();
|
||||||
// A better approach would be to integrate with ProposalRegistry.
|
|
||||||
auto proposal_path =
|
int executed_commands = 0;
|
||||||
RomSandboxManager::Instance().RootDirectory() / (proposal.id + ".json");
|
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 =
|
auto save_proposal_status =
|
||||||
generator.SaveProposal(proposal, proposal_path.string());
|
generator.SaveProposal(proposal, proposal_path.string());
|
||||||
if (!save_proposal_status.ok()) {
|
if (!save_proposal_status.ok()) {
|
||||||
@@ -198,16 +267,22 @@ absl::Status HandleRunCommand(const std::vector<std::string>& arg_vec,
|
|||||||
save_proposal_status.message()));
|
save_proposal_status.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
RETURN_IF_ERROR(registry.AppendLog(
|
||||||
|
metadata.id,
|
||||||
|
absl::StrCat("Saved proposal JSON to ", proposal_path.string())));
|
||||||
|
|
||||||
std::cout
|
std::cout
|
||||||
<< "✅ Agent successfully planned and executed changes in a sandbox."
|
<< "✅ Agent successfully planned and executed changes in a sandbox."
|
||||||
<< std::endl;
|
<< 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 << " 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 << "\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 << "\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();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
@@ -287,6 +362,14 @@ absl::Status HandleDiffCommand(Rom& rom, const std::vector<std::string>& args) {
|
|||||||
std::cout << "Commands Executed: " << proposal.commands_executed << "\n";
|
std::cout << "Commands Executed: " << proposal.commands_executed << "\n";
|
||||||
std::cout << "Bytes Changed: " << proposal.bytes_changed << "\n\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)) {
|
if (std::filesystem::exists(proposal.diff_path)) {
|
||||||
std::cout << "--- Diff Content ---\n";
|
std::cout << "--- Diff Content ---\n";
|
||||||
std::ifstream diff_file(proposal.diff_path);
|
std::ifstream diff_file(proposal.diff_path);
|
||||||
@@ -490,45 +573,90 @@ absl::Status HandleChatCommand(Rom& rom) {
|
|||||||
|
|
||||||
absl::Status HandleAcceptCommand(const std::vector<std::string>& arg_vec,
|
absl::Status HandleAcceptCommand(const std::vector<std::string>& arg_vec,
|
||||||
Rom& rom) {
|
Rom& rom) {
|
||||||
if (arg_vec.empty() || arg_vec[0] != "--proposal-id") {
|
std::optional<std::string> 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(
|
return absl::InvalidArgumentError(
|
||||||
"Usage: agent accept --proposal-id <proposal_id>");
|
"Usage: agent accept --proposal-id <proposal_id>");
|
||||||
}
|
}
|
||||||
std::string proposal_id = arg_vec[1];
|
|
||||||
|
|
||||||
// 1. Load the proposal from disk.
|
auto& registry = ProposalRegistry::Instance();
|
||||||
Tile16ProposalGenerator generator;
|
ASSIGN_OR_RETURN(auto metadata, registry.GetProposal(*proposal_id));
|
||||||
auto proposal_path =
|
|
||||||
RomSandboxManager::Instance().RootDirectory() / (proposal_id + ".json");
|
if (metadata.status == ProposalRegistry::ProposalStatus::kAccepted) {
|
||||||
auto proposal_or = generator.LoadProposal(proposal_path.string());
|
std::cout << "Proposal '" << *proposal_id << "' is already accepted."
|
||||||
if (!proposal_or.ok()) {
|
<< std::endl;
|
||||||
return absl::InternalError(
|
return absl::OkStatus();
|
||||||
absl::StrCat("Failed to load proposal file '", proposal_path.string(),
|
|
||||||
"': ", proposal_or.status().message()));
|
|
||||||
}
|
}
|
||||||
auto proposal = proposal_or.value();
|
|
||||||
|
|
||||||
// 2. Ensure the main ROM is loaded.
|
if (metadata.sandbox_rom_path.empty()) {
|
||||||
RETURN_IF_ERROR(EnsureRomLoaded(rom, "agent accept --proposal-id <id>"));
|
return absl::FailedPreconditionError(absl::StrCat(
|
||||||
|
"Proposal '", *proposal_id,
|
||||||
|
"' is missing sandbox ROM metadata. Cannot accept."));
|
||||||
|
}
|
||||||
|
|
||||||
// 3. Apply the proposal to the main ROM.
|
if (!std::filesystem::exists(metadata.sandbox_rom_path)) {
|
||||||
auto apply_status = generator.ApplyProposal(proposal, &rom);
|
return absl::NotFoundError(absl::StrCat(
|
||||||
if (!apply_status.ok()) {
|
"Sandbox ROM not found at ", metadata.sandbox_rom_path.string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
RETURN_IF_ERROR(
|
||||||
|
EnsureRomLoaded(rom, "agent accept --proposal-id <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(
|
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<int>(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});
|
auto save_status = rom.SaveToFile({.save_new = false});
|
||||||
if (!save_status.ok()) {
|
if (!save_status.ok()) {
|
||||||
return absl::InternalError(absl::StrCat(
|
return absl::InternalError(absl::StrCat(
|
||||||
"Failed to save changes to main ROM: ", save_status.message()));
|
"Failed to save changes to main ROM: ", save_status.message()));
|
||||||
}
|
}
|
||||||
|
|
||||||
std::cout << "✅ Proposal '" << proposal_id << "' accepted and applied to '"
|
RETURN_IF_ERROR(registry.UpdateStatus(
|
||||||
<< rom.filename() << "'." << std::endl;
|
*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();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
#include "cli/handlers/agent/commands.h"
|
#include "cli/handlers/agent/commands.h"
|
||||||
|
|
||||||
|
#include <filesystem>
|
||||||
|
#include <fstream>
|
||||||
#include <iostream>
|
#include <iostream>
|
||||||
|
#include <optional>
|
||||||
#include <string>
|
#include <string>
|
||||||
#include <vector>
|
#include <vector>
|
||||||
|
|
||||||
#include "absl/status/status.h"
|
#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_cat.h"
|
||||||
|
#include "absl/strings/str_format.h"
|
||||||
|
#include "absl/time/time.h"
|
||||||
#include "cli/handlers/agent/common.h"
|
#include "cli/handlers/agent/common.h"
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
|
#include "util/macro.h"
|
||||||
|
|
||||||
#ifdef YAZE_WITH_GRPC
|
#ifdef YAZE_WITH_GRPC
|
||||||
#include "cli/service/gui/gui_automation_client.h"
|
#include "cli/service/gui/gui_automation_client.h"
|
||||||
@@ -19,6 +28,100 @@ namespace agent {
|
|||||||
|
|
||||||
#ifdef YAZE_WITH_GRPC
|
#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<RecordingState> 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
|
// Forward declarations for subcommand handlers
|
||||||
absl::Status HandleTestRunCommand(const std::vector<std::string>& args);
|
absl::Status HandleTestRunCommand(const std::vector<std::string>& args);
|
||||||
absl::Status HandleTestReplayCommand(const std::vector<std::string>& args);
|
absl::Status HandleTestReplayCommand(const std::vector<std::string>& args);
|
||||||
@@ -307,7 +410,10 @@ absl::Status HandleTestResultsCommand(const std::vector<std::string>& args) {
|
|||||||
absl::Status HandleTestRecordCommand(const std::vector<std::string>& args) {
|
absl::Status HandleTestRecordCommand(const std::vector<std::string>& args) {
|
||||||
if (args.empty()) {
|
if (args.empty()) {
|
||||||
return absl::InvalidArgumentError(
|
return absl::InvalidArgumentError(
|
||||||
"Usage: agent test record <start|stop> [--output <file>]");
|
"Usage: agent test record <start|stop> [options]\n"
|
||||||
|
" start [--output <file>] [--description <text>] [--session <id>]\n"
|
||||||
|
" [--host <host>] [--port <port>]\n"
|
||||||
|
" stop [--validate] [--discard] [--host <host>] [--port <port>]");
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string action = args[0];
|
std::string action = args[0];
|
||||||
@@ -315,10 +421,167 @@ absl::Status HandleTestRecordCommand(const std::vector<std::string>& args) {
|
|||||||
return absl::InvalidArgumentError("Record action must be 'start' or 'stop'");
|
return absl::InvalidArgumentError("Record action must be 'start' or 'stop'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: Implement recording functionality
|
if (action == "start") {
|
||||||
return absl::UnimplementedError(
|
std::string host = "localhost";
|
||||||
"Test recording is not yet implemented.\n"
|
int port = 50052;
|
||||||
"This feature will allow capturing GUI interactions for replay.");
|
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<std::string> host_override;
|
||||||
|
std::optional<int> 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
|
#endif // YAZE_WITH_GRPC
|
||||||
|
|||||||
@@ -186,6 +186,74 @@ absl::StatusOr<ReplayTestResult> GuiAutomationClient::ReplayTest(
|
|||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
absl::StatusOr<StartRecordingResult> 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<StopRecordingResult> 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<AutomationResult> GuiAutomationClient::Click(
|
absl::StatusOr<AutomationResult> GuiAutomationClient::Click(
|
||||||
const std::string& target, ClickType type) {
|
const std::string& target, ClickType type) {
|
||||||
#ifdef YAZE_WITH_GRPC
|
#ifdef YAZE_WITH_GRPC
|
||||||
|
|||||||
@@ -134,6 +134,21 @@ struct ReplayTestResult {
|
|||||||
std::vector<std::string> logs;
|
std::vector<std::string> logs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
struct StartRecordingResult {
|
||||||
|
bool success = false;
|
||||||
|
std::string message;
|
||||||
|
std::string recording_id;
|
||||||
|
std::optional<absl::Time> 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 {
|
enum class WidgetTypeFilter {
|
||||||
kUnspecified,
|
kUnspecified,
|
||||||
kAll,
|
kAll,
|
||||||
@@ -303,6 +318,15 @@ class GuiAutomationClient {
|
|||||||
const std::string& script_path, bool ci_mode,
|
const std::string& script_path, bool ci_mode,
|
||||||
const std::map<std::string, std::string>& parameter_overrides = {});
|
const std::map<std::string, std::string>& parameter_overrides = {});
|
||||||
|
|
||||||
|
absl::StatusOr<StartRecordingResult> StartRecording(
|
||||||
|
const std::string& output_path,
|
||||||
|
const std::string& session_name,
|
||||||
|
const std::string& description);
|
||||||
|
|
||||||
|
absl::StatusOr<StopRecordingResult> StopRecording(
|
||||||
|
const std::string& recording_id,
|
||||||
|
bool discard = false);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @brief Check if client is connected
|
* @brief Check if client is connected
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8,10 +8,13 @@
|
|||||||
|
|
||||||
#include "absl/status/status.h"
|
#include "absl/status/status.h"
|
||||||
#include "absl/status/statusor.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_cat.h"
|
||||||
#include "absl/strings/str_format.h"
|
#include "absl/strings/str_format.h"
|
||||||
#include "absl/time/time.h"
|
#include "absl/time/time.h"
|
||||||
|
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
#include "util/macro.h"
|
#include "util/macro.h"
|
||||||
|
|
||||||
namespace yaze {
|
namespace yaze {
|
||||||
@@ -31,6 +34,40 @@ std::filesystem::path DetermineDefaultRoot() {
|
|||||||
return temp_dir / "yaze" / "proposals";
|
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<absl::Time> OptionalTimeFromMillis(int64_t millis) {
|
||||||
|
if (millis <= 0) {
|
||||||
|
return std::nullopt;
|
||||||
|
}
|
||||||
|
return absl::FromUnixMillis(millis);
|
||||||
|
}
|
||||||
|
|
||||||
} // namespace
|
} // namespace
|
||||||
|
|
||||||
ProposalRegistry& ProposalRegistry::Instance() {
|
ProposalRegistry& ProposalRegistry::Instance() {
|
||||||
@@ -65,92 +102,169 @@ absl::Status ProposalRegistry::EnsureRootExistsLocked() {
|
|||||||
|
|
||||||
absl::Status ProposalRegistry::LoadProposalsFromDiskLocked() {
|
absl::Status ProposalRegistry::LoadProposalsFromDiskLocked() {
|
||||||
std::error_code ec;
|
std::error_code ec;
|
||||||
|
|
||||||
// Check if root directory exists
|
|
||||||
if (!std::filesystem::exists(root_directory_, ec)) {
|
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)) {
|
for (const auto& entry : std::filesystem::directory_iterator(root_directory_, ec)) {
|
||||||
if (ec) {
|
if (ec) {
|
||||||
continue; // Skip entries that cause errors
|
break;
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.is_directory()) {
|
|
||||||
continue; // Skip non-directories
|
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string proposal_id = entry.path().filename().string();
|
if (!entry.is_directory()) {
|
||||||
|
continue;
|
||||||
// Skip if already loaded (shouldn't happen, but be defensive)
|
}
|
||||||
|
|
||||||
|
const std::string proposal_id = entry.path().filename().string();
|
||||||
if (proposals_.find(proposal_id) != proposals_.end()) {
|
if (proposals_.find(proposal_id) != proposals_.end()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reconstruct metadata from directory contents
|
ProposalMetadata metadata;
|
||||||
// Since we don't have a metadata.json file, we need to infer what we can
|
bool metadata_loaded = false;
|
||||||
std::filesystem::path log_path = entry.path() / "execution.log";
|
const std::filesystem::path metadata_path = entry.path() / "metadata.json";
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract timestamp from proposal ID (format: proposal-20251001T200215-1)
|
if (std::filesystem::exists(metadata_path, ec) && !ec) {
|
||||||
absl::Time created_at = absl::Now(); // Default to now if parsing fails
|
std::ifstream metadata_file(metadata_path);
|
||||||
if (proposal_id.starts_with("proposal-")) {
|
if (metadata_file.is_open()) {
|
||||||
std::string time_str = proposal_id.substr(9, 15); // Extract YYYYMMDDTHHmmSS
|
try {
|
||||||
std::string error;
|
nlohmann::json metadata_json;
|
||||||
if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
|
metadata_file >> metadata_json;
|
||||||
// Successfully parsed time
|
|
||||||
|
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<std::string>());
|
||||||
|
} 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<std::string>());
|
||||||
|
} 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<int64_t>(
|
||||||
|
"created_at_millis", TimeToMillis(absl::Now()));
|
||||||
|
metadata.created_at = absl::FromUnixMillis(created_at_millis);
|
||||||
|
|
||||||
|
int64_t reviewed_at_millis = metadata_json.value<int64_t>(
|
||||||
|
"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<std::string>());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
if (!metadata_loaded) {
|
||||||
auto ftime = std::filesystem::last_write_time(log_path, ec);
|
std::filesystem::path log_path = entry.path() / "execution.log";
|
||||||
if (!ec) {
|
if (!std::filesystem::exists(log_path, ec) || ec) {
|
||||||
auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
|
continue;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create minimal metadata for this proposal
|
std::filesystem::path diff_path = entry.path() / "diff.txt";
|
||||||
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,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Count diff size if it exists
|
absl::Time created_at = absl::Now();
|
||||||
if (std::filesystem::exists(diff_path, ec) && !ec) {
|
if (proposal_id.starts_with("proposal-")) {
|
||||||
metadata.bytes_changed = static_cast<int>(
|
std::string time_str = proposal_id.substr(9, 15);
|
||||||
std::filesystem::file_size(diff_path, ec));
|
std::string error;
|
||||||
}
|
if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
|
||||||
|
// Parsed successfully.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Scan for screenshots
|
auto ftime = std::filesystem::last_write_time(log_path, ec);
|
||||||
for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) {
|
if (!ec) {
|
||||||
if (ec) continue;
|
auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
|
||||||
if (file.path().extension() == ".png" ||
|
ftime - std::filesystem::file_time_type::clock::now() +
|
||||||
file.path().extension() == ".jpg" ||
|
std::chrono::system_clock::now());
|
||||||
file.path().extension() == ".jpeg") {
|
auto time_t_value = std::chrono::system_clock::to_time_t(sctp);
|
||||||
metadata.screenshots.push_back(file.path());
|
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<int>(
|
||||||
|
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();
|
return absl::OkStatus();
|
||||||
@@ -171,6 +285,7 @@ std::filesystem::path ProposalRegistry::ProposalDirectory(
|
|||||||
|
|
||||||
absl::StatusOr<ProposalRegistry::ProposalMetadata>
|
absl::StatusOr<ProposalRegistry::ProposalMetadata>
|
||||||
ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
|
ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
|
||||||
|
const std::filesystem::path& sandbox_rom_path,
|
||||||
absl::string_view prompt,
|
absl::string_view prompt,
|
||||||
absl::string_view description) {
|
absl::string_view description) {
|
||||||
std::unique_lock<std::mutex> lock(mutex_);
|
std::unique_lock<std::mutex> lock(mutex_);
|
||||||
@@ -191,6 +306,10 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
|
|||||||
proposals_[id] = ProposalMetadata{
|
proposals_[id] = ProposalMetadata{
|
||||||
.id = id,
|
.id = id,
|
||||||
.sandbox_id = std::string(sandbox_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),
|
.description = std::string(description),
|
||||||
.prompt = std::string(prompt),
|
.prompt = std::string(prompt),
|
||||||
.status = ProposalStatus::kPending,
|
.status = ProposalStatus::kPending,
|
||||||
@@ -203,6 +322,8 @@ ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
|
|||||||
.commands_executed = 0,
|
.commands_executed = 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
RETURN_IF_ERROR(WriteMetadataLocked(proposals_.at(id)));
|
||||||
|
|
||||||
return 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)
|
// Update bytes_changed metric (rough estimate based on diff size)
|
||||||
it->second.bytes_changed = static_cast<int>(diff_content.size());
|
it->second.bytes_changed = static_cast<int>(diff_content.size());
|
||||||
|
|
||||||
|
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
|
||||||
|
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -272,6 +395,21 @@ absl::Status ProposalRegistry::AddScreenshot(
|
|||||||
}
|
}
|
||||||
|
|
||||||
it->second.screenshots.push_back(screenshot_path);
|
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<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.commands_executed = commands_executed;
|
||||||
|
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +424,7 @@ absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id,
|
|||||||
|
|
||||||
it->second.status = status;
|
it->second.status = status;
|
||||||
it->second.reviewed_at = absl::Now();
|
it->second.reviewed_at = absl::Now();
|
||||||
|
RETURN_IF_ERROR(WriteMetadataLocked(it->second));
|
||||||
return absl::OkStatus();
|
return absl::OkStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -354,6 +492,70 @@ ProposalRegistry::GetLatestPendingProposal() const {
|
|||||||
return *latest;
|
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) {
|
absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) {
|
||||||
std::lock_guard<std::mutex> lock(mutex_);
|
std::lock_guard<std::mutex> lock(mutex_);
|
||||||
auto it = proposals_.find(proposal_id);
|
auto it = proposals_.find(proposal_id);
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ class ProposalRegistry {
|
|||||||
struct ProposalMetadata {
|
struct ProposalMetadata {
|
||||||
std::string id;
|
std::string id;
|
||||||
std::string sandbox_id;
|
std::string sandbox_id;
|
||||||
|
std::filesystem::path sandbox_directory;
|
||||||
|
std::filesystem::path sandbox_rom_path;
|
||||||
std::string description;
|
std::string description;
|
||||||
std::string prompt; // Original agent prompt that created this proposal
|
std::string prompt; // Original agent prompt that created this proposal
|
||||||
ProposalStatus status;
|
ProposalStatus status;
|
||||||
@@ -65,6 +67,7 @@ class ProposalRegistry {
|
|||||||
// is created under the root, and metadata is initialized.
|
// is created under the root, and metadata is initialized.
|
||||||
absl::StatusOr<ProposalMetadata> CreateProposal(
|
absl::StatusOr<ProposalMetadata> CreateProposal(
|
||||||
absl::string_view sandbox_id,
|
absl::string_view sandbox_id,
|
||||||
|
const std::filesystem::path& sandbox_rom_path,
|
||||||
absl::string_view prompt,
|
absl::string_view prompt,
|
||||||
absl::string_view description);
|
absl::string_view description);
|
||||||
|
|
||||||
@@ -82,6 +85,11 @@ class ProposalRegistry {
|
|||||||
absl::Status AddScreenshot(const std::string& proposal_id,
|
absl::Status AddScreenshot(const std::string& proposal_id,
|
||||||
const std::filesystem::path& screenshot_path);
|
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
|
// Updates the proposal status (pending -> accepted/rejected) and sets
|
||||||
// the review timestamp.
|
// the review timestamp.
|
||||||
absl::Status UpdateStatus(const std::string& proposal_id,
|
absl::Status UpdateStatus(const std::string& proposal_id,
|
||||||
@@ -111,6 +119,7 @@ class ProposalRegistry {
|
|||||||
absl::Status LoadProposalsFromDiskLocked();
|
absl::Status LoadProposalsFromDiskLocked();
|
||||||
std::string GenerateProposalIdLocked();
|
std::string GenerateProposalIdLocked();
|
||||||
std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const;
|
std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const;
|
||||||
|
absl::Status WriteMetadataLocked(const ProposalMetadata& metadata) const;
|
||||||
|
|
||||||
std::filesystem::path root_directory_;
|
std::filesystem::path root_directory_;
|
||||||
mutable std::mutex mutex_;
|
mutable std::mutex mutex_;
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
#include "cli/service/planning/tile16_proposal_generator.h"
|
#include "cli/service/planning/tile16_proposal_generator.h"
|
||||||
|
|
||||||
#include <sstream>
|
|
||||||
#include <fstream>
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
|
||||||
#include "absl/strings/match.h"
|
#include "absl/strings/match.h"
|
||||||
#include "absl/strings/str_split.h"
|
#include "absl/strings/str_split.h"
|
||||||
#include "absl/strings/str_cat.h"
|
#include "absl/strings/str_cat.h"
|
||||||
|
#include "absl/strings/numbers.h"
|
||||||
#include "app/zelda3/overworld/overworld.h"
|
#include "app/zelda3/overworld/overworld.h"
|
||||||
|
#include "nlohmann/json.hpp"
|
||||||
|
#include "util/macro.h"
|
||||||
|
|
||||||
namespace yaze {
|
namespace yaze {
|
||||||
namespace cli {
|
namespace cli {
|
||||||
@@ -54,9 +57,175 @@ std::string Tile16Proposal::ToJson() const {
|
|||||||
return json.str();
|
return json.str();
|
||||||
}
|
}
|
||||||
|
|
||||||
absl::StatusOr<Tile16Proposal> Tile16Proposal::FromJson(const std::string& /* json */) {
|
namespace {
|
||||||
// TODO: Implement JSON parsing using nlohmann/json when available
|
|
||||||
return absl::UnimplementedError("JSON parsing not yet implemented");
|
absl::StatusOr<uint16_t> 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<int>();
|
||||||
|
if (value < 0 || value > 0xFFFF) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
absl::StrCat("Tile value for '", field,
|
||||||
|
"' out of range: ", value));
|
||||||
|
}
|
||||||
|
return static_cast<uint16_t>(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json[field].is_string()) {
|
||||||
|
std::string value = json[field].get<std::string>();
|
||||||
|
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<std::string>()));
|
||||||
|
}
|
||||||
|
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<std::string>()));
|
||||||
|
}
|
||||||
|
return static_cast<uint16_t>(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<std::string>()));
|
||||||
|
}
|
||||||
|
return static_cast<uint16_t>(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> 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<std::string>();
|
||||||
|
|
||||||
|
if (!json.contains("prompt") || !json["prompt"].is_string()) {
|
||||||
|
return absl::InvalidArgumentError(
|
||||||
|
"Proposal JSON must include string field 'prompt'");
|
||||||
|
}
|
||||||
|
proposal.prompt = json["prompt"].get<std::string>();
|
||||||
|
|
||||||
|
if (json.contains("ai_service") && json["ai_service"].is_string()) {
|
||||||
|
proposal.ai_service = json["ai_service"].get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (json.contains("reasoning") && json["reasoning"].is_string()) {
|
||||||
|
proposal.reasoning = json["reasoning"].get<std::string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
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<std::string>());
|
||||||
|
} 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<int>();
|
||||||
|
|
||||||
|
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<int>();
|
||||||
|
|
||||||
|
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<int>();
|
||||||
|
|
||||||
|
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<int64_t>();
|
||||||
|
proposal.created_at = std::chrono::system_clock::time_point(
|
||||||
|
std::chrono::milliseconds(millis));
|
||||||
|
}
|
||||||
|
|
||||||
|
return proposal;
|
||||||
}
|
}
|
||||||
|
|
||||||
std::string Tile16ProposalGenerator::GenerateProposalId() const {
|
std::string Tile16ProposalGenerator::GenerateProposalId() const {
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ endif()
|
|||||||
target_link_libraries(yaze_test yaze_agent)
|
target_link_libraries(yaze_test yaze_agent)
|
||||||
endif()
|
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)
|
target_link_libraries(yaze_test yaze_emulator)
|
||||||
endif()
|
endif()
|
||||||
else()
|
else()
|
||||||
|
|||||||
Reference in New Issue
Block a user