yaze 0.3.2
Link to the Past ROM Editor
 
Loading...
Searching...
No Matches
project_plan_agent.cc
Go to the documentation of this file.
2
3#include <algorithm>
4#include <cctype>
5#include <initializer_list>
6#include <utility>
7
8#include "absl/strings/ascii.h"
9#include "absl/strings/str_format.h"
11#include "util/macro.h"
12
13namespace yaze::core {
14namespace {
15
16constexpr const char* kTargetKinds[] = {"step", "item", "decision",
17 "milestone"};
18constexpr const char* kStampKeys[] = {"agent", "observed_utc"};
19
20bool IsSha256(std::string_view text) {
21 return text.size() == 64 &&
22 std::all_of(text.begin(), text.end(), [](unsigned char c) {
23 return std::isdigit(c) || (c >= 'a' && c <= 'f');
24 });
25}
26
27absl::StatusOr<PlanTarget> ParseObservationKey(std::string_view key) {
28 PlanTarget target = PlanTarget::Parse(key);
29 const bool known_kind =
30 std::any_of(std::begin(kTargetKinds), std::end(kTargetKinds),
31 [&](const char* kind) { return target.kind == kind; });
32 if (!known_kind || target.id.empty()) {
33 return absl::InvalidArgumentError(absl::StrFormat(
34 "Observation key must be step:, item:, decision: or milestone: "
35 "followed by an ID: \"%s\"",
36 std::string(key)));
37 }
38 return target;
39}
40
41template <typename Entry>
42bool HasId(const std::vector<Entry>& entries, const std::string& id) {
43 return std::any_of(entries.begin(), entries.end(),
44 [&](const Entry& entry) { return entry.id == id; });
45}
46
47bool PlanHasTarget(const ProjectPlan& plan, const PlanTarget& target) {
48 if (target.kind == "step") {
49 return HasId(plan.route_steps, target.id);
50 }
51 if (target.kind == "item") {
52 return HasId(plan.route_items, target.id);
53 }
54 if (target.kind == "decision") {
55 return HasId(plan.decisions, target.id);
56 }
57 if (target.kind == "milestone") {
58 return HasId(plan.milestones, target.id);
59 }
60 return false;
61}
62
63absl::Status ValidateObservation(const std::string& key, const PlanJson& obs) {
64 if (!obs.is_object()) {
65 return absl::InvalidArgumentError(
66 absl::StrFormat("Observation \"%s\" must be an object", key));
67 }
68 for (const char* field : {"state", "summary", "next"}) {
69 auto it = obs.find(field);
70 if (it != obs.end() && !it->is_string()) {
71 return absl::InvalidArgumentError(absl::StrFormat(
72 "Observation \"%s\": \"%s\" must be a string", key, field));
73 }
74 }
75 if (auto it = obs.find("evidence"); it != obs.end()) {
76 if (!it->is_array() ||
77 !std::all_of(it->begin(), it->end(),
78 [](const PlanJson& v) { return v.is_string(); })) {
79 return absl::InvalidArgumentError(absl::StrFormat(
80 "Observation \"%s\": \"evidence\" must be an array of strings", key));
81 }
82 }
83 if (auto it = obs.find("prs"); it != obs.end()) {
84 if (!it->is_array() ||
85 !std::all_of(it->begin(), it->end(), [](const PlanJson& v) {
86 return v.is_number_integer() || v.is_string();
87 })) {
88 return absl::InvalidArgumentError(absl::StrFormat(
89 "Observation \"%s\": \"prs\" must be an array of numbers or strings",
90 key));
91 }
92 }
93 return absl::OkStatus();
94}
95
96// Key order is not meaningful for comparison; stamps are not facts.
97nlohmann::json Facts(const PlanJson& obs) {
98 nlohmann::json facts = nlohmann::json::parse(obs.dump());
99 for (const char* stamp : kStampKeys) {
100 facts.erase(std::string(stamp));
101 }
102 return facts;
103}
104
105absl::StatusOr<const PlanJson*> ExistingSection(const ProjectPlan& plan) {
106 auto it = plan.extra.find(std::string(kPlanAgentStatusKey));
107 if (it == plan.extra.end()) {
108 return static_cast<const PlanJson*>(nullptr);
109 }
110 if (!it->is_object()) {
111 return absl::FailedPreconditionError(
112 "The plan's \"agent_status\" section is not an object; repair it "
113 "before applying agent updates");
114 }
115 auto observations = it->find("observations");
116 if (observations != it->end() && !observations->is_object()) {
117 return absl::FailedPreconditionError(
118 "The plan's \"agent_status.observations\" is not an object; repair "
119 "it before applying agent updates");
120 }
121 return &*it;
122}
123
124absl::Status ReadOptionalString(const PlanJson& root, const char* key,
125 std::string* out) {
126 auto it = root.find(key);
127 if (it == root.end() || it->is_null()) {
128 return absl::OkStatus();
129 }
130 if (!it->is_string()) {
131 return absl::InvalidArgumentError(
132 absl::StrFormat("Agent update \"%s\" must be a string", key));
133 }
134 *out = it->get<std::string>();
135 return absl::OkStatus();
136}
137
138} // namespace
139
140absl::StatusOr<PlanAgentUpdate> PlanAgentUpdate::Parse(std::string_view text) {
141 PlanJson root = PlanJson::parse(text.begin(), text.end(), nullptr,
142 /*allow_exceptions=*/false);
143 if (root.is_discarded() || !root.is_object()) {
144 return absl::InvalidArgumentError("Agent update must be a JSON object");
145 }
146 auto schema = root.find("schema");
147 if (schema == root.end() || !schema->is_string() ||
148 schema->get<std::string>() != kPlanAgentUpdateSchema) {
149 return absl::InvalidArgumentError(
150 absl::StrFormat("Agent update must declare \"schema\": \"%s\"",
151 std::string(kPlanAgentUpdateSchema)));
152 }
153 for (auto it = root.begin(); it != root.end(); ++it) {
154 static constexpr const char* kKnown[] = {
155 "schema", "base_sha256", "agent", "observed_utc",
156 "sources", "observations", "remove"};
157 if (std::none_of(std::begin(kKnown), std::end(kKnown),
158 [&](const char* key) { return it.key() == key; })) {
159 return absl::InvalidArgumentError(absl::StrFormat(
160 "Agent update has an unknown field \"%s\"", it.key()));
161 }
162 }
163
164 PlanAgentUpdate update;
165 RETURN_IF_ERROR(ReadOptionalString(root, "base_sha256", &update.base_sha256));
166 update.base_sha256 = absl::AsciiStrToLower(update.base_sha256);
167 if (!update.base_sha256.empty() && !IsSha256(update.base_sha256)) {
168 return absl::InvalidArgumentError(
169 "Agent update \"base_sha256\" must be 64 hexadecimal characters");
170 }
171 RETURN_IF_ERROR(ReadOptionalString(root, "agent", &update.agent));
173 ReadOptionalString(root, "observed_utc", &update.observed_utc));
174
175 if (auto it = root.find("sources"); it != root.end() && !it->is_null()) {
176 if (!it->is_object()) {
177 return absl::InvalidArgumentError(
178 "Agent update \"sources\" must be an object");
179 }
180 update.sources = *it;
181 }
182 if (auto it = root.find("observations"); it != root.end() && !it->is_null()) {
183 if (!it->is_object()) {
184 return absl::InvalidArgumentError(
185 "Agent update \"observations\" must be an object");
186 }
187 update.observations = *it;
188 }
189 if (auto it = root.find("remove"); it != root.end() && !it->is_null()) {
190 if (!it->is_array()) {
191 return absl::InvalidArgumentError(
192 "Agent update \"remove\" must be an array of observation keys");
193 }
194 for (const auto& key : *it) {
195 if (!key.is_string()) {
196 return absl::InvalidArgumentError(
197 "Agent update \"remove\" must be an array of observation keys");
198 }
199 update.remove.push_back(key.get<std::string>());
200 }
201 }
202 return update;
203}
204
206 return sources_changed ||
207 std::any_of(changes.begin(), changes.end(), [](const auto& change) {
208 return change.kind != PlanAgentChangeKind::kUnchanged;
209 });
210}
211
213 switch (kind) {
215 return "added";
217 return "changed";
219 return "removed";
221 return "unchanged";
222 }
223 return "unchanged";
224}
225
226absl::StatusOr<PlanAgentDiff> DiffPlanAgentUpdate(
227 const ProjectPlan& plan, const PlanAgentUpdate& update) {
228 ASSIGN_OR_RETURN(const PlanJson* section, ExistingSection(plan));
229 static const PlanJson kEmpty = PlanJson::object();
230 const PlanJson* stored = &kEmpty;
231 if (section != nullptr) {
232 if (auto it = section->find("observations"); it != section->end()) {
233 stored = &*it;
234 }
235 }
236
237 PlanAgentDiff diff;
238 for (auto it = update.observations.begin(); it != update.observations.end();
239 ++it) {
240 const std::string& key = it.key();
241 ASSIGN_OR_RETURN(PlanTarget target, ParseObservationKey(key));
242 if (!PlanHasTarget(plan, target)) {
243 return absl::NotFoundError(absl::StrFormat(
244 "The plan has no %s with ID \"%s\"; agent updates use existing "
245 "plan IDs",
246 target.kind, target.id));
247 }
248 RETURN_IF_ERROR(ValidateObservation(key, it.value()));
249 if (std::find(update.remove.begin(), update.remove.end(), key) !=
250 update.remove.end()) {
251 return absl::InvalidArgumentError(absl::StrFormat(
252 "Observation \"%s\" is both updated and removed", key));
253 }
254 auto current = stored->find(key);
256 if (current != stored->end()) {
257 kind = Facts(*current) == Facts(it.value())
260 }
261 diff.changes.push_back({key, kind});
262 }
263 for (const auto& key : update.remove) {
264 RETURN_IF_ERROR(ParseObservationKey(key).status());
265 if (stored->contains(key)) {
266 diff.changes.push_back({key, PlanAgentChangeKind::kRemoved});
267 }
268 }
269 if (!update.sources.empty()) {
270 const PlanJson* current_sources = nullptr;
271 if (section != nullptr) {
272 if (auto it = section->find("sources"); it != section->end()) {
273 current_sources = &*it;
274 }
275 }
276 diff.sources_changed = current_sources == nullptr ||
277 nlohmann::json::parse(current_sources->dump()) !=
278 nlohmann::json::parse(update.sources.dump());
279 }
280 return diff;
281}
282
283absl::StatusOr<PlanAgentDiff> ApplyPlanAgentUpdate(
284 ProjectPlan* plan, const PlanAgentUpdate& update) {
285 if (plan == nullptr) {
286 return absl::InvalidArgumentError("A plan is required");
287 }
289 if (!diff.HasChanges()) {
290 return diff;
291 }
292 ASSIGN_OR_RETURN(const PlanJson* existing, ExistingSection(*plan));
293 PlanJson section = existing != nullptr ? *existing : PlanJson::object();
294 section["schema"] = std::string(kPlanAgentStatusSchema);
295 if (!update.observed_utc.empty()) {
296 section["updated_utc"] = update.observed_utc;
297 }
298 if (!update.agent.empty()) {
299 section["agent"] = update.agent;
300 }
301 if (!update.sources.empty()) {
302 section["sources"] = update.sources;
303 }
304 PlanJson& observations = section["observations"];
305 if (observations.is_null()) {
306 observations = PlanJson::object();
307 }
308 for (const auto& change : diff.changes) {
309 switch (change.kind) {
312 PlanJson obs = update.observations.at(change.key);
313 for (const char* stamp : kStampKeys) {
314 obs.erase(std::string(stamp));
315 }
316 if (!update.agent.empty()) {
317 obs["agent"] = update.agent;
318 }
319 if (!update.observed_utc.empty()) {
320 obs["observed_utc"] = update.observed_utc;
321 }
322 observations[change.key] = std::move(obs);
323 break;
324 }
326 observations.erase(change.key);
327 break;
329 break;
330 }
331 }
332 plan->extra[std::string(kPlanAgentStatusKey)] = std::move(section);
333 return diff;
334}
335
336absl::StatusOr<PlanAgentFileResult> ApplyPlanAgentUpdateToFile(
337 const std::string& path, const PlanAgentUpdate& update, bool dry_run) {
338 if (!dry_run && update.base_sha256.empty()) {
339 return absl::InvalidArgumentError(
340 "Agent update needs \"base_sha256\" (the plan bytes it read) before "
341 "it can be applied");
342 }
343 std::string source;
345 PlanAgentFileResult result;
347 if (!update.base_sha256.empty() &&
348 update.base_sha256 != result.before_sha256) {
349 return absl::FailedPreconditionError(absl::StrFormat(
350 "Plan changed since the agent read it (base %s, current %s); reread "
351 "the plan and rebuild the update. Nothing was written.",
352 update.base_sha256, result.before_sha256));
353 }
354 ASSIGN_OR_RETURN(result.diff, ApplyPlanAgentUpdate(&plan, update));
355 if (!result.diff.HasChanges()) {
356 result.after_sha256 = result.before_sha256;
357 return result;
358 }
359 // Replace only the agent section in the file's own JSON, so every other key,
360 // its order and empty values keep the bytes the person last saved.
361 PlanJson root = PlanJson::parse(source, nullptr, /*allow_exceptions=*/false);
362 const std::string key(kPlanAgentStatusKey);
363 root[key] = plan.extra.at(key);
364 const std::string text = root.dump(1) + "\n";
365 auto written = ProjectPlan::Parse(text);
366 if (!written.ok() || written->Serialize() != plan.Serialize()) {
367 return absl::InternalError(
368 "Agent update did not reproduce the validated plan; nothing was "
369 "written");
370 }
372 if (dry_run) {
373 return result;
374 }
375 RETURN_IF_ERROR(ProjectPlan::PublishSource(path, source, text));
376 result.written = true;
377 return result;
378}
379
381 auto it = plan.extra.find(std::string(kPlanAgentStatusKey));
382 return it != plan.extra.end() && it->is_object() ? &*it : nullptr;
383}
384
386 std::string_view key) {
387 const PlanJson* section = FindPlanAgentStatus(plan);
388 if (section == nullptr) {
389 return nullptr;
390 }
391 auto observations = section->find("observations");
392 if (observations == section->end() || !observations->is_object()) {
393 return nullptr;
394 }
395 auto it = observations->find(std::string(key));
396 return it != observations->end() && it->is_object() ? &*it : nullptr;
397}
398
399std::vector<std::string> OrphanedPlanAgentObservations(
400 const ProjectPlan& plan) {
401 std::vector<std::string> orphans;
402 const PlanJson* section = FindPlanAgentStatus(plan);
403 if (section == nullptr) {
404 return orphans;
405 }
406 auto observations = section->find("observations");
407 if (observations == section->end() || !observations->is_object()) {
408 return orphans;
409 }
410 for (auto it = observations->begin(); it != observations->end(); ++it) {
411 auto target = ParseObservationKey(it.key());
412 if (!target.ok() || !PlanHasTarget(plan, *target)) {
413 orphans.push_back(it.key());
414 }
415 }
416 return orphans;
417}
418
419void RenamePlanAgentObservation(ProjectPlan* plan, std::string_view before,
420 std::string_view after) {
421 auto section = plan->extra.find(std::string(kPlanAgentStatusKey));
422 if (section == plan->extra.end() || !section->is_object()) {
423 return;
424 }
425 auto observations = section->find("observations");
426 if (observations == section->end() || !observations->is_object()) {
427 return;
428 }
429 auto it = observations->find(std::string(before));
430 if (it == observations->end() || before == after) {
431 return;
432 }
433 PlanJson moved = std::move(*it);
434 observations->erase(std::string(before));
435 (*observations)[std::string(after)] = std::move(moved);
436}
437
439 std::string_view loaded_source,
440 std::string_view disk_source) {
441 if (draft == nullptr) {
442 return false;
443 }
444 auto loaded = ProjectPlan::Parse(loaded_source);
445 auto disk = ProjectPlan::Parse(disk_source);
446 if (!loaded.ok() || !disk.ok()) {
447 return false;
448 }
449 const std::string key(kPlanAgentStatusKey);
450 PlanJson disk_section;
451 const bool disk_has_section = disk->extra.contains(key);
452 if (disk_has_section) {
453 disk_section = disk->extra[key];
454 }
455 loaded->extra.erase(key);
456 disk->extra.erase(key);
457 if (loaded->Serialize() != disk->Serialize()) {
458 return false;
459 }
460 if (disk_has_section) {
461 draft->extra[key] = std::move(disk_section);
462 } else {
463 draft->extra.erase(key);
464 }
465 return true;
466}
467
468} // namespace yaze::core
std::string Serialize() const
static absl::StatusOr< ProjectPlan > Parse(std::string_view text)
std::vector< PlanMilestone > milestones
static absl::Status PublishSource(const std::string &path, const std::string &expected_source, const std::string &text)
static absl::StatusOr< ProjectPlan > LoadFromFile(const std::string &path, std::string *source=nullptr)
std::vector< PlanRouteItem > route_items
std::vector< PlanRouteStep > route_steps
std::vector< PlanDecision > decisions
#define ASSIGN_OR_RETURN(type_variable_name, expression)
Definition macro.h:62
bool PlanHasTarget(const ProjectPlan &plan, const PlanTarget &target)
absl::Status ReadOptionalString(const PlanJson &root, const char *key, std::string *out)
absl::Status ValidateObservation(const std::string &key, const PlanJson &obs)
bool HasId(const std::vector< Entry > &entries, const std::string &id)
absl::StatusOr< const PlanJson * > ExistingSection(const ProjectPlan &plan)
absl::StatusOr< PlanTarget > ParseObservationKey(std::string_view key)
void RenamePlanAgentObservation(ProjectPlan *plan, std::string_view before, std::string_view after)
const PlanJson * FindPlanAgentStatus(const ProjectPlan &plan)
const PlanJson * FindPlanAgentObservation(const ProjectPlan &plan, std::string_view key)
constexpr std::string_view kPlanAgentUpdateSchema
std::string ComputeSourceArtifactSha256(std::string_view content)
absl::StatusOr< PlanAgentDiff > ApplyPlanAgentUpdate(ProjectPlan *plan, const PlanAgentUpdate &update)
absl::StatusOr< PlanAgentDiff > DiffPlanAgentUpdate(const ProjectPlan &plan, const PlanAgentUpdate &update)
constexpr std::string_view kPlanAgentStatusSchema
bool AdoptPlanAgentOnlyChanges(ProjectPlan *draft, std::string_view loaded_source, std::string_view disk_source)
const char * PlanAgentChangeKindName(PlanAgentChangeKind kind)
nlohmann::ordered_json PlanJson
absl::StatusOr< PlanAgentFileResult > ApplyPlanAgentUpdateToFile(const std::string &path, const PlanAgentUpdate &update, bool dry_run)
std::vector< std::string > OrphanedPlanAgentObservations(const ProjectPlan &plan)
constexpr std::string_view kPlanAgentStatusKey
#define RETURN_IF_ERROR(expr)
Definition snes.cc:22
std::vector< PlanAgentChange > changes
std::vector< std::string > remove
static absl::StatusOr< PlanAgentUpdate > Parse(std::string_view text)
static PlanTarget Parse(std::string_view text)