mateclaw/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java

145 lines
9.1 KiB
Java

package vip.mate.goal.service;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import vip.mate.agent.context.ChatOrigin;
import vip.mate.exception.MateClawException;
import vip.mate.execution.evidence.service.JsonArtifactRecipe;
import java.nio.charset.StandardCharsets;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
/** Only this service creates bindings, by checking managed bytes under the same goal lock. */
@Service
public class GoalJsonBindingService {
private final JdbcTemplate jdbc;
private final GoalJsonAcceptanceService acceptance;
private final ManagedGoalJsonService artifacts;
public GoalJsonBindingService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance, ManagedGoalJsonService artifacts) {
this.jdbc = jdbc; this.acceptance = acceptance; this.artifacts = artifacts;
}
public record CheckRequest(Long expectedRequirementRevision, String artifactId, Long expectedGeneration) { }
public record Check(String criterionKey, long requirementRevision, String artifactId, long generation,
String status, List<String> missingFields, String recipeId, int recipeRevision,
Instant checkedAt, Instant expiresAt, boolean acceptanceEligible) { }
public record State(String criterionKey, long requirementRevision, String artifactId, Long generation,
String status, boolean acceptanceEligible) { }
record Stored(String artifactId, long generation, String body, String sha256, int byteLength, Instant expiresAt) { }
record Binding(long requirementRevision, long evaluationRevision, String artifactId, long generation,
String sha256, String recipeId, int recipeRevision, String status, Instant expiresAt) { }
@Transactional
public Check check(Long goalId, String key, CheckRequest request, String username) {
var goal = acceptance.authorizedGoal(goalId, username, true);
return checkLocked(goal, key, request);
}
@Transactional
public Check checkForRuntime(ChatOrigin origin, String key, CheckRequest request) {
var runtime = artifacts.runtimeGoal(origin);
var result = checkLocked(runtime.goal(), key, request);
ManagedGoalJsonService.verifyLease(runtime);
return result;
}
@Transactional
public List<State> state(Long goalId, String username) {
acceptance.authorizedGoal(goalId, username, true);
return statesLocked(goalId);
}
@Transactional
public List<State> stateForRuntime(ChatOrigin origin) {
return statesLocked(artifacts.runtimeGoal(origin).goal().id());
}
private Check checkLocked(GoalJsonAcceptanceService.GoalScope goal, String key, CheckRequest request) {
if (!goal.required() || !List.of("active", "paused").contains(goal.status())) throw failure("Goal does not accept JSON checks");
var requirement = acceptance.requirements(goal.id()).stream().filter(r -> r.criterionKey().equals(key)).findFirst()
.orElseThrow(() -> failure("Current JSON requirement not found"));
if (request == null || request.expectedRequirementRevision() == null || request.expectedGeneration() == null || request.artifactId() == null) {
throw new MateClawException(400, "Expected requirement revision, artifact ID and generation are required");
}
var current = current(goal.id(), requirement.artifactSlot());
if (request.expectedRequirementRevision() != requirement.revision() || current == null
|| !current.artifactId().equals(request.artifactId()) || request.expectedGeneration() != current.generation()) {
throw failure("Requirement or artifact changed; reload before checking");
}
Instant checkedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS);
if (!current.expiresAt().isAfter(Instant.now())) throw failure("Current JSON version has expired");
if (!intact(current)) throw failure("Managed JSON integrity check failed");
var result = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), requirement.requiredFields());
long evaluationRevision = evaluationRevision(goal.id());
// Goal serialization makes replacement safe across all supported database dialects.
jdbc.update("DELETE FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=?", goal.id(), key);
jdbc.update("""
INSERT INTO mate_goal_json_binding
(goal_id,criterion_key,requirement_revision,evaluation_revision,artifact_id,generation,sha256,
recipe_id,recipe_revision,check_status,checked_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
""", goal.id(), key, requirement.revision(), evaluationRevision, current.artifactId(), current.generation(), current.sha256(),
result.recipeId(), result.recipeRevision(), result.status(), Timestamp.from(checkedAt), Timestamp.from(current.expiresAt()));
jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id());
return new Check(key, requirement.revision(), current.artifactId(), current.generation(), result.status(), result.missingFields(),
result.recipeId(), result.recipeRevision(), checkedAt, current.expiresAt(), "MATCH".equals(result.status()));
}
List<State> statesLocked(Long goalId) {
long revision = evaluationRevision(goalId);
return acceptance.requirements(goalId).stream().map(r -> {
Stored current = current(goalId, r.artifactSlot());
Binding binding = binding(goalId, r.criterionKey());
String status;
if (current == null) status = "NO_ARTIFACT";
else if (!current.expiresAt().isAfter(Instant.now())) status = "EXPIRED";
else if (!intact(current)) status = "CORRUPT";
else if (binding == null) status = "UNBOUND";
else if (binding.requirementRevision() != r.revision()) status = "REQUIREMENT_CHANGED";
else if (binding.evaluationRevision() != revision) status = "GOAL_CHANGED";
else if (!Objects.equals(binding.artifactId(), current.artifactId()) || binding.generation() != current.generation()
|| !Objects.equals(binding.sha256(), current.sha256())) status = "SUPERSEDED";
else if (!"json-required-fields".equals(binding.recipeId()) || binding.recipeRevision() != 1) status = "RECIPE_CHANGED";
else if (!binding.expiresAt().equals(current.expiresAt()) || !binding.expiresAt().isAfter(Instant.now())) status = "EXPIRED";
else if (!"MATCH".equals(binding.status())) status = binding.status();
else status = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), r.requiredFields()).status();
return new State(r.criterionKey(), r.revision(), current == null ? null : current.artifactId(),
current == null ? null : current.generation(), status, "MATCH".equals(status));
}).toList();
}
private long evaluationRevision(Long goalId) {
Long revision = jdbc.queryForObject("SELECT evaluation_revision FROM mate_agent_goal WHERE id=? AND deleted=0 FOR UPDATE", Long.class, goalId);
if (revision == null) throw failure("Goal definition unavailable");
return revision;
}
private Stored current(Long goalId, String slot) {
var rows = jdbc.query("""
SELECT a.* FROM mate_goal_json_slot s JOIN mate_goal_json_artifact a
ON a.artifact_id=s.artifact_id AND a.goal_id=s.goal_id AND a.artifact_slot=s.artifact_slot AND a.generation=s.generation
WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE
""", (r, i) -> new Stored(r.getString("artifact_id"), r.getLong("generation"), r.getString("json_body"),
r.getString("sha256"), r.getInt("byte_length"), r.getTimestamp("expires_at").toInstant()), goalId, slot);
return rows.size() == 1 ? rows.getFirst() : null;
}
private Binding binding(Long goalId, String key) {
var rows = jdbc.query("SELECT * FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=? FOR UPDATE",
(r, i) -> new Binding(r.getLong("requirement_revision"), r.getLong("evaluation_revision"), r.getString("artifact_id"),
r.getLong("generation"), r.getString("sha256"), r.getString("recipe_id"), r.getInt("recipe_revision"),
r.getString("check_status"), r.getTimestamp("expires_at").toInstant()), goalId, key);
return rows.size() == 1 ? rows.getFirst() : null;
}
private static boolean intact(Stored current) {
byte[] bytes = current.body().getBytes(StandardCharsets.UTF_8);
return bytes.length <= 1_048_576 && bytes.length == current.byteLength() && ManagedGoalJsonService.digest(bytes).equals(current.sha256());
}
private static MateClawException failure(String message) { return new MateClawException(409, message); }
}