yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
proposal_registry.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <chrono>
5#include <cstdlib>
6#include <fstream>
7#include <iostream>
8
9#include "absl/status/status.h"
10#include "absl/status/statusor.h"
11#include "absl/strings/ascii.h"
12#include "absl/strings/match.h"
13#include "absl/strings/str_cat.h"
14#include "absl/strings/str_format.h"
15#include "absl/time/clock.h"
16#include "absl/time/time.h"
17#include "nlohmann/json.hpp"
18#include "util/macro.h"
19
20namespace yaze {
21namespace cli {
22
23namespace {
24
25std::filesystem::path DetermineDefaultRoot() {
26 if (const char* env_root = std::getenv("YAZE_PROPOSAL_ROOT")) {
27 return std::filesystem::path(env_root);
28 }
29 std::error_code ec;
30 auto temp_dir = std::filesystem::temp_directory_path(ec);
31 if (ec) {
32 return std::filesystem::current_path() / "yaze" / "proposals";
33 }
34 return temp_dir / "yaze" / "proposals";
35}
36
38 switch (status) {
40 return "accepted";
42 return "rejected";
44 default:
45 return "pending";
46 }
47}
48
50 std::string lower = absl::AsciiStrToLower(value);
51 if (absl::StartsWith(lower, "accept")) {
53 }
54 if (absl::StartsWith(lower, "reject")) {
56 }
58}
59
60int64_t TimeToMillis(absl::Time time) {
61 return absl::ToUnixMillis(time);
62}
63
64std::optional<absl::Time> OptionalTimeFromMillis(int64_t millis) {
65 if (millis <= 0) {
66 return std::nullopt;
67 }
68 return absl::FromUnixMillis(millis);
69}
70
71} // namespace
72
74 static ProposalRegistry* instance = new ProposalRegistry();
75 return *instance;
76}
77
79 : root_directory_(DetermineDefaultRoot()) {}
80
81void ProposalRegistry::SetRootDirectory(const std::filesystem::path& root) {
82 std::lock_guard<std::mutex> lock(mutex_);
83 root_directory_ = root;
85}
86
87const std::filesystem::path& ProposalRegistry::RootDirectory() const {
88 return root_directory_;
89}
90
92 std::error_code ec;
93 if (!std::filesystem::exists(root_directory_, ec)) {
94 if (!std::filesystem::create_directories(root_directory_, ec) && ec) {
95 return absl::InternalError(
96 absl::StrCat("Failed to create proposal root at ",
97 root_directory_.string(), ": ", ec.message()));
98 }
99 }
100 return absl::OkStatus();
101}
102
104 std::error_code ec;
105
106 if (!std::filesystem::exists(root_directory_, ec)) {
107 return absl::OkStatus();
108 }
109
110 for (const auto& entry :
111 std::filesystem::directory_iterator(root_directory_, ec)) {
112 if (ec) {
113 break;
114 }
115
116 if (!entry.is_directory()) {
117 continue;
118 }
119
120 const std::string proposal_id = entry.path().filename().string();
121 if (proposals_.find(proposal_id) != proposals_.end()) {
122 continue;
123 }
124
125 ProposalMetadata metadata;
126 bool metadata_loaded = false;
127 const std::filesystem::path metadata_path = entry.path() / "metadata.json";
128
129 if (std::filesystem::exists(metadata_path, ec) && !ec) {
130 std::ifstream metadata_file(metadata_path);
131 if (metadata_file.is_open()) {
132 try {
133 nlohmann::json metadata_json;
134 metadata_file >> metadata_json;
135
136 metadata.id = metadata_json.value("id", proposal_id);
137 if (metadata.id.empty()) {
138 metadata.id = proposal_id;
139 }
140 metadata.sandbox_id = metadata_json.value("sandbox_id", "");
141
142 if (metadata_json.contains("sandbox_directory") &&
143 metadata_json["sandbox_directory"].is_string()) {
144 metadata.sandbox_directory = std::filesystem::path(
145 metadata_json["sandbox_directory"].get<std::string>());
146 } else {
147 metadata.sandbox_directory.clear();
148 }
149
150 if (metadata_json.contains("sandbox_rom_path") &&
151 metadata_json["sandbox_rom_path"].is_string()) {
152 metadata.sandbox_rom_path = std::filesystem::path(
153 metadata_json["sandbox_rom_path"].get<std::string>());
154 } else {
155 metadata.sandbox_rom_path.clear();
156 }
157
158 metadata.description = metadata_json.value("description", "");
159 metadata.prompt = metadata_json.value("prompt", "");
160 metadata.status =
161 ParseStatus(metadata_json.value("status", "pending"));
162
163 int64_t created_at_millis = metadata_json.value<int64_t>(
164 "created_at_millis", TimeToMillis(absl::Now()));
165 metadata.created_at = absl::FromUnixMillis(created_at_millis);
166
167 int64_t reviewed_at_millis =
168 metadata_json.value<int64_t>("reviewed_at_millis", 0);
169 metadata.reviewed_at = OptionalTimeFromMillis(reviewed_at_millis);
170
171 std::string diff_path =
172 metadata_json.value("diff_path", std::string("diff.txt"));
173 std::string log_path =
174 metadata_json.value("log_path", std::string("execution.log"));
175 metadata.diff_path = entry.path() / diff_path;
176 metadata.log_path = entry.path() / log_path;
177
178 metadata.bytes_changed = metadata_json.value("bytes_changed", 0);
179 metadata.commands_executed =
180 metadata_json.value("commands_executed", 0);
181
182 metadata.screenshots.clear();
183 if (metadata_json.contains("screenshots") &&
184 metadata_json["screenshots"].is_array()) {
185 for (const auto& screenshot : metadata_json["screenshots"]) {
186 if (screenshot.is_string()) {
187 metadata.screenshots.emplace_back(
188 entry.path() / screenshot.get<std::string>());
189 }
190 }
191 }
192
193 if (metadata.sandbox_directory.empty() &&
194 !metadata.sandbox_rom_path.empty()) {
195 metadata.sandbox_directory =
196 metadata.sandbox_rom_path.parent_path();
197 }
198
199 metadata_loaded = true;
200 } catch (const std::exception& ex) {
201 std::cerr << "Warning: Failed to parse metadata for proposal "
202 << proposal_id << ": " << ex.what() << "\n";
203 }
204 }
205 }
206
207 if (!metadata_loaded) {
208 std::filesystem::path log_path = entry.path() / "execution.log";
209 if (!std::filesystem::exists(log_path, ec) || ec) {
210 continue;
211 }
212
213 std::filesystem::path diff_path = entry.path() / "diff.txt";
214
215 absl::Time created_at = absl::Now();
216 if (proposal_id.starts_with("proposal-")) {
217 std::string time_str = proposal_id.substr(9, 15);
218 std::string error;
219 if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
220 // Parsed successfully.
221 }
222 }
223
224 auto ftime = std::filesystem::last_write_time(log_path, ec);
225 if (!ec) {
226 auto sctp =
227 std::chrono::time_point_cast<std::chrono::system_clock::duration>(
228 ftime - std::filesystem::file_time_type::clock::now() +
229 std::chrono::system_clock::now());
230 auto time_t_value = std::chrono::system_clock::to_time_t(sctp);
231 created_at = absl::FromTimeT(time_t_value);
232 }
233
234 metadata = ProposalMetadata{
235 .id = proposal_id,
236 .sandbox_id = "",
237 .sandbox_directory = std::filesystem::path(),
238 .sandbox_rom_path = std::filesystem::path(),
239 .description = "Loaded from disk",
240 .prompt = "",
241 .status = ProposalStatus::kPending,
242 .created_at = created_at,
243 .reviewed_at = std::nullopt,
244 .diff_path = diff_path,
245 .log_path = log_path,
246 .screenshots = {},
247 .bytes_changed = 0,
248 .commands_executed = 0,
249 };
250
251 if (std::filesystem::exists(diff_path, ec) && !ec) {
252 metadata.bytes_changed =
253 static_cast<int>(std::filesystem::file_size(diff_path, ec));
254 }
255
256 for (const auto& file :
257 std::filesystem::directory_iterator(entry.path(), ec)) {
258 if (ec) {
259 break;
260 }
261 if (file.path().extension() == ".png" ||
262 file.path().extension() == ".jpg" ||
263 file.path().extension() == ".jpeg") {
264 metadata.screenshots.push_back(file.path());
265 }
266 }
267
268 // Create a metadata file for legacy proposals so future loads are fast.
269 absl::Status write_status = WriteMetadataLocked(metadata);
270 if (!write_status.ok()) {
271 std::cerr << "Warning: Failed to persist metadata for legacy proposal "
272 << proposal_id << ": " << write_status.message() << "\n";
273 }
274 }
275
276 proposals_[metadata.id] = metadata;
277 }
278
279 return absl::OkStatus();
280}
281
283 absl::Time now = absl::Now();
284 std::string time_component =
285 absl::FormatTime("%Y%m%dT%H%M%S", now, absl::LocalTimeZone());
286 ++sequence_;
287 return absl::StrCat("proposal-", time_component, "-", sequence_);
288}
289
291 absl::string_view proposal_id) const {
292 return root_directory_ / std::string(proposal_id);
293}
294
295absl::StatusOr<ProposalRegistry::ProposalMetadata>
296ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
297 const std::filesystem::path& sandbox_rom_path,
298 absl::string_view prompt,
299 absl::string_view description) {
300 std::unique_lock<std::mutex> lock(mutex_);
302
303 std::string id = GenerateProposalIdLocked();
304 std::filesystem::path proposal_dir = ProposalDirectory(id);
305 lock.unlock();
306
307 std::error_code ec;
308 if (!std::filesystem::create_directories(proposal_dir, ec) && ec) {
309 return absl::InternalError(
310 absl::StrCat("Failed to create proposal directory at ",
311 proposal_dir.string(), ": ", ec.message()));
312 }
313
314 lock.lock();
316 .id = id,
317 .sandbox_id = std::string(sandbox_id),
318 .sandbox_directory = sandbox_rom_path.empty()
319 ? std::filesystem::path()
320 : sandbox_rom_path.parent_path(),
321 .sandbox_rom_path = sandbox_rom_path,
322 .description = std::string(description),
323 .prompt = std::string(prompt),
324 .status = ProposalStatus::kPending,
325 .created_at = absl::Now(),
326 .reviewed_at = std::nullopt,
327 .diff_path = proposal_dir / "diff.txt",
328 .log_path = proposal_dir / "execution.log",
329 .screenshots = {},
330 .bytes_changed = 0,
331 .commands_executed = 0,
332 };
333
335
336 return proposals_.at(id);
337}
338
339absl::Status ProposalRegistry::RecordDiff(const std::string& proposal_id,
340 absl::string_view diff_content) {
341 std::lock_guard<std::mutex> lock(mutex_);
342 auto it = proposals_.find(proposal_id);
343 if (it == proposals_.end()) {
344 return absl::NotFoundError(
345 absl::StrCat("Proposal not found: ", proposal_id));
346 }
347
348 std::ofstream diff_file(it->second.diff_path, std::ios::out);
349 if (!diff_file.is_open()) {
350 return absl::InternalError(absl::StrCat("Failed to open diff file: ",
351 it->second.diff_path.string()));
352 }
353
354 diff_file << diff_content;
355 diff_file.close();
356
357 // Update bytes_changed metric (rough estimate based on diff size)
358 it->second.bytes_changed = static_cast<int>(diff_content.size());
359
361
362 return absl::OkStatus();
363}
364
365absl::Status ProposalRegistry::AppendLog(const std::string& proposal_id,
366 absl::string_view log_entry) {
367 std::lock_guard<std::mutex> lock(mutex_);
368 auto it = proposals_.find(proposal_id);
369 if (it == proposals_.end()) {
370 return absl::NotFoundError(
371 absl::StrCat("Proposal not found: ", proposal_id));
372 }
373
374 std::ofstream log_file(it->second.log_path, std::ios::out | std::ios::app);
375 if (!log_file.is_open()) {
376 return absl::InternalError(absl::StrCat("Failed to open log file: ",
377 it->second.log_path.string()));
378 }
379
380 log_file << absl::FormatTime("[%Y-%m-%d %H:%M:%S] ", absl::Now(),
381 absl::LocalTimeZone())
382 << log_entry << "\n";
383 log_file.close();
384
385 return absl::OkStatus();
386}
387
389 const std::string& proposal_id,
390 const std::filesystem::path& screenshot_path) {
391 std::lock_guard<std::mutex> lock(mutex_);
392 auto it = proposals_.find(proposal_id);
393 if (it == proposals_.end()) {
394 return absl::NotFoundError(
395 absl::StrCat("Proposal not found: ", proposal_id));
396 }
397
398 // Verify screenshot exists
399 std::error_code ec;
400 if (!std::filesystem::exists(screenshot_path, ec)) {
401 return absl::NotFoundError(
402 absl::StrCat("Screenshot file not found: ", screenshot_path.string()));
403 }
404
405 it->second.screenshots.push_back(screenshot_path);
407 return absl::OkStatus();
408}
409
411 const std::string& proposal_id, int commands_executed) {
412 std::lock_guard<std::mutex> lock(mutex_);
413 auto it = proposals_.find(proposal_id);
414 if (it == proposals_.end()) {
415 return absl::NotFoundError(
416 absl::StrCat("Proposal not found: ", proposal_id));
417 }
418
419 it->second.commands_executed = commands_executed;
421 return absl::OkStatus();
422}
423
424absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id,
425 ProposalStatus status) {
426 std::lock_guard<std::mutex> lock(mutex_);
427 auto it = proposals_.find(proposal_id);
428 if (it == proposals_.end()) {
429 return absl::NotFoundError(
430 absl::StrCat("Proposal not found: ", proposal_id));
431 }
432
433 it->second.status = status;
434 it->second.reviewed_at = absl::Now();
436 return absl::OkStatus();
437}
438
439absl::StatusOr<ProposalRegistry::ProposalMetadata>
440ProposalRegistry::GetProposal(const std::string& proposal_id) const {
441 std::lock_guard<std::mutex> lock(mutex_);
442 auto it = proposals_.find(proposal_id);
443 if (it == proposals_.end()) {
444 return absl::NotFoundError(
445 absl::StrCat("Proposal not found: ", proposal_id));
446 }
447 return it->second;
448}
449
450std::vector<ProposalRegistry::ProposalMetadata> ProposalRegistry::ListProposals(
451 std::optional<ProposalStatus> filter_status) const {
452 std::unique_lock<std::mutex> lock(mutex_);
453
454 // Load proposals from disk if we haven't already
455 if (proposals_.empty()) {
456 // Cast away const for loading - this is a lazy initialization pattern
457 auto* self = const_cast<ProposalRegistry*>(this);
458 auto status = self->LoadProposalsFromDiskLocked();
459 if (!status.ok()) {
460 // Log error but continue - return empty list if loading fails
461 std::cerr << "Warning: Failed to load proposals from disk: "
462 << status.message() << "\n";
463 }
464 }
465
466 std::vector<ProposalMetadata> result;
467
468 for (const auto& [id, metadata] : proposals_) {
469 if (!filter_status.has_value() || metadata.status == *filter_status) {
470 result.push_back(metadata);
471 }
472 }
473
474 // Sort by creation time (newest first)
475 std::sort(result.begin(), result.end(),
476 [](const ProposalMetadata& a, const ProposalMetadata& b) {
477 return a.created_at > b.created_at;
478 });
479
480 return result;
481}
482
483absl::StatusOr<ProposalRegistry::ProposalMetadata>
485 std::lock_guard<std::mutex> lock(mutex_);
486
487 const ProposalMetadata* latest = nullptr;
488 for (const auto& [id, metadata] : proposals_) {
489 if (metadata.status == ProposalStatus::kPending) {
490 if (!latest || metadata.created_at > latest->created_at) {
491 latest = &metadata;
492 }
493 }
494 }
495
496 if (!latest) {
497 return absl::NotFoundError("No pending proposals found");
498 }
499
500 return *latest;
501}
502
504 const ProposalMetadata& metadata) const {
505 std::filesystem::path proposal_dir = ProposalDirectory(metadata.id);
506 std::error_code ec;
507 if (!std::filesystem::exists(proposal_dir, ec) || ec) {
508 return absl::NotFoundError(
509 absl::StrCat("Proposal directory missing for ", metadata.id));
510 }
511
512 auto relative_to_proposal = [&](const std::filesystem::path& path) {
513 if (path.empty()) {
514 return std::string();
515 }
516 std::error_code relative_error;
517 auto relative_path =
518 std::filesystem::relative(path, proposal_dir, relative_error);
519 if (!relative_error) {
520 return relative_path.generic_string();
521 }
522 return path.generic_string();
523 };
524
525 nlohmann::json metadata_json;
526 metadata_json["id"] = metadata.id;
527 metadata_json["sandbox_id"] = metadata.sandbox_id;
528 if (!metadata.sandbox_directory.empty()) {
529 metadata_json["sandbox_directory"] =
530 metadata.sandbox_directory.generic_string();
531 }
532 if (!metadata.sandbox_rom_path.empty()) {
533 metadata_json["sandbox_rom_path"] =
534 metadata.sandbox_rom_path.generic_string();
535 }
536 metadata_json["description"] = metadata.description;
537 metadata_json["prompt"] = metadata.prompt;
538 metadata_json["status"] = StatusToString(metadata.status);
539 metadata_json["created_at_millis"] = TimeToMillis(metadata.created_at);
540 metadata_json["reviewed_at_millis"] =
541 metadata.reviewed_at.has_value() ? TimeToMillis(*metadata.reviewed_at)
542 : int64_t{0};
543 metadata_json["diff_path"] = relative_to_proposal(metadata.diff_path);
544 metadata_json["log_path"] = relative_to_proposal(metadata.log_path);
545 metadata_json["bytes_changed"] = metadata.bytes_changed;
546 metadata_json["commands_executed"] = metadata.commands_executed;
547
548 nlohmann::json screenshots_json = nlohmann::json::array();
549 for (const auto& screenshot : metadata.screenshots) {
550 screenshots_json.push_back(relative_to_proposal(screenshot));
551 }
552 metadata_json["screenshots"] = std::move(screenshots_json);
553
554 std::ofstream metadata_file(proposal_dir / "metadata.json", std::ios::out);
555 if (!metadata_file.is_open()) {
556 return absl::InternalError(absl::StrCat(
557 "Failed to write metadata file for proposal ", metadata.id));
558 }
559
560 metadata_file << metadata_json.dump(2);
561 metadata_file.close();
562 if (!metadata_file) {
563 return absl::InternalError(absl::StrCat(
564 "Failed to flush metadata file for proposal ", metadata.id));
565 }
566
567 return absl::OkStatus();
568}
569
570absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) {
571 std::lock_guard<std::mutex> lock(mutex_);
572 auto it = proposals_.find(proposal_id);
573 if (it == proposals_.end()) {
574 return absl::NotFoundError(
575 absl::StrCat("Proposal not found: ", proposal_id));
576 }
577
578 std::filesystem::path proposal_dir = ProposalDirectory(proposal_id);
579 std::error_code ec;
580 std::filesystem::remove_all(proposal_dir, ec);
581 if (ec) {
582 return absl::InternalError(
583 absl::StrCat("Failed to remove proposal directory: ", ec.message()));
584 }
585
586 proposals_.erase(it);
587 return absl::OkStatus();
588}
589
590absl::StatusOr<int> ProposalRegistry::CleanupOlderThan(absl::Duration max_age) {
591 std::lock_guard<std::mutex> lock(mutex_);
592 absl::Time cutoff = absl::Now() - max_age;
593 int removed_count = 0;
594
595 std::vector<std::string> to_remove;
596 for (const auto& [id, metadata] : proposals_) {
597 if (metadata.created_at < cutoff) {
598 to_remove.push_back(id);
599 }
600 }
601
602 for (const auto& id : to_remove) {
603 std::filesystem::path proposal_dir = ProposalDirectory(id);
604 std::error_code ec;
605 std::filesystem::remove_all(proposal_dir, ec);
606 // Continue even if removal fails
607 proposals_.erase(id);
608 ++removed_count;
609 }
610
611 return removed_count;
612}
613
614} // namespace cli
615} // namespace yaze
static ProposalRegistry & Instance()
absl::Status AppendLog(const std::string &proposal_id, absl::string_view log_entry)
absl::StatusOr< ProposalMetadata > GetLatestPendingProposal() const
std::filesystem::path root_directory_
absl::Status UpdateStatus(const std::string &proposal_id, ProposalStatus status)
absl::Status WriteMetadataLocked(const ProposalMetadata &metadata) const
std::unordered_map< std::string, ProposalMetadata > proposals_
absl::Status AddScreenshot(const std::string &proposal_id, const std::filesystem::path &screenshot_path)
std::filesystem::path ProposalDirectory(absl::string_view proposal_id) const
absl::Status RemoveProposal(const std::string &proposal_id)
absl::Status RecordDiff(const std::string &proposal_id, absl::string_view diff_content)
void SetRootDirectory(const std::filesystem::path &root)
const std::filesystem::path & RootDirectory() const
std::vector< ProposalMetadata > ListProposals(std::optional< ProposalStatus > filter_status=std::nullopt) const
absl::StatusOr< ProposalMetadata > CreateProposal(absl::string_view sandbox_id, const std::filesystem::path &sandbox_rom_path, absl::string_view prompt, absl::string_view description)
absl::StatusOr< ProposalMetadata > GetProposal(const std::string &proposal_id) const
absl::StatusOr< int > CleanupOlderThan(absl::Duration max_age)
absl::Status UpdateCommandStats(const std::string &proposal_id, int commands_executed)
std::optional< absl::Time > OptionalTimeFromMillis(int64_t millis)
std::string StatusToString(ProposalRegistry::ProposalStatus status)
ProposalRegistry::ProposalStatus ParseStatus(absl::string_view value)
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::vector< std::filesystem::path > screenshots