From 868e2dbfb120a88b3db7725cff382a1a283c6ef7 Mon Sep 17 00:00:00 2001 From: mateaix <7333791@qq.com> Date: Mon, 14 Sep 2026 20:57:09 +0800 Subject: [PATCH] Persist user JSON acceptance contracts and block unverified completion --- .../GoalJsonAcceptanceController.java | 30 ++++ .../java/vip/mate/goal/model/GoalEntity.java | 3 + .../vip/mate/goal/model/GoalResponse.java | 2 + .../service/GoalJsonAcceptanceService.java | 121 ++++++++++++++ .../mate/goal/service/GoalServiceImpl.java | 7 + .../h2/V194__goal_json_requirements.sql | 14 ++ .../kingbase/V194__goal_json_requirements.sql | 14 ++ .../mysql/V194__goal_json_requirements.sql | 14 ++ .../GoalJsonAcceptanceIntegrationTest.java | 156 ++++++++++++++++++ 9 files changed, 361 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java create mode 100644 mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V194__goal_json_requirements.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/kingbase/V194__goal_json_requirements.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V194__goal_json_requirements.sql create mode 100644 mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java 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 new file mode 100644 index 00000000..04615e2d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/controller/GoalJsonAcceptanceController.java @@ -0,0 +1,30 @@ +package vip.mate.goal.controller; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; +import vip.mate.common.result.R; +import vip.mate.goal.service.GoalJsonAcceptanceService; + +/** Authenticated user surface, intentionally not a model tool. */ +@RestController +@RequestMapping("/api/v1/goals/{goalId}/json-acceptance") +@RequiredArgsConstructor +public class GoalJsonAcceptanceController { + private final GoalJsonAcceptanceService acceptance; + + @GetMapping + public R get(@PathVariable Long goalId, Authentication auth) { + return R.ok(acceptance.get(goalId, username(auth))); + } + + @PutMapping("/requirements/{criterionKey}") + public R configure(@PathVariable Long goalId, @PathVariable String criterionKey, + @RequestBody GoalJsonAcceptanceService.ConfigureRequest request, Authentication auth) { + return R.ok(acceptance.configure(goalId, criterionKey, request, 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/model/GoalEntity.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java index 7428428b..9468f3ff 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalEntity.java @@ -56,6 +56,9 @@ public class GoalEntity { /** Advances on evaluation-definition edits, independently of optimistic-lock/usage version. */ private long evaluationRevision; + /** User-selected managed JSON acceptance; never falls back to semantic completion. */ + private boolean jsonAcceptanceRequired; + /** LLM-readable exit criteria; evaluator scores against this. Nullable. */ @TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS) private String exitCriteria; diff --git a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java index d45f4ecf..30ce2c55 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/model/GoalResponse.java @@ -30,6 +30,8 @@ public class GoalResponse { private GoalStatus status; + private boolean jsonAcceptanceRequired; + /** Opts into durable continuation; zero budgets mean unlimited only in this mode. */ private Boolean persistentExecution; 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 new file mode 100644 index 00000000..0273fe2d --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java @@ -0,0 +1,121 @@ +package vip.mate.goal.service; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +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.util.List; +import java.util.Objects; + +/** User-managed requirements. Agent tools must not expose this configuration surface. */ +@Service +public class GoalJsonAcceptanceService { + private final JdbcTemplate jdbc; + private final ObjectMapper json; + + public GoalJsonAcceptanceService(JdbcTemplate jdbc, ObjectMapper json) { + this.jdbc = jdbc; + this.json = json; + } + + public record ConfigureRequest(Long expectedRevision, String artifactSlot, List requiredFields) { } + 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) { } + + @Transactional + public View get(Long goalId, String username) { + GoalScope goal = authorizedGoal(goalId, username, true); + return new View(goal.required(), requirements(goalId)); + } + + @Transactional + public Requirement configure(Long goalId, String criterionKey, ConfigureRequest request, String username) { + GoalScope goal = authorizedGoal(goalId, username, true); + if (!List.of("active", "paused").contains(goal.status())) { + throw failure(409, "JSON requirements can only change on an active or paused goal"); + } + if (request == null || request.expectedRevision() == null || request.expectedRevision() < 0) { + throw failure(400, "expectedRevision is required (0 for a new requirement)"); + } + validateKey(criterionKey); + validateKey(request.artifactSlot()); + List fields = JsonArtifactRecipe.validate(request.requiredFields()); + List current = requirements(goalId); + Requirement previous = current.stream().filter(r -> r.criterionKey().equals(criterionKey)).findFirst().orElse(null); + long revision = previous == null ? 0 : previous.revision(); + if (request.expectedRevision() != revision) throw failure(409, "JSON requirement revision changed; reload before editing"); + if (previous != null && previous.artifactSlot().equals(request.artifactSlot()) && previous.requiredFields().equals(fields)) { + return previous; + } + if (previous == null && current.size() >= 8) throw failure(400, "At most 8 JSON requirements per goal"); + long next = Math.addExact(revision, 1); + String encoded = encode(fields); + if (previous == null) { + jdbc.update(""" + INSERT INTO mate_goal_json_requirement + (goal_id,criterion_key,artifact_slot,revision,required_fields,created_by,updated_by,created_at,updated_at) + VALUES (?,?,?,?,?,?,?,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP) + """, goalId, criterionKey, request.artifactSlot(), next, encoded, username, username); + } else { + jdbc.update(""" + UPDATE mate_goal_json_requirement SET artifact_slot=?,revision=?,required_fields=?,updated_by=?,updated_at=CURRENT_TIMESTAMP + WHERE goal_id=? AND criterion_key=? + """, request.artifactSlot(), next, encoded, username, goalId, criterionKey); + } + // This version write races safely with existing GoalService CAS completion. + // No removal/disable endpoint: opting in never silently restores text-only completion. + jdbc.update("UPDATE mate_agent_goal SET json_acceptance_required=TRUE,version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goalId); + return new Requirement(criterionKey, request.artifactSlot(), next, fields, username); + } + + private 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); + if (roles.size() != 1) throw failure(403, "An enabled user account is required"); + GoalScope initial = goal(goalId, false); + var conversations = jdbc.query("SELECT username,workspace_id FROM mate_conversation WHERE conversation_id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""), + (row, i) -> Objects.equals(row.getString("username"), username) || "admin".equalsIgnoreCase(roles.getFirst()) + ? row.getLong("workspace_id") : null, initial.conversationId()); + if (conversations.size() != 1 || !Objects.equals(conversations.getFirst(), initial.workspaceId())) throw failure(403, "Goal owner permission required"); + GoalScope current = lock ? goal(goalId, true) : initial; + if (!Objects.equals(current.conversationId(), initial.conversationId()) || current.workspaceId() != initial.workspaceId()) { + throw failure(409, "Goal scope changed; reload before editing"); + } + return current; + } + + private GoalScope goal(Long id, boolean lock) { + var rows = jdbc.query("SELECT id,conversation_id,workspace_id,status,json_acceptance_required FROM mate_agent_goal WHERE id=? AND deleted=0" + (lock ? " FOR UPDATE" : ""), + (row, i) -> new GoalScope(row.getLong("id"), row.getString("conversation_id"), row.getLong("workspace_id"), row.getString("status"), row.getBoolean("json_acceptance_required")), id); + if (rows.size() != 1) throw failure(404, "Goal not found"); + return rows.getFirst(); + } + + private 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); + } + + private static void validateKey(String key) { + if (key == null || !key.matches("[a-z][a-z0-9_-]{0,63}")) throw failure(400, "Keys must be 1–64 lowercase letters, digits, underscores or hyphens, starting with a letter"); + } + + private String encode(List fields) { + try { return json.writeValueAsString(fields); } + catch (Exception e) { throw new IllegalStateException("Cannot encode JSON requirements", e); } + } + + private List decode(String fields) { + try { return JsonArtifactRecipe.validate(json.readValue(fields, new TypeReference>() { })); } + catch (Exception e) { throw new IllegalStateException("Stored JSON requirements are invalid", e); } + } + + private static MateClawException failure(int code, String message) { return new MateClawException(code, message); } +} diff --git a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java index 2b71f1d0..15d9f077 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalServiceImpl.java @@ -400,6 +400,12 @@ public class GoalServiceImpl implements GoalService { } return null; // idempotent } + if (fresh.isJsonAcceptanceRequired()) { + // User-selected requirements never fall back to model text or + // the legacy explicit-completion path while bindings are absent. + throw new MateClawException("err.goal.json_acceptance_required", 409, + "Managed JSON acceptance requires verified current artifact bindings"); + } if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) { throw new MateClawException("err.goal.completion_not_verified", 409, "Automatic completion requires the current evaluation definition revision"); @@ -737,6 +743,7 @@ public class GoalServiceImpl implements GoalService { r.setExitCriteria(e.getExitCriteria()); r.setSuccessCheckPrompt(e.getSuccessCheckPrompt()); r.setStatus(e.getStatus()); + r.setJsonAcceptanceRequired(e.isJsonAcceptanceRequired()); r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution())); r.setTurnBudget(e.getTurnBudget()); r.setTurnsUsed(e.getTurnsUsed()); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V194__goal_json_requirements.sql b/mateclaw-server/src/main/resources/db/migration/h2/V194__goal_json_requirements.sql new file mode 100644 index 00000000..5cd5587c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V194__goal_json_requirements.sql @@ -0,0 +1,14 @@ +-- Explicit opt-in is durable and cannot silently fall back to text completion. +ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_json_requirement ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + revision BIGINT NOT NULL, + required_fields TEXT NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V194__goal_json_requirements.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V194__goal_json_requirements.sql new file mode 100644 index 00000000..5cd5587c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V194__goal_json_requirements.sql @@ -0,0 +1,14 @@ +-- Explicit opt-in is durable and cannot silently fall back to text completion. +ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_json_requirement ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + revision BIGINT NOT NULL, + required_fields TEXT NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V194__goal_json_requirements.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V194__goal_json_requirements.sql new file mode 100644 index 00000000..5cd5587c --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V194__goal_json_requirements.sql @@ -0,0 +1,14 @@ +-- Explicit opt-in is durable and cannot silently fall back to text completion. +ALTER TABLE mate_agent_goal ADD COLUMN json_acceptance_required BOOLEAN NOT NULL DEFAULT FALSE; +CREATE TABLE mate_goal_json_requirement ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + artifact_slot VARCHAR(64) NOT NULL, + revision BIGINT NOT NULL, + required_fields TEXT NOT NULL, + created_by VARCHAR(64) NOT NULL, + updated_by VARCHAR(64) NOT NULL, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); diff --git a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java new file mode 100644 index 00000000..929fa2ec --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -0,0 +1,156 @@ +package vip.mate.goal; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import vip.mate.MateClawApplication; +import vip.mate.exception.MateClawException; +import vip.mate.goal.model.*; +import vip.mate.goal.service.GoalJsonAcceptanceService; +import vip.mate.goal.service.GoalService; +import vip.mate.memory.spi.MemoryManager; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest(classes = MateClawApplication.class, webEnvironment = SpringBootTest.WebEnvironment.NONE) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:json_acceptance_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", "spring.main.web-application-type=none", + "mateclaw.goal.enabled=false", "mateclaw.plugin.enabled=false", "mateclaw.skill.workspace.auto-init=false", + "mateclaw.skill.workspace.root=${java.io.tmpdir}/mateclaw-json-acceptance-skills-${random.uuid}" +}) +class GoalJsonAcceptanceIntegrationTest { + @MockBean private MemoryManager memory; + @Autowired private GoalService goals; + @Autowired private GoalJsonAcceptanceService acceptance; + @Autowired private JdbcTemplate jdbc; + @Autowired private PlatformTransactionManager transactions; + private String alice; + private String bob; + + @BeforeEach void users() { + alice = "alice-" + UUID.randomUUID(); + bob = "bob-" + UUID.randomUUID(); + for (String user : List.of(alice, bob)) jdbc.update(""" + INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) + VALUES (?,?,?,TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0) + """, IdWorker.getId(), user, "unused-test-password"); + } + + private GoalEntity goal(boolean persistent) { + String conversation = UUID.randomUUID().toString(); + jdbc.update(""" + INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted) + VALUES (?,?,?,1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0) + """, IdWorker.getId(), conversation, alice); + GoalCreateRequest req = new GoalCreateRequest(); + req.setConversationId(conversation); req.setWorkspaceId(1L); req.setAgentId(1L); + req.setTitle("A JSON report"); req.setDescription("Produce a managed JSON report"); req.setPersistentExecution(persistent); + return goals.create(req, alice); + } + + private GoalJsonAcceptanceService.ConfigureRequest request(long revision, String... fields) { + return new GoalJsonAcceptanceService.ConfigureRequest(revision, "report", List.of(fields)); + } + + @Test void ownerCanPersistAndReviseRequirementsWithoutAcceptingAStaleEdit() { + GoalEntity goal = goal(false); + assertFalse(acceptance.get(goal.getId(), alice).required()); + var first = acceptance.configure(goal.getId(), "report-fields", request(0, "summary"), alice); + assertEquals(1, first.revision()); + assertTrue(goals.toResponse(goals.getById(goal.getId())).isJsonAcceptanceRequired()); + assertEquals(List.of("summary"), acceptance.get(goal.getId(), alice).requirements().getFirst().requiredFields()); + var second = acceptance.configure(goal.getId(), "report-fields", request(1, "summary", "sources"), alice); + assertEquals(2, second.revision()); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "report-fields", request(1, "forged"), alice)); + assertEquals(List.of("summary", "sources"), acceptance.get(goal.getId(), alice).requirements().getFirst().requiredFields()); + assertEquals(2, acceptance.configure(goal.getId(), "report-fields", request(2, "summary", "sources"), alice).revision()); + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void selectedContractBlocksBothSyntheticExplicitAndAutomaticCompletion(boolean automatic) { + GoalEntity goal = goal(false); + goals.appendCriterion(goal.getId(), "write report", alice); + var forged = new GoalEvaluationResult(1, "report saved and checked", "completed", true, "fixture", 1, 0, + List.of(new GoalChecklistVerdict.CriterionVerdict("C1", true, "I verified the JSON")), null); + goals.recordEvaluation(goal.getId(), forged, 1, 1); + acceptance.configure(goal.getId(), "report-fields", request(0, "summary"), alice); + assertThrows(MateClawException.class, () -> { + if (automatic) goals.markEvaluatedCompleted(goal.getId(), forged); + else goals.markCompleted(goal.getId(), forged); + }); + assertEquals(GoalStatus.ACTIVE, goals.getById(goal.getId()).getStatus()); + assertTrue(goals.listEvents(goal.getId(), 30).stream().noneMatch(e -> "completed".equals(e.getEventType()))); + } + + @Test void unselectedLegacyCompletionRemainsCompatible() { + GoalEntity goal = goal(false); + assertEquals(GoalStatus.COMPLETED, goals.markCompleted(goal.getId(), null).getStatus()); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "late", request(0, "summary"), alice)); + } + + @Test void unknownDisabledAndOtherUsersCannotConfigureOrReadEvenForSystemConversations() { + GoalEntity goal = goal(false); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), null)); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), bob)); + assertThrows(MateClawException.class, () -> acceptance.get(goal.getId(), bob)); + jdbc.update("UPDATE mate_user SET enabled=FALSE WHERE username=?", alice); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), alice)); + jdbc.update("UPDATE mate_conversation SET username='system' WHERE conversation_id=?", goal.getConversationId()); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary"), "unknown-account")); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM mate_goal_json_requirement WHERE goal_id=?", Integer.class, goal.getId())); + assertFalse(goals.getById(goal.getId()).isJsonAcceptanceRequired()); + } + + @Test void rollbackDoesNotLeaveAContractOrEnableFlag() { + GoalEntity goal = goal(false); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + status.setRollbackOnly(); + }); + assertFalse(acceptance.get(goal.getId(), alice).required()); + assertTrue(acceptance.get(goal.getId(), alice).requirements().isEmpty()); + } + + @Test void competingUserEditsCannotBothCommitTheSameExpectedRevision() throws Exception { + GoalEntity goal = goal(false); + var start = new java.util.concurrent.CountDownLatch(1); + try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) { + java.util.concurrent.Callable edit = () -> { + start.await(); + try { acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); return true; } + catch (MateClawException conflict) { + assertTrue(conflict.getMessage().contains("revision changed")); + return false; + } + }; + var first = workers.submit(edit); var second = workers.submit(edit); start.countDown(); + assertNotEquals(first.get(10, java.util.concurrent.TimeUnit.SECONDS), second.get(10, java.util.concurrent.TimeUnit.SECONDS)); + } + assertEquals(1, acceptance.get(goal.getId(), alice).requirements().size()); + assertEquals(1, acceptance.get(goal.getId(), alice).requirements().getFirst().revision()); + } + + @Test void malformedAndUnboundedContractsAreRejectedWithoutEnablingTheGoal() { + GoalEntity goal = goal(false); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "../r", request(0, "summary"), alice)); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "r", request(0, "summary", "summary"), alice)); + assertFalse(goals.getById(goal.getId()).isJsonAcceptanceRequired()); + for (int i=0; i<8; i++) acceptance.configure(goal.getId(), "r"+i, request(0, "summary"), alice); + assertThrows(MateClawException.class, () -> acceptance.configure(goal.getId(), "overflow", request(0, "summary"), alice)); + assertEquals(8, acceptance.get(goal.getId(), alice).requirements().size()); + } +}