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
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(absl::StrCat(
96 "Failed to create proposal root at ", root_directory_.string(),
97 ": ", 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 : 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 =
144 std::filesystem::path(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 =
152 std::filesystem::path(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 = ParseStatus(metadata_json.value("status", "pending"));
160
161 int64_t created_at_millis = metadata_json.value<int64_t>(
162 "created_at_millis", TimeToMillis(absl::Now()));
163 metadata.created_at = absl::FromUnixMillis(created_at_millis);
164
165 int64_t reviewed_at_millis = metadata_json.value<int64_t>(
166 "reviewed_at_millis", 0);
167 metadata.reviewed_at = OptionalTimeFromMillis(reviewed_at_millis);
168
169 std::string diff_path = metadata_json.value("diff_path", std::string("diff.txt"));
170 std::string log_path = metadata_json.value("log_path", std::string("execution.log"));
171 metadata.diff_path = entry.path() / diff_path;
172 metadata.log_path = entry.path() / log_path;
173
174 metadata.bytes_changed = metadata_json.value("bytes_changed", 0);
175 metadata.commands_executed = metadata_json.value("commands_executed", 0);
176
177 metadata.screenshots.clear();
178 if (metadata_json.contains("screenshots") &&
179 metadata_json["screenshots"].is_array()) {
180 for (const auto& screenshot : metadata_json["screenshots"]) {
181 if (screenshot.is_string()) {
182 metadata.screenshots.emplace_back(entry.path() /
183 screenshot.get<std::string>());
184 }
185 }
186 }
187
188 if (metadata.sandbox_directory.empty() &&
189 !metadata.sandbox_rom_path.empty()) {
190 metadata.sandbox_directory = metadata.sandbox_rom_path.parent_path();
191 }
192
193 metadata_loaded = true;
194 } catch (const std::exception& ex) {
195 std::cerr << "Warning: Failed to parse metadata for proposal "
196 << proposal_id << ": " << ex.what() << "\n";
197 }
198 }
199 }
200
201 if (!metadata_loaded) {
202 std::filesystem::path log_path = entry.path() / "execution.log";
203 if (!std::filesystem::exists(log_path, ec) || ec) {
204 continue;
205 }
206
207 std::filesystem::path diff_path = entry.path() / "diff.txt";
208
209 absl::Time created_at = absl::Now();
210 if (proposal_id.starts_with("proposal-")) {
211 std::string time_str = proposal_id.substr(9, 15);
212 std::string error;
213 if (absl::ParseTime("%Y%m%dT%H%M%S", time_str, &created_at, &error)) {
214 // Parsed successfully.
215 }
216 }
217
218 auto ftime = std::filesystem::last_write_time(log_path, ec);
219 if (!ec) {
220 auto sctp = std::chrono::time_point_cast<std::chrono::system_clock::duration>(
221 ftime - std::filesystem::file_time_type::clock::now() +
222 std::chrono::system_clock::now());
223 auto time_t_value = std::chrono::system_clock::to_time_t(sctp);
224 created_at = absl::FromTimeT(time_t_value);
225 }
226
227 metadata = ProposalMetadata{
228 .id = proposal_id,
229 .sandbox_id = "",
230 .sandbox_directory = std::filesystem::path(),
231 .sandbox_rom_path = std::filesystem::path(),
232 .description = "Loaded from disk",
233 .prompt = "",
234 .status = ProposalStatus::kPending,
235 .created_at = created_at,
236 .reviewed_at = std::nullopt,
237 .diff_path = diff_path,
238 .log_path = log_path,
239 .screenshots = {},
240 .bytes_changed = 0,
241 .commands_executed = 0,
242 };
243
244 if (std::filesystem::exists(diff_path, ec) && !ec) {
245 metadata.bytes_changed = static_cast<int>(
246 std::filesystem::file_size(diff_path, ec));
247 }
248
249 for (const auto& file : std::filesystem::directory_iterator(entry.path(), ec)) {
250 if (ec) {
251 break;
252 }
253 if (file.path().extension() == ".png" || file.path().extension() == ".jpg" ||
254 file.path().extension() == ".jpeg") {
255 metadata.screenshots.push_back(file.path());
256 }
257 }
258
259 // Create a metadata file for legacy proposals so future loads are fast.
260 absl::Status write_status = WriteMetadataLocked(metadata);
261 if (!write_status.ok()) {
262 std::cerr << "Warning: Failed to persist metadata for legacy proposal "
263 << proposal_id << ": " << write_status.message() << "\n";
264 }
265 }
266
267 proposals_[metadata.id] = metadata;
268 }
269
270 return absl::OkStatus();
271}
272
274 absl::Time now = absl::Now();
275 std::string time_component = absl::FormatTime("%Y%m%dT%H%M%S", now,
276 absl::LocalTimeZone());
277 ++sequence_;
278 return absl::StrCat("proposal-", time_component, "-", sequence_);
279}
280
282 absl::string_view proposal_id) const {
283 return root_directory_ / std::string(proposal_id);
284}
285
286absl::StatusOr<ProposalRegistry::ProposalMetadata>
287ProposalRegistry::CreateProposal(absl::string_view sandbox_id,
288 const std::filesystem::path& sandbox_rom_path,
289 absl::string_view prompt,
290 absl::string_view description) {
291 std::unique_lock<std::mutex> lock(mutex_);
293
294 std::string id = GenerateProposalIdLocked();
295 std::filesystem::path proposal_dir = ProposalDirectory(id);
296 lock.unlock();
297
298 std::error_code ec;
299 if (!std::filesystem::create_directories(proposal_dir, ec) && ec) {
300 return absl::InternalError(absl::StrCat(
301 "Failed to create proposal directory at ", proposal_dir.string(),
302 ": ", ec.message()));
303 }
304
305 lock.lock();
307 .id = id,
308 .sandbox_id = std::string(sandbox_id),
309 .sandbox_directory = sandbox_rom_path.empty()
310 ? std::filesystem::path()
311 : sandbox_rom_path.parent_path(),
312 .sandbox_rom_path = sandbox_rom_path,
313 .description = std::string(description),
314 .prompt = std::string(prompt),
315 .status = ProposalStatus::kPending,
316 .created_at = absl::Now(),
317 .reviewed_at = std::nullopt,
318 .diff_path = proposal_dir / "diff.txt",
319 .log_path = proposal_dir / "execution.log",
320 .screenshots = {},
321 .bytes_changed = 0,
322 .commands_executed = 0,
323 };
324
326
327 return proposals_.at(id);
328}
329
330absl::Status ProposalRegistry::RecordDiff(const std::string& proposal_id,
331 absl::string_view diff_content) {
332 std::lock_guard<std::mutex> lock(mutex_);
333 auto it = proposals_.find(proposal_id);
334 if (it == proposals_.end()) {
335 return absl::NotFoundError(
336 absl::StrCat("Proposal not found: ", proposal_id));
337 }
338
339 std::ofstream diff_file(it->second.diff_path, std::ios::out);
340 if (!diff_file.is_open()) {
341 return absl::InternalError(absl::StrCat(
342 "Failed to open diff file: ", it->second.diff_path.string()));
343 }
344
345 diff_file << diff_content;
346 diff_file.close();
347
348 // Update bytes_changed metric (rough estimate based on diff size)
349 it->second.bytes_changed = static_cast<int>(diff_content.size());
350
352
353 return absl::OkStatus();
354}
355
356absl::Status ProposalRegistry::AppendLog(const std::string& proposal_id,
357 absl::string_view log_entry) {
358 std::lock_guard<std::mutex> lock(mutex_);
359 auto it = proposals_.find(proposal_id);
360 if (it == proposals_.end()) {
361 return absl::NotFoundError(
362 absl::StrCat("Proposal not found: ", proposal_id));
363 }
364
365 std::ofstream log_file(it->second.log_path,
366 std::ios::out | std::ios::app);
367 if (!log_file.is_open()) {
368 return absl::InternalError(absl::StrCat(
369 "Failed to open log file: ", it->second.log_path.string()));
370 }
371
372 log_file << absl::FormatTime("[%Y-%m-%d %H:%M:%S] ", absl::Now(),
373 absl::LocalTimeZone())
374 << log_entry << "\n";
375 log_file.close();
376
377 return absl::OkStatus();
378}
379
381 const std::string& proposal_id,
382 const std::filesystem::path& screenshot_path) {
383 std::lock_guard<std::mutex> lock(mutex_);
384 auto it = proposals_.find(proposal_id);
385 if (it == proposals_.end()) {
386 return absl::NotFoundError(
387 absl::StrCat("Proposal not found: ", proposal_id));
388 }
389
390 // Verify screenshot exists
391 std::error_code ec;
392 if (!std::filesystem::exists(screenshot_path, ec)) {
393 return absl::NotFoundError(
394 absl::StrCat("Screenshot file not found: ", screenshot_path.string()));
395 }
396
397 it->second.screenshots.push_back(screenshot_path);
399 return absl::OkStatus();
400}
401
402absl::Status ProposalRegistry::UpdateCommandStats(const std::string& proposal_id,
403 int commands_executed) {
404 std::lock_guard<std::mutex> lock(mutex_);
405 auto it = proposals_.find(proposal_id);
406 if (it == proposals_.end()) {
407 return absl::NotFoundError(
408 absl::StrCat("Proposal not found: ", proposal_id));
409 }
410
411 it->second.commands_executed = commands_executed;
413 return absl::OkStatus();
414}
415
416absl::Status ProposalRegistry::UpdateStatus(const std::string& proposal_id,
417 ProposalStatus status) {
418 std::lock_guard<std::mutex> lock(mutex_);
419 auto it = proposals_.find(proposal_id);
420 if (it == proposals_.end()) {
421 return absl::NotFoundError(
422 absl::StrCat("Proposal not found: ", proposal_id));
423 }
424
425 it->second.status = status;
426 it->second.reviewed_at = absl::Now();
428 return absl::OkStatus();
429}
430
431absl::StatusOr<ProposalRegistry::ProposalMetadata>
432ProposalRegistry::GetProposal(const std::string& proposal_id) const {
433 std::lock_guard<std::mutex> lock(mutex_);
434 auto it = proposals_.find(proposal_id);
435 if (it == proposals_.end()) {
436 return absl::NotFoundError(
437 absl::StrCat("Proposal not found: ", proposal_id));
438 }
439 return it->second;
440}
441
442std::vector<ProposalRegistry::ProposalMetadata>
443ProposalRegistry::ListProposals(std::optional<ProposalStatus> filter_status) const {
444 std::unique_lock<std::mutex> lock(mutex_);
445
446 // Load proposals from disk if we haven't already
447 if (proposals_.empty()) {
448 // Cast away const for loading - this is a lazy initialization pattern
449 auto* self = const_cast<ProposalRegistry*>(this);
450 auto status = self->LoadProposalsFromDiskLocked();
451 if (!status.ok()) {
452 // Log error but continue - return empty list if loading fails
453 std::cerr << "Warning: Failed to load proposals from disk: "
454 << status.message() << "\n";
455 }
456 }
457
458 std::vector<ProposalMetadata> result;
459
460 for (const auto& [id, metadata] : proposals_) {
461 if (!filter_status.has_value() || metadata.status == *filter_status) {
462 result.push_back(metadata);
463 }
464 }
465
466 // Sort by creation time (newest first)
467 std::sort(result.begin(), result.end(),
468 [](const ProposalMetadata& a, const ProposalMetadata& b) {
469 return a.created_at > b.created_at;
470 });
471
472 return result;
473}
474
475absl::StatusOr<ProposalRegistry::ProposalMetadata>
477 std::lock_guard<std::mutex> lock(mutex_);
478
479 const ProposalMetadata* latest = nullptr;
480 for (const auto& [id, metadata] : proposals_) {
481 if (metadata.status == ProposalStatus::kPending) {
482 if (!latest || metadata.created_at > latest->created_at) {
483 latest = &metadata;
484 }
485 }
486 }
487
488 if (!latest) {
489 return absl::NotFoundError("No pending proposals found");
490 }
491
492 return *latest;
493}
494
496 const ProposalMetadata& metadata) const {
497 std::filesystem::path proposal_dir = ProposalDirectory(metadata.id);
498 std::error_code ec;
499 if (!std::filesystem::exists(proposal_dir, ec) || ec) {
500 return absl::NotFoundError(
501 absl::StrCat("Proposal directory missing for ", metadata.id));
502 }
503
504 auto relative_to_proposal = [&](const std::filesystem::path& path) {
505 if (path.empty()) {
506 return std::string();
507 }
508 std::error_code relative_error;
509 auto relative_path = std::filesystem::relative(path, proposal_dir, relative_error);
510 if (!relative_error) {
511 return relative_path.generic_string();
512 }
513 return path.generic_string();
514 };
515
516 nlohmann::json metadata_json;
517 metadata_json["id"] = metadata.id;
518 metadata_json["sandbox_id"] = metadata.sandbox_id;
519 if (!metadata.sandbox_directory.empty()) {
520 metadata_json["sandbox_directory"] = metadata.sandbox_directory.generic_string();
521 }
522 if (!metadata.sandbox_rom_path.empty()) {
523 metadata_json["sandbox_rom_path"] = metadata.sandbox_rom_path.generic_string();
524 }
525 metadata_json["description"] = metadata.description;
526 metadata_json["prompt"] = metadata.prompt;
527 metadata_json["status"] = StatusToString(metadata.status);
528 metadata_json["created_at_millis"] = TimeToMillis(metadata.created_at);
529 metadata_json["reviewed_at_millis"] = metadata.reviewed_at.has_value()
530 ? TimeToMillis(*metadata.reviewed_at)
531 : int64_t{0};
532 metadata_json["diff_path"] = relative_to_proposal(metadata.diff_path);
533 metadata_json["log_path"] = relative_to_proposal(metadata.log_path);
534 metadata_json["bytes_changed"] = metadata.bytes_changed;
535 metadata_json["commands_executed"] = metadata.commands_executed;
536
537 nlohmann::json screenshots_json = nlohmann::json::array();
538 for (const auto& screenshot : metadata.screenshots) {
539 screenshots_json.push_back(relative_to_proposal(screenshot));
540 }
541 metadata_json["screenshots"] = std::move(screenshots_json);
542
543 std::ofstream metadata_file(proposal_dir / "metadata.json", std::ios::out);
544 if (!metadata_file.is_open()) {
545 return absl::InternalError(absl::StrCat(
546 "Failed to write metadata file for proposal ", metadata.id));
547 }
548
549 metadata_file << metadata_json.dump(2);
550 metadata_file.close();
551 if (!metadata_file) {
552 return absl::InternalError(absl::StrCat(
553 "Failed to flush metadata file for proposal ", metadata.id));
554 }
555
556 return absl::OkStatus();
557}
558
559absl::Status ProposalRegistry::RemoveProposal(const std::string& proposal_id) {
560 std::lock_guard<std::mutex> lock(mutex_);
561 auto it = proposals_.find(proposal_id);
562 if (it == proposals_.end()) {
563 return absl::NotFoundError(
564 absl::StrCat("Proposal not found: ", proposal_id));
565 }
566
567 std::filesystem::path proposal_dir = ProposalDirectory(proposal_id);
568 std::error_code ec;
569 std::filesystem::remove_all(proposal_dir, ec);
570 if (ec) {
571 return absl::InternalError(absl::StrCat(
572 "Failed to remove proposal directory: ", ec.message()));
573 }
574
575 proposals_.erase(it);
576 return absl::OkStatus();
577}
578
579absl::StatusOr<int> ProposalRegistry::CleanupOlderThan(absl::Duration max_age) {
580 std::lock_guard<std::mutex> lock(mutex_);
581 absl::Time cutoff = absl::Now() - max_age;
582 int removed_count = 0;
583
584 std::vector<std::string> to_remove;
585 for (const auto& [id, metadata] : proposals_) {
586 if (metadata.created_at < cutoff) {
587 to_remove.push_back(id);
588 }
589 }
590
591 for (const auto& id : to_remove) {
592 std::filesystem::path proposal_dir = ProposalDirectory(id);
593 std::error_code ec;
594 std::filesystem::remove_all(proposal_dir, ec);
595 // Continue even if removal fails
596 proposals_.erase(id);
597 ++removed_count;
598 }
599
600 return removed_count;
601}
602
603} // namespace cli
604} // 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)
#define RETURN_IF_ERROR(expression)
Definition macro.h:53
std::optional< absl::Time > OptionalTimeFromMillis(int64_t millis)
std::string StatusToString(ProposalRegistry::ProposalStatus status)
ProposalRegistry::ProposalStatus ParseStatus(absl::string_view value)
Main namespace for the application.
std::vector< std::filesystem::path > screenshots