mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
feat: add managed goal JSON version storage and publication API
This commit is contained in:
parent
971aa536a8
commit
bc6882e629
@ -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<String> requestedFields) {
|
||||
List<String> 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<String> missing = fields.stream().filter(field -> !document.hasNonNull(field)).toList();
|
||||
return outcome(missing.isEmpty() ? "MATCH" : "MISSING_FIELDS", fields, missing);
|
||||
} catch (Exception invalid) {
|
||||
|
||||
@ -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<GoalJsonAcceptanceService.View> 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<java.util.List<ManagedGoalJsonService.Slot>> artifacts(@PathVariable Long goalId, Authentication auth) {
|
||||
return R.ok(artifacts.list(goalId, username(auth)));
|
||||
}
|
||||
|
||||
@PostMapping("/artifacts/{slot}")
|
||||
public R<ManagedGoalJsonService.Artifact> 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<ManagedGoalJsonService.Content> 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;
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ public class GoalJsonAcceptanceService {
|
||||
public record Requirement(String criterionKey, String artifactSlot, long revision,
|
||||
List<String> requiredFields, String configuredBy) { }
|
||||
public record View(boolean required, List<Requirement> 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<String> 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<Requirement> requirements(Long goalId) {
|
||||
List<Requirement> 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);
|
||||
}
|
||||
|
||||
@ -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<Slot> 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<Slot> 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); }
|
||||
}
|
||||
@ -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)
|
||||
);
|
||||
@ -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)
|
||||
);
|
||||
@ -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)
|
||||
);
|
||||
@ -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.
|
||||
@ -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 服务集成测试不等于外部数据库验证。
|
||||
@ -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<Boolean> 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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user