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