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