From bc6882e629f07ce2116f910a5d12a679d1803038 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Mon, 14 Sep 2026 21:21:05 +0800 Subject: [PATCH] feat: add managed goal JSON version storage and publication API --- .../evidence/service/JsonArtifactRecipe.java | 15 ++- .../GoalJsonAcceptanceController.java | 18 +++ .../service/GoalJsonAcceptanceService.java | 6 +- .../goal/service/ManagedGoalJsonService.java | 119 ++++++++++++++++++ .../h2/V195__goal_json_artifacts.sql | 22 ++++ .../kingbase/V195__goal_json_artifacts.sql | 22 ++++ .../mysql/V195__goal_json_artifacts.sql | 22 ++++ .../docs/en/managed-json-acceptance.md | 19 +++ .../docs/zh/managed-json-acceptance.md | 19 +++ .../GoalJsonAcceptanceIntegrationTest.java | 98 +++++++++++++++ 10 files changed, 355 insertions(+), 5 deletions(-) create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V195__goal_json_artifacts.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V195__goal_json_artifacts.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V195__goal_json_artifacts.sql create mode 100644 mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md create mode 100644 mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md diff --git a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java index b89415f3..38ff3ec3 100644 --- a/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java +++ b/mateclaw-server/src/main/java/vip/mate/execution/evidence/service/JsonArtifactRecipe.java @@ -39,12 +39,23 @@ public final class JsonArtifactRecipe { Instant.now(), false); } + /** Shared strict parser for managed publication and diagnostic checks. */ + public static com.fasterxml.jackson.databind.JsonNode parseObject(byte[] bytes) { + if (bytes == null || bytes.length > 1_048_576) throw new MateClawException(400, "JSON must be at most 1 MiB"); + try { + var document = JSON.readTree(bytes); + if (document == null || !document.isObject()) throw new IllegalArgumentException(); + return document; + } catch (Exception invalid) { + throw new MateClawException(400, "A strict JSON object is required"); + } + } + public static Result check(byte[] bytes, List requestedFields) { List fields = validate(requestedFields); if (bytes == null || bytes.length > 1_048_576) return outcome("UNKNOWN", fields, List.of()); try { - var document = JSON.readTree(bytes); - if (document == null || !document.isObject()) return outcome("INVALID_JSON", fields, List.of()); + var document = parseObject(bytes); List missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList(); return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing); } catch (Exception invalid) { diff --git a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java index 04615e2d..8775b986 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java @@ -5,6 +5,7 @@ import org.springframework.security.core.Authentication; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.goal.service.GoalJsonAcceptanceService; +import vip.mate.goal.service.ManagedGoalJsonService; /** Authenticated user surface, intentionally not a model tool. */ @RestController @@ -12,6 +13,7 @@ import vip.mate.goal.service.GoalJsonAcceptanceService; @RequiredArgsConstructor public class GoalJsonAcceptanceController { private final GoalJsonAcceptanceService acceptance; + private final ManagedGoalJsonService artifacts; @GetMapping public R get(@PathVariable Long goalId, Authentication auth) { @@ -24,6 +26,22 @@ public class GoalJsonAcceptanceController { return R.ok(acceptance.configure(goalId, criterionKey, request, username(auth))); } + @GetMapping("/artifacts") + public R> artifacts(@PathVariable Long goalId, Authentication auth) { + return R.ok(artifacts.list(goalId, username(auth))); + } + + @PostMapping("/artifacts/{slot}") + public R publish(@PathVariable Long goalId, @PathVariable String slot, + @RequestBody ManagedGoalJsonService.PublishRequest request, Authentication auth) { + return R.ok(artifacts.publish(goalId, slot, request, username(auth))); + } + + @GetMapping("/artifacts/versions/{artifactId}") + public R version(@PathVariable Long goalId, @PathVariable String artifactId, Authentication auth) { + return R.ok(artifacts.read(goalId, artifactId, username(auth))); + } + private static String username(Authentication auth) { return auth != null && auth.isAuthenticated() ? auth.getName() : null; } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java index 0273fe2d..6ba8bee1 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java @@ -26,7 +26,7 @@ public class GoalJsonAcceptanceService { public record Requirement(String criterionKey, String artifactSlot, long revision, List requiredFields, String configuredBy) { } public record View(boolean required, List requirements) { } - private record GoalScope(long id, String conversationId, long workspaceId, String status, boolean required) { } + record GoalScope(long id, String conversationId, long workspaceId, String status, boolean required) { } @Transactional public View get(Long goalId, String username) { @@ -74,7 +74,7 @@ public class GoalJsonAcceptanceService { return new Requirement(criterionKey, request.artifactSlot(), next, fields, username); } - private GoalScope authorizedGoal(Long goalId, String username, boolean lock) { + GoalScope authorizedGoal(Long goalId, String username, boolean lock) { if (username == null || username.isBlank() || "anonymous".equals(username)) throw failure(401, "Authentication required"); // Deliberately stricter than legacy system-conversation ownership fallback. List roles = jdbc.queryForList("SELECT role FROM mate_user WHERE username=? AND enabled=TRUE AND deleted=0" + (lock ? " FOR UPDATE" : ""), String.class, username); @@ -98,7 +98,7 @@ public class GoalJsonAcceptanceService { return rows.getFirst(); } - private List requirements(Long goalId) { + List requirements(Long goalId) { return jdbc.query("SELECT criterion_key,artifact_slot,revision,required_fields,updated_by FROM mate_goal_json_requirement WHERE goal_id=? ORDER BY criterion_key", (row, i) -> new Requirement(row.getString("criterion_key"), row.getString("artifact_slot"), row.getLong("revision"), decode(row.getString("required_fields")), row.getString("updated_by")), goalId); } diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java new file mode 100644 index 00000000..b2521098 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java @@ -0,0 +1,119 @@ +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.exception.MateClawException; +import vip.mate.execution.evidence.service.JsonArtifactRecipe; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.sql.Timestamp; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +/** Managed JSON is independent of mutable workspace files and cache metadata. + * Database credentials and the service host are trusted; hashes do not isolate a hostile host. */ +@Service +public class ManagedGoalJsonService { + private final JdbcTemplate jdbc; + private final GoalJsonAcceptanceService acceptance; + public ManagedGoalJsonService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance) { + this.jdbc = jdbc; + this.acceptance = acceptance; + } + + public record PublishRequest(Long expectedGeneration, String jsonContent) { } + public record Artifact(String artifactId, String artifactSlot, long generation, String sha256, + int byteLength, String producerKind, Instant createdAt, Instant expiresAt) { } + public record Content(Artifact artifact, String jsonContent) { } + public record Slot(String artifactSlot, long generation, Artifact current) { } + + @Transactional + public List list(Long goalId, String username) { + acceptance.authorizedGoal(goalId, username, true); + return slots(goalId); + } + + @Transactional + public Artifact publish(Long goalId, String slot, PublishRequest request, String username) { + var goal = acceptance.authorizedGoal(goalId, username, true); + return publishLocked(goal, slot, request, "user", username); + } + + @Transactional + public Content read(Long goalId, String artifactId, String username) { + acceptance.authorizedGoal(goalId, username, true); + var rows = jdbc.query("SELECT * FROM mate_goal_json_artifact WHERE goal_id=? AND artifact_id=?", + (r, i) -> new Content(artifact(r), r.getString("json_body")), goalId, artifactId); + if (rows.size() != 1) throw failure(404, "Managed JSON version not found"); + return rows.getFirst(); + } + + // Caller must hold the authorized goal lock in the same transaction. + Artifact publishLocked(GoalJsonAcceptanceService.GoalScope goal, String slot, PublishRequest request, + String producerKind, String producerId) { + if (!List.of("active", "paused").contains(goal.status())) throw failure(409, "Goal is no longer writable"); + if (!goal.required() || acceptance.requirements(goal.id()).stream().noneMatch(r -> r.artifactSlot().equals(slot))) { + throw failure(409, "Only a currently required JSON slot can be published"); + } + if (request == null || request.expectedGeneration() == null || request.expectedGeneration() < 0) { + throw failure(400, "expectedGeneration is required (0 for an empty slot)"); + } + String content = request.jsonContent(); + if (content == null || content.length() > 1_048_576) throw failure(400, "JSON must be at most 1 MiB"); + byte[] bytes = content.getBytes(StandardCharsets.UTF_8); + if (!content.equals(new String(bytes, StandardCharsets.UTF_8))) throw failure(400, "JSON must be valid UTF-8"); + JsonArtifactRecipe.parseObject(bytes); + var generations = jdbc.queryForList("SELECT generation FROM mate_goal_json_slot WHERE goal_id=? AND artifact_slot=?", Long.class, goal.id(), slot); + long generation = generations.isEmpty() ? 0 : generations.getFirst(); + if (request.expectedGeneration() != generation) throw failure(409, "JSON slot generation changed; reload before publishing"); + Integer count = jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.id()); + if (count == null || count >= 32) throw failure(409, "Managed JSON limit reached (32 versions per goal)"); + long next = Math.addExact(generation, 1); + // Whole seconds also round-trip through MySQL TIMESTAMP without fractional precision. + Instant created = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + Artifact artifact = new Artifact(UUID.randomUUID().toString(), slot, next, digest(bytes), bytes.length, + producerKind, created, created.plusSeconds(86_400)); + jdbc.update(""" + INSERT INTO mate_goal_json_artifact + (artifact_id,goal_id,artifact_slot,generation,json_body,sha256,byte_length,producer_kind,producer_id,created_at,expires_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?) + """, artifact.artifactId(), goal.id(), slot, next, content, artifact.sha256(), bytes.length, + producerKind, producerId, Timestamp.from(created), Timestamp.from(artifact.expiresAt())); + if (generation == 0) jdbc.update("INSERT INTO mate_goal_json_slot(goal_id,artifact_slot,generation,artifact_id) VALUES (?,?,?,?)", + goal.id(), slot, next, artifact.artifactId()); + else jdbc.update("UPDATE mate_goal_json_slot SET generation=?,artifact_id=? WHERE goal_id=? AND artifact_slot=?", + next, artifact.artifactId(), goal.id(), slot); + jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id()); + return artifact; + } + + List slots(Long goalId) { + return acceptance.requirements(goalId).stream().map(GoalJsonAcceptanceService.Requirement::artifactSlot).distinct().sorted() + .map(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=? + """, (r, i) -> artifact(r), goalId, slot); + if (rows.isEmpty()) return new Slot(slot, 0, null); + var current = rows.getFirst(); + return new Slot(slot, current.generation(), current); + }).toList(); + } + + private static Artifact artifact(java.sql.ResultSet r) throws java.sql.SQLException { + return new Artifact(r.getString("artifact_id"), r.getString("artifact_slot"), r.getLong("generation"), + r.getString("sha256"), r.getInt("byte_length"), r.getString("producer_kind"), + r.getTimestamp("created_at").toInstant(), r.getTimestamp("expires_at").toInstant()); + } + + static String digest(byte[] bytes) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)); } + catch (java.security.NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private static MateClawException failure(int code, String message) { return new MateClawException(code, message); } +} diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V195__goal_json_artifacts.sql b/mateclaw-server/src/main/resources/db/migration/h2/V195__goal_json_artifacts.sql new file mode 100644 index 00000000..3aa774e5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V195__goal_json_artifacts.sql @@ -0,0 +1,22 @@ +-- Application-managed append-only content; slot pointers advance under the goal lock. +CREATE TABLE mate_goal_json_artifact ( + artifact_id VARCHAR(36) NOT NULL PRIMARY KEY, + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + json_body CLOB NOT NULL, + sha256 VARCHAR(64) NOT NULL, + byte_length INTEGER NOT NULL, + producer_kind VARCHAR(32) NOT NULL, + producer_id VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + UNIQUE (goal_id, artifact_slot, generation) +); +CREATE TABLE mate_goal_json_slot ( + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + PRIMARY KEY (goal_id, artifact_slot) +); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V195__goal_json_artifacts.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V195__goal_json_artifacts.sql new file mode 100644 index 00000000..c7d4ebbe --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V195__goal_json_artifacts.sql @@ -0,0 +1,22 @@ +-- Application-managed append-only content; slot pointers advance under the goal lock. +CREATE TABLE mate_goal_json_artifact ( + artifact_id VARCHAR(36) NOT NULL PRIMARY KEY, + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + json_body TEXT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + byte_length INTEGER NOT NULL, + producer_kind VARCHAR(32) NOT NULL, + producer_id VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + UNIQUE (goal_id, artifact_slot, generation) +); +CREATE TABLE mate_goal_json_slot ( + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + PRIMARY KEY (goal_id, artifact_slot) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V195__goal_json_artifacts.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V195__goal_json_artifacts.sql new file mode 100644 index 00000000..abd299be --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V195__goal_json_artifacts.sql @@ -0,0 +1,22 @@ +-- Application-managed append-only content; slot pointers advance under the goal lock. +CREATE TABLE mate_goal_json_artifact ( + artifact_id VARCHAR(36) NOT NULL PRIMARY KEY, + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + json_body MEDIUMTEXT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + byte_length INTEGER NOT NULL, + producer_kind VARCHAR(32) NOT NULL, + producer_id VARCHAR(128) NOT NULL, + created_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + UNIQUE (goal_id, artifact_slot, generation) +); +CREATE TABLE mate_goal_json_slot ( + goal_id BIGINT NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + generation BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + PRIMARY KEY (goal_id, artifact_slot) +); diff --git a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md new file mode 100644 index 00000000..f96f5eed --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -0,0 +1,19 @@ +# Managed JSON acceptance for Goals + +Expand JSON acceptance requirements in the Goal panel. The conversation owner or an administrator explicitly saves up to eight requirements, each mapping an artifact slot to 1–16 top-level fields. Fields must exist and be non-null; false, zero and empty strings are allowed. This is a presence check, not a quality judgment. Opt-in is durable: requirements can be revised using their current revision, but required mode cannot be disabled. Stale revisions produce a conflict. + +The current stage provides user configuration and independent managed version storage. Binding verification and the successful strong-completion path are still pending. Selected goals temporarily reject completion without falling back to textual claims. Unselected goals retain existing behavior. + +## Managed version API + +Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conversation-owner or administrator permission is required. Preserve IDs, revisions and generations as strings in clients. + +- `GET /`: read required mode and requirements. +- `PUT /requirements/{criterionKey}`: send `expectedRevision`, `artifactSlot` and `requiredFields`; use revision `0` for a new requirement. +- `GET /artifacts`: list required slots and current versions; an empty slot has generation `0`. +- `POST /artifacts/{slot}`: send `expectedGeneration` and `jsonContent` (a string containing the original JSON body) to append a version and atomically advance the slot. +- `GET /artifacts/versions/{artifactId}`: read metadata and exact content of a version belonging to this goal, including historical versions. Read access does not imply current acceptance eligibility. + +Publication requires an active or paused goal and a slot referenced by a current requirement. Content must be a strict JSON object: duplicate keys, trailing documents, nesting beyond 32 levels and UTF-8 content over 1 MiB are rejected. Each goal can retain at most 32 versions; the limit rejects new publication instead of overwriting history. Each version expires after 24 hours. Republishing identical bytes still creates a new version. Reload after a generation conflict rather than automatically overwriting another publication. + +Managed bodies live independently in the database. Ordinary workspace files, cache paths and hashes in text are not substitutes. No publication API edits historical bodies; bodies and pointers commit together. SHA-256 identifies content and supports integrity checks; it does not isolate an attacker with database credentials or host privileges. The database and service host are trusted foundations of this limited protocol. MySQL and Kingbase/PostgreSQL migrations have not yet been exercised against external database instances; H2 service integration tests do not establish that coverage. diff --git a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md new file mode 100644 index 00000000..6dd69100 --- /dev/null +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -0,0 +1,19 @@ +# Goal 受管 JSON 验收 + +在 Goal 面板展开“JSON 验收要求”,由对话所有者或管理员显式保存要求。每个 Goal 最多 8 条要求,每条绑定一个产物槽和 1–16 个顶层字段。字段检查表示字段存在且不为 null;false、0 和空字符串允许,不等于内容质量判断。保存后不可关闭强验收模式,可以带当前 revision 修改要求;旧修订会返回冲突。 + +当前阶段已提供用户配置与独立受管版本存储;绑定检查和强验收成功完成路径尚未接通。选中此模式的 Goal 暂时拒绝完成,不会回退到文字声明;未选中的 Goal 保持既有行为。 + +## 受管版本接口 + +接口前缀 `/api/v1/goals/{goalId}/json-acceptance`,需要启用账户及对话所有者或管理员权限。ID、revision 和 generation 在响应中使用字符串,客户端应原样保留。 + +- `GET /`:读取启用状态和要求。 +- `PUT /requirements/{criterionKey}`:提交 `expectedRevision`、`artifactSlot`、`requiredFields`。新要求的 revision 为 `0`。 +- `GET /artifacts`:列出当前要求使用的槽及当前版本。空槽 generation 为 `0`。 +- `POST /artifacts/{slot}`:提交 `expectedGeneration` 和 `jsonContent`(包含原始 JSON 正文的字符串),原子追加新版本并推进槽。 +- `GET /artifacts/versions/{artifactId}`:读取本 Goal 指定版本的元数据及原始正文,包括历史版本;读取历史版本不表示它仍可用于验收。 + +仅当前要求引用的槽可发布,Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取,不自动覆盖他人发布。 + +这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文;所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。当前未对 MySQL、Kingbase/PostgreSQL 实例执行迁移验收;H2 服务集成测试不等于外部数据库验证。 diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java index 929fa2ec..6b58f018 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -35,6 +35,7 @@ class GoalJsonAcceptanceIntegrationTest { @MockBean private MemoryManager memory; @Autowired private GoalService goals; @Autowired private GoalJsonAcceptanceService acceptance; + @Autowired private vip.mate.goal.service.ManagedGoalJsonService artifacts; @Autowired private JdbcTemplate jdbc; @Autowired private PlatformTransactionManager transactions; private String alice; @@ -153,4 +154,101 @@ class GoalJsonAcceptanceIntegrationTest { assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "overflow", request(0, "summary"), alice)); assertEquals(8, acceptance.get(goal.getId(), alice).requirements().size()); } + private vip.mate.goal.service.ManagedGoalJsonService.PublishRequest publication(long generation, String content) { + return new vip.mate.goal.service.ManagedGoalJsonService.PublishRequest(generation, content); + } + + @Test void managedVersionsPreserveExactBytesAndRejectStaleOverwriteAndForeignReads() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation()); + String original = "{ \"summary\": false, \"count\": 0 }"; + var first = artifacts.publish(goal.getId(), "report", publication(0, original), alice); + assertEquals(1, first.generation()); + assertEquals(original, artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent()); + try { + assertEquals(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256") + .digest(original.getBytes(java.nio.charset.StandardCharsets.UTF_8))), first.sha256()); + } catch (java.security.NoSuchAlgorithmException impossible) { throw new AssertionError(impossible); } + assertEquals(first, artifacts.read(goal.getId(), first.artifactId(), alice).artifact()); + assertEquals(86_400, java.time.Duration.between(first.createdAt(), first.expiresAt()).toSeconds()); + var second = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":\"next\"}"), alice); + assertNotEquals(first.artifactId(), second.artifactId()); + assertEquals(second.artifactId(), artifacts.list(goal.getId(), alice).getFirst().current().artifactId()); + assertEquals(original, artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent()); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice)); + assertThrows(MateClawException.class, () -> artifacts.read(goal.getId(), first.artifactId(), bob)); + assertThrows(MateClawException.class, () -> artifacts.read(goal(false).getId(), first.artifactId(), alice)); + } + + @Test void managedPublicationRejectsInvalidObjectsAndUnrequiredSlotsWithoutCreatingVersions() { + GoalEntity goal = goal(false); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice)); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + for (String invalid : List.of("[]", "null", "{\"a\":1,\"a\":2}", "{} {}", "[".repeat(33)+"]".repeat(33), "{\"a\":\""+"中".repeat(350000)+"\"}")) { + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, invalid), alice)); + } + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "other", publication(0, "{}"), alice)); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(0, "{}"), bob)); + assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId())); + } + + @Test void managedPublicationRollsBackBodyPointerAndGoalVersionTogether() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = goals.getById(goal.getId()).getVersion(); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice); + status.setRollbackOnly(); + }); + assertEquals(version, goals.getById(goal.getId()).getVersion()); + assertEquals(0, artifacts.list(goal.getId(), alice).getFirst().generation()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId())); + } + + @Test void competingPublishersHaveExactlyOneCurrentGeneration() throws Exception { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var start = new java.util.concurrent.CountDownLatch(1); + try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) { + java.util.concurrent.Callable publish = () -> { + start.await(); + try { artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice); return true; } + catch (MateClawException conflict) { + assertTrue(conflict.getMessage().contains("generation changed")); + return false; + } + }; + var first = workers.submit(publish); var second = workers.submit(publish); start.countDown(); + assertNotEquals(first.get(10, java.util.concurrent.TimeUnit.SECONDS), second.get(10, java.util.concurrent.TimeUnit.SECONDS)); + } + assertEquals(1, artifacts.list(goal.getId(), alice).getFirst().generation()); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_artifact WHERE goal_id=?", Integer.class, goal.getId())); + } + + @Test void quotaAndTerminalStateNeverReuseOrMutateOldVersions() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var first = artifacts.publish(goal.getId(), "report", publication(0, "{}"), alice); + for (int i=1; i<32; i++) artifacts.publish(goal.getId(), "report", publication(i, "{}"), alice); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(32, "{}"), alice)); + assertEquals(32, artifacts.list(goal.getId(), alice).getFirst().generation()); + assertEquals("{}", artifacts.read(goal.getId(), first.artifactId(), alice).jsonContent()); + jdbc.update("UPDATE mate_agent_goal SET status='abandoned' WHERE id=?", goal.getId()); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(32, "{}"), alice)); + } + + @Test void managedJsonAboveSmallTextCapacityRemainsExactAndDisabledOwnerLosesAccess() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + String content = "{\"summary\":\"" + "中".repeat(30_000) + "\"}"; + var stored = artifacts.publish(goal.getId(), "report", publication(0, content), alice); + assertTrue(stored.byteLength() > 65_535); + assertEquals(content, artifacts.read(goal.getId(), stored.artifactId(), alice).jsonContent()); + jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice); + assertThrows(MateClawException.class, () -> artifacts.read(goal.getId(), stored.artifactId(), alice)); + assertThrows(MateClawException.class, () -> artifacts.publish(goal.getId(), "report", publication(1, "{}"), alice)); + } + }