mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
Persist user JSON acceptance contracts and block unverified completion
This commit is contained in:
parent
2c73da7e2a
commit
868e2dbfb1
@ -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<GoalJsonAcceptanceService.View> get(@PathVariable Long goalId, Authentication auth) {
|
||||||
|
return R.ok(acceptance.get(goalId, username(auth)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/requirements/{criterionKey}")
|
||||||
|
public R<GoalJsonAcceptanceService.Requirement> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -56,6 +56,9 @@ public class GoalEntity {
|
|||||||
/** Advances on evaluation-definition edits, independently of optimistic-lock/usage version. */
|
/** Advances on evaluation-definition edits, independently of optimistic-lock/usage version. */
|
||||||
private long evaluationRevision;
|
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. */
|
/** LLM-readable exit criteria; evaluator scores against this. Nullable. */
|
||||||
@TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS)
|
@TableField(value = "exit_criteria", updateStrategy = FieldStrategy.ALWAYS)
|
||||||
private String exitCriteria;
|
private String exitCriteria;
|
||||||
|
|||||||
@ -30,6 +30,8 @@ public class GoalResponse {
|
|||||||
|
|
||||||
private GoalStatus status;
|
private GoalStatus status;
|
||||||
|
|
||||||
|
private boolean jsonAcceptanceRequired;
|
||||||
|
|
||||||
/** Opts into durable continuation; zero budgets mean unlimited only in this mode. */
|
/** Opts into durable continuation; zero budgets mean unlimited only in this mode. */
|
||||||
private Boolean persistentExecution;
|
private Boolean persistentExecution;
|
||||||
|
|
||||||
|
|||||||
@ -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<String> requiredFields) { }
|
||||||
|
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) { }
|
||||||
|
|
||||||
|
@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<String> fields = JsonArtifactRecipe.validate(request.requiredFields());
|
||||||
|
List<Requirement> 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<String> 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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<String> fields) {
|
||||||
|
try { return json.writeValueAsString(fields); }
|
||||||
|
catch (Exception e) { throw new IllegalStateException("Cannot encode JSON requirements", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<String> decode(String fields) {
|
||||||
|
try { return JsonArtifactRecipe.validate(json.readValue(fields, new TypeReference<List<String>>() { })); }
|
||||||
|
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); }
|
||||||
|
}
|
||||||
@ -400,6 +400,12 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
}
|
}
|
||||||
return null; // idempotent
|
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()) {
|
if (evaluated && result.evaluationRevision() != fresh.getEvaluationRevision()) {
|
||||||
throw new MateClawException("err.goal.completion_not_verified", 409,
|
throw new MateClawException("err.goal.completion_not_verified", 409,
|
||||||
"Automatic completion requires the current evaluation definition revision");
|
"Automatic completion requires the current evaluation definition revision");
|
||||||
@ -737,6 +743,7 @@ public class GoalServiceImpl implements GoalService {
|
|||||||
r.setExitCriteria(e.getExitCriteria());
|
r.setExitCriteria(e.getExitCriteria());
|
||||||
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
|
r.setSuccessCheckPrompt(e.getSuccessCheckPrompt());
|
||||||
r.setStatus(e.getStatus());
|
r.setStatus(e.getStatus());
|
||||||
|
r.setJsonAcceptanceRequired(e.isJsonAcceptanceRequired());
|
||||||
r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution()));
|
r.setPersistentExecution(Boolean.TRUE.equals(e.getPersistentExecution()));
|
||||||
r.setTurnBudget(e.getTurnBudget());
|
r.setTurnBudget(e.getTurnBudget());
|
||||||
r.setTurnsUsed(e.getTurnsUsed());
|
r.setTurnsUsed(e.getTurnsUsed());
|
||||||
|
|||||||
@ -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)
|
||||||
|
);
|
||||||
@ -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)
|
||||||
|
);
|
||||||
@ -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)
|
||||||
|
);
|
||||||
@ -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<Boolean> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user