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 8775b986..ac1404e2 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 @@ -14,6 +14,7 @@ import vip.mate.goal.service.ManagedGoalJsonService; public class GoalJsonAcceptanceController { private final GoalJsonAcceptanceService acceptance; private final ManagedGoalJsonService artifacts; + private final vip.mate.goal.service.GoalJsonBindingService bindings; @GetMapping public R get(@PathVariable Long goalId, Authentication auth) { @@ -42,6 +43,17 @@ public class GoalJsonAcceptanceController { return R.ok(artifacts.read(goalId, artifactId, username(auth))); } + @GetMapping("/checks") + public R> checks(@PathVariable Long goalId, Authentication auth) { + return R.ok(bindings.state(goalId, username(auth))); + } + + @PostMapping("/checks/{criterionKey}") + public R check(@PathVariable Long goalId, @PathVariable String criterionKey, + @RequestBody vip.mate.goal.service.GoalJsonBindingService.CheckRequest request, Authentication auth) { + return R.ok(bindings.check(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/service/GoalJsonAcceptanceService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonAcceptanceService.java index e6dd2800..0247d822 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 @@ -99,7 +99,7 @@ public class GoalJsonAcceptanceService { } 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", + 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 FOR UPDATE", (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/GoalJsonBindingService.java b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java new file mode 100644 index 00000000..cf65c4e0 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/GoalJsonBindingService.java @@ -0,0 +1,144 @@ +package vip.mate.goal.service; + +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import vip.mate.agent.context.ChatOrigin; +import vip.mate.exception.MateClawException; +import vip.mate.execution.evidence.service.JsonArtifactRecipe; + +import java.nio.charset.StandardCharsets; +import java.sql.Timestamp; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Objects; + +/** Only this service creates bindings, by checking managed bytes under the same goal lock. */ +@Service +public class GoalJsonBindingService { + private final JdbcTemplate jdbc; + private final GoalJsonAcceptanceService acceptance; + private final ManagedGoalJsonService artifacts; + public GoalJsonBindingService(JdbcTemplate jdbc, GoalJsonAcceptanceService acceptance, ManagedGoalJsonService artifacts) { + this.jdbc = jdbc; this.acceptance = acceptance; this.artifacts = artifacts; + } + + public record CheckRequest(Long expectedRequirementRevision, String artifactId, Long expectedGeneration) { } + public record Check(String criterionKey, long requirementRevision, String artifactId, long generation, + String status, List missingFields, String recipeId, int recipeRevision, + Instant checkedAt, Instant expiresAt, boolean acceptanceEligible) { } + public record State(String criterionKey, long requirementRevision, String artifactId, Long generation, + String status, boolean acceptanceEligible) { } + record Stored(String artifactId, long generation, String body, String sha256, int byteLength, Instant expiresAt) { } + record Binding(long requirementRevision, long evaluationRevision, String artifactId, long generation, + String sha256, String recipeId, int recipeRevision, String status, Instant expiresAt) { } + + @Transactional + public Check check(Long goalId, String key, CheckRequest request, String username) { + var goal = acceptance.authorizedGoal(goalId, username, true); + return checkLocked(goal, key, request); + } + + @Transactional + public Check checkForRuntime(ChatOrigin origin, String key, CheckRequest request) { + var runtime = artifacts.runtimeGoal(origin); + var result = checkLocked(runtime.goal(), key, request); + ManagedGoalJsonService.verifyLease(runtime); + return result; + } + + @Transactional + public List state(Long goalId, String username) { + acceptance.authorizedGoal(goalId, username, true); + return statesLocked(goalId); + } + + @Transactional + public List stateForRuntime(ChatOrigin origin) { + return statesLocked(artifacts.runtimeGoal(origin).goal().id()); + } + + private Check checkLocked(GoalJsonAcceptanceService.GoalScope goal, String key, CheckRequest request) { + if (!goal.required() || !List.of("active", "paused").contains(goal.status())) throw failure("Goal does not accept JSON checks"); + var requirement = acceptance.requirements(goal.id()).stream().filter(r -> r.criterionKey().equals(key)).findFirst() + .orElseThrow(() -> failure("Current JSON requirement not found")); + if (request == null || request.expectedRequirementRevision() == null || request.expectedGeneration() == null || request.artifactId() == null) { + throw new MateClawException(400, "Expected requirement revision, artifact ID and generation are required"); + } + var current = current(goal.id(), requirement.artifactSlot()); + if (request.expectedRequirementRevision() != requirement.revision() || current == null + || !current.artifactId().equals(request.artifactId()) || request.expectedGeneration() != current.generation()) { + throw failure("Requirement or artifact changed; reload before checking"); + } + Instant checkedAt = Instant.now().truncatedTo(ChronoUnit.SECONDS); + if (!current.expiresAt().isAfter(Instant.now())) throw failure("Current JSON version has expired"); + if (!intact(current)) throw failure("Managed JSON integrity check failed"); + var result = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), requirement.requiredFields()); + long evaluationRevision = evaluationRevision(goal.id()); + // Goal serialization makes replacement safe across all supported database dialects. + jdbc.update("DELETE FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=?", goal.id(), key); + jdbc.update(""" + INSERT INTO mate_goal_json_binding + (goal_id,criterion_key,requirement_revision,evaluation_revision,artifact_id,generation,sha256, + recipe_id,recipe_revision,check_status,checked_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?) + """, goal.id(), key, requirement.revision(), evaluationRevision, current.artifactId(), current.generation(), current.sha256(), + result.recipeId(), result.recipeRevision(), result.status(), Timestamp.from(checkedAt), Timestamp.from(current.expiresAt())); + jdbc.update("UPDATE mate_agent_goal SET version=version+1,update_time=CURRENT_TIMESTAMP WHERE id=?", goal.id()); + return new Check(key, requirement.revision(), current.artifactId(), current.generation(), result.status(), result.missingFields(), + result.recipeId(), result.recipeRevision(), checkedAt, current.expiresAt(), "MATCH".equals(result.status())); + } + + List statesLocked(Long goalId) { + long revision = evaluationRevision(goalId); + return acceptance.requirements(goalId).stream().map(r -> { + Stored current = current(goalId, r.artifactSlot()); + Binding binding = binding(goalId, r.criterionKey()); + String status; + if (current == null) status = "NO_ARTIFACT"; + else if (!current.expiresAt().isAfter(Instant.now())) status = "EXPIRED"; + else if (!intact(current)) status = "CORRUPT"; + else if (binding == null) status = "UNBOUND"; + else if (binding.requirementRevision() != r.revision()) status = "REQUIREMENT_CHANGED"; + else if (binding.evaluationRevision() != revision) status = "GOAL_CHANGED"; + else if (!Objects.equals(binding.artifactId(), current.artifactId()) || binding.generation() != current.generation() + || !Objects.equals(binding.sha256(), current.sha256())) status = "SUPERSEDED"; + else if (!"json-required-fields".equals(binding.recipeId()) || binding.recipeRevision() != 1) status = "RECIPE_CHANGED"; + else if (!binding.expiresAt().equals(current.expiresAt()) || !binding.expiresAt().isAfter(Instant.now())) status = "EXPIRED"; + else if (!"MATCH".equals(binding.status())) status = binding.status(); + else status = JsonArtifactRecipe.check(current.body().getBytes(StandardCharsets.UTF_8), r.requiredFields()).status(); + return new State(r.criterionKey(), r.revision(), current == null ? null : current.artifactId(), + current == null ? null : current.generation(), status, "MATCH".equals(status)); + }).toList(); + } + + private long evaluationRevision(Long goalId) { + Long revision = jdbc.queryForObject("SELECT evaluation_revision FROM mate_agent_goal WHERE id=? AND deleted=0 FOR UPDATE", Long.class, goalId); + if (revision == null) throw failure("Goal definition unavailable"); + return revision; + } + + private Stored current(Long goalId, String slot) { + var rows = jdbc.query(""" + SELECT a.* FROM mate_goal_json_slot s JOIN mate_goal_json_artifact a + ON a.artifact_id=s.artifact_id AND a.goal_id=s.goal_id AND a.artifact_slot=s.artifact_slot AND a.generation=s.generation + WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE + """, (r, i) -> new Stored(r.getString("artifact_id"), r.getLong("generation"), r.getString("json_body"), + r.getString("sha256"), r.getInt("byte_length"), r.getTimestamp("expires_at").toInstant()), goalId, slot); + return rows.size() == 1 ? rows.getFirst() : null; + } + + private Binding binding(Long goalId, String key) { + var rows = jdbc.query("SELECT * FROM mate_goal_json_binding WHERE goal_id=? AND criterion_key=? FOR UPDATE", + (r, i) -> new Binding(r.getLong("requirement_revision"), r.getLong("evaluation_revision"), r.getString("artifact_id"), + r.getLong("generation"), r.getString("sha256"), r.getString("recipe_id"), r.getInt("recipe_revision"), + r.getString("check_status"), r.getTimestamp("expires_at").toInstant()), goalId, key); + return rows.size() == 1 ? rows.getFirst() : null; + } + + private static boolean intact(Stored current) { + byte[] bytes = current.body().getBytes(StandardCharsets.UTF_8); + return bytes.length <= 1_048_576 && bytes.length == current.byteLength() && ManagedGoalJsonService.digest(bytes).equals(current.sha256()); + } + private static MateClawException failure(String message) { return new MateClawException(409, message); } +} 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 index e910dfd7..c6cc746b 100644 --- a/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java +++ b/mateclaw-server/src/main/java/vip/mate/goal/service/ManagedGoalJsonService.java @@ -49,7 +49,7 @@ public class ManagedGoalJsonService { @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=?", + var rows = jdbc.query("SELECT * FROM mate_goal_json_artifact WHERE goal_id=? AND artifact_id=? FOR UPDATE", (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(); @@ -67,12 +67,16 @@ public class ManagedGoalJsonService { public Artifact publishForRuntime(ChatOrigin origin, String slot, PublishRequest request) { var runtime = runtimeGoal(origin); var result = publishLocked(runtime.goal(), slot, request, runtime.producerKind(), runtime.producerId()); - if (runtime.leaseUntil() != null && !runtime.leaseUntil().isAfter(LocalDateTime.now())) { - throw failure(409, "Goal attempt lease expired during publication"); - } + verifyLease(runtime); return result; } + static void verifyLease(RuntimeScope runtime) { + if (runtime.leaseUntil() != null && !runtime.leaseUntil().isAfter(LocalDateTime.now())) { + throw failure(409, "Goal attempt lease expired during managed JSON operation"); + } + } + record RuntimeScope(GoalJsonAcceptanceService.GoalScope goal, String producerKind, String producerId, LocalDateTime leaseUntil) { } @@ -151,11 +155,11 @@ public class ManagedGoalJsonService { 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); + var generations = jdbc.queryForList("SELECT generation FROM mate_goal_json_slot WHERE goal_id=? AND artifact_slot=? FOR UPDATE", 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)"); + int count = jdbc.queryForList("SELECT artifact_id FROM mate_goal_json_artifact WHERE goal_id=? FOR UPDATE", String.class, goal.id()).size(); + if (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); @@ -181,7 +185,7 @@ public class ManagedGoalJsonService { 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=? + WHERE s.goal_id=? AND s.artifact_slot=? FOR UPDATE """, (r, i) -> artifact(r), goalId, slot); if (rows.isEmpty()) return new Slot(slot, 0, null); var current = rows.getFirst(); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java index aa1adfe8..298d7459 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/DelegateAgentTool.java @@ -136,6 +136,7 @@ public class DelegateAgentTool { "getGoalStatus", "getManagedGoalJsonSlots", "publishManagedGoalJson", + "checkManagedGoalJson", "waitForGoalInput", // Employee authoring spawns persistent agents; a delegated child // doing so risks recursive team creation and privilege creep, so diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ManagedGoalJsonTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ManagedGoalJsonTool.java index 4197daf2..8a2e7350 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/ManagedGoalJsonTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/ManagedGoalJsonTool.java @@ -16,6 +16,7 @@ import vip.mate.goal.service.ManagedGoalJsonService; public class ManagedGoalJsonTool { private final ManagedGoalJsonService artifacts; private final ObjectMapper json; + private final vip.mate.goal.service.GoalJsonBindingService bindings; @Tool(description = "Read the current conversation goal's managed JSON artifact slots and generations. " + "Only user-selected slots appear. Preserve generation strings exactly. This does not check or complete the goal.") @@ -38,4 +39,21 @@ public class ManagedGoalJsonTool { return json.writeValueAsString(artifacts.publishForRuntime(ChatOrigin.from(context), artifactSlot, new ManagedGoalJsonService.PublishRequest(generation, jsonContent))); } + @Tool(description = "Run the trusted JSON fields recipe against an exact current managed version for one user requirement. " + + "Use the requirement revision from getManagedGoalJsonSlots and artifact ID/generation from publication. " + + "The server derives the result from stored bytes; it does not accept a caller PASS. " + + "Every current user requirement needs a matching binding before goal completion; edits or new versions invalidate old bindings.") + public String checkManagedGoalJson( + @ToolParam(description = "Current user requirement key") String criterionKey, + @ToolParam(description = "Exact current requirement revision string") String expectedRequirementRevision, + @ToolParam(description = "Exact current managed artifact ID") String artifactId, + @ToolParam(description = "Exact current slot generation string") String expectedGeneration, + ToolContext context) throws JsonProcessingException { + Long revision; Long generation; + try { revision = Long.valueOf(expectedRequirementRevision); generation = Long.valueOf(expectedGeneration); } + catch (RuntimeException invalid) { throw new vip.mate.exception.MateClawException(400, "Valid expected revisions are required"); } + return json.writeValueAsString(bindings.checkForRuntime(ChatOrigin.from(context), criterionKey, + new vip.mate.goal.service.GoalJsonBindingService.CheckRequest(revision, artifactId, generation))); + } + } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V196__goal_json_bindings.sql b/mateclaw-server/src/main/resources/db/migration/h2/V196__goal_json_bindings.sql new file mode 100644 index 00000000..eabfe84b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V196__goal_json_bindings.sql @@ -0,0 +1,16 @@ +-- Current trusted check per user requirement; invalidated by any referenced revision change. +CREATE TABLE mate_goal_json_binding ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + requirement_revision BIGINT NOT NULL, + evaluation_revision BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + generation BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + recipe_id VARCHAR(64) NOT NULL, + recipe_revision INTEGER NOT NULL, + check_status VARCHAR(32) NOT NULL, + checked_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); diff --git a/mateclaw-server/src/main/resources/db/migration/kingbase/V196__goal_json_bindings.sql b/mateclaw-server/src/main/resources/db/migration/kingbase/V196__goal_json_bindings.sql new file mode 100644 index 00000000..eabfe84b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/kingbase/V196__goal_json_bindings.sql @@ -0,0 +1,16 @@ +-- Current trusted check per user requirement; invalidated by any referenced revision change. +CREATE TABLE mate_goal_json_binding ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + requirement_revision BIGINT NOT NULL, + evaluation_revision BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + generation BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + recipe_id VARCHAR(64) NOT NULL, + recipe_revision INTEGER NOT NULL, + check_status VARCHAR(32) NOT NULL, + checked_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V196__goal_json_bindings.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V196__goal_json_bindings.sql new file mode 100644 index 00000000..eabfe84b --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V196__goal_json_bindings.sql @@ -0,0 +1,16 @@ +-- Current trusted check per user requirement; invalidated by any referenced revision change. +CREATE TABLE mate_goal_json_binding ( + goal_id BIGINT NOT NULL, + criterion_key VARCHAR(64) NOT NULL, + requirement_revision BIGINT NOT NULL, + evaluation_revision BIGINT NOT NULL, + artifact_id VARCHAR(36) NOT NULL, + generation BIGINT NOT NULL, + sha256 VARCHAR(64) NOT NULL, + recipe_id VARCHAR(64) NOT NULL, + recipe_revision INTEGER NOT NULL, + check_status VARCHAR(32) NOT NULL, + checked_at TIMESTAMP NOT NULL, + expires_at TIMESTAMP NOT NULL, + PRIMARY KEY (goal_id, criterion_key) +); 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 index 4d7b286c..f9576066 100644 --- a/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/en/managed-json-acceptance.md @@ -2,7 +2,7 @@ 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. +The current stage provides user configuration and independent managed version storage. Binding verification is available; the successful strong-completion path is still pending. Selected goals temporarily reject completion without falling back to textual claims. Unselected goals retain existing behavior. ## Managed version API @@ -23,3 +23,10 @@ Managed bodies live independently in the database. Ordinary workspace files, cac `getManagedGoalJsonSlots` returns current user requirements, slots and generations. `publishManagedGoalJson` accepts `artifactSlot`, a string `expectedGeneration` and `jsonContent`. Tools cannot configure requirements or supply goal IDs, accounts or owner fences. Interactive sessions require the authenticated account's internal ID. Scheduled persistent-goal execution must match the current continuation, attempt, owner token and live leases. Both paths recheck the conversation, workspace, agent and enabled account. The default delegation deny list includes both tools; the service still independently validates identity. Publication and scheduler settlement serialize through the goal lock, rejecting late writes by former owners. Ending a lease does not mutate previously published versions. Anonymous sessions and cron runs without a bound goal attempt are outside this publication protocol. Missing identity is rejected instead of trusting a display username. + + +## Binding checks + +After publication, call `POST /checks/{criterionKey}` with `expectedRequirementRevision`, `artifactId` and `expectedGeneration`, or use the agent tool `checkManagedGoalJson` with the same fields. Tool revisions and generations are strings. The server checks the specified current slot version using its own fields recipe; it never accepts a caller-provided PASS. `acceptanceEligible=true` applies to that requirement at the time of checking, not to whole-goal completion. + +`GET /checks` reads each requirement's current eligibility. Requirement edits, goal-definition edits, a new slot version, expiry or failed body integrity checks invalidate previous bindings. Recheck the current inputs. Binding and goal-version updates share a transaction; rollback cannot leave a passing credential. Historical diagnostic APIs retain `acceptanceEligible=false`; only managed checks create bindings. Integration with the shared completion entry point is still pending. 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 index c4a8d128..0e6a5637 100644 --- a/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md +++ b/mateclaw-server/src/main/resources/docs/zh/managed-json-acceptance.md @@ -2,7 +2,7 @@ 在 Goal 面板展开“JSON 验收要求”,由对话所有者或管理员显式保存要求。每个 Goal 最多 8 条要求,每条绑定一个产物槽和 1–16 个顶层字段。字段检查表示字段存在且不为 null;false、0 和空字符串允许,不等于内容质量判断。保存后不可关闭强验收模式,可以带当前 revision 修改要求;旧修订会返回冲突。 -当前阶段已提供用户配置与独立受管版本存储;绑定检查和强验收成功完成路径尚未接通。选中此模式的 Goal 暂时拒绝完成,不会回退到文字声明;未选中的 Goal 保持既有行为。 +当前阶段已提供用户配置与独立受管版本存储;绑定检查已接通,强验收成功完成路径尚未接通。选中此模式的 Goal 暂时拒绝完成,不会回退到文字声明;未选中的 Goal 保持既有行为。 ## 受管版本接口 @@ -23,3 +23,10 @@ `getManagedGoalJsonSlots` 返回当前 Goal 的用户要求、槽和 generation;`publishManagedGoalJson` 接收 `artifactSlot`、字符串 `expectedGeneration` 和 `jsonContent`。工具不能配置要求,也不能传 Goal ID、账户或 owner fence。普通会话必须携带已认证账户的内部 ID;持久 Goal 的调度执行必须同时匹配当前 continuation、attempt、owner token 和有效租约。两种入口都重新检查对话、工作区、Agent 和启用账户。代理委派的默认禁止列表包含这两个工具,服务仍独立检查身份。 发布与调度结算按 Goal 锁串行化,晚到的旧 owner 不得继续写入。租约结束不会改写已经合法发布的历史版本。匿名会话和没有绑定 Goal attempt 的 cron 不支持此发布协议;身份缺失直接拒绝,不以显示用户名代替认证。 + + +## 绑定检查 + +发布后,调用 `POST /checks/{criterionKey}`,提交 `expectedRequirementRevision`、`artifactId`、`expectedGeneration`;代理使用 `checkManagedGoalJson` 传相同字段。所有修订和 generation 在代理工具里都是字符串。服务端只检查当前槽的指定版本,运行自己的字段 recipe,不接受调用方提供的 PASS。返回 `acceptanceEligible=true` 表示这条要求当前匹配,不能代表整个 Goal 已完成。 + +`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。当前统一完成入口仍在接入中。 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 2fc57e5d..a5494894 100644 --- a/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java +++ b/mateclaw-server/src/test/java/vip/mate/goal/GoalJsonAcceptanceIntegrationTest.java @@ -39,6 +39,7 @@ class GoalJsonAcceptanceIntegrationTest { @Autowired private JdbcTemplate jdbc; @Autowired private PlatformTransactionManager transactions; @Autowired private vip.mate.tool.builtin.ManagedGoalJsonTool managedTool; + @Autowired private vip.mate.goal.service.GoalJsonBindingService bindings; @Autowired private vip.mate.goal.service.GoalContinuationStore continuations; @Autowired private vip.mate.goal.service.GoalRunCoordinator coordinator; @@ -266,7 +267,7 @@ class GoalJsonAcceptanceIntegrationTest { acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); var origin = accountOrigin(goal, alice); var callbacks = org.springframework.ai.support.ToolCallbacks.from(managedTool); - assertEquals(2, callbacks.length); + assertEquals(3, callbacks.length); for (var callback : callbacks) { String schema = callback.getToolDefinition().inputSchema(); assertFalse(schema.contains("\"goalId\"")); @@ -356,4 +357,89 @@ class GoalJsonAcceptanceIntegrationTest { } } + private vip.mate.goal.service.GoalJsonBindingService.CheckRequest checkRequest(long revision, vip.mate.goal.service.ManagedGoalJsonService.Artifact version) { + return new vip.mate.goal.service.GoalJsonBindingService.CheckRequest(revision, version.artifactId(), version.generation()); + } + + @Test void trustedRecipeBindsExactCurrentVersionAndRejectsTextualSubstitutes() throws Exception { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + assertEquals("NO_ARTIFACT", bindings.state(goal.getId(), alice).getFirst().status()); + var bad = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":null,\"claim\":\"PASS\"}"), alice); + var rejected = bindings.check(goal.getId(), "r", checkRequest(1, bad), alice); + assertFalse(rejected.acceptanceEligible()); + assertEquals(List.of("summary"), rejected.missingFields()); + var good = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":false}"), alice); + assertEquals("SUPERSEDED", bindings.state(goal.getId(), alice).getFirst().status()); + assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, bad), alice)); + var result = managedTool.checkManagedGoalJson("r", "1", good.artifactId(), "2", accountOrigin(goal, alice).toToolContext()); + assertTrue(result.contains("\"acceptanceEligible\":true")); + assertTrue(bindings.state(goal.getId(), alice).getFirst().acceptanceEligible()); + assertEquals(good.artifactId(), bindings.state(goal.getId(), alice).getFirst().artifactId()); + assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, good), bob)); + } + + @Test void editedRequirementsAndGoalDefinitionInvalidatePreviouslyMatchingBindings() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true,\"sources\":[]}"), alice); + assertTrue(bindings.check(goal.getId(), "r", checkRequest(1, version), alice).acceptanceEligible()); + acceptance.configure(goal.getId(), "r", request(1, "summary", "sources"), alice); + assertEquals("REQUIREMENT_CHANGED", bindings.state(goal.getId(), alice).getFirst().status()); + assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, version), alice)); + assertTrue(bindings.check(goal.getId(), "r", checkRequest(2, version), alice).acceptanceEligible()); + GoalUpdateRequest edit = new GoalUpdateRequest(); edit.setDescription("A revised report definition"); + goals.update(goal.getId(), edit, alice); + assertEquals("GOAL_CHANGED", bindings.state(goal.getId(), alice).getFirst().status()); + assertTrue(bindings.check(goal.getId(), "r", checkRequest(2, version), alice).acceptanceEligible()); + } + + @Test void expiredAndCorruptBodiesNeverRemainEligible() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice); + bindings.check(goal.getId(), "r", checkRequest(1, version), alice); + // Direct DB mutation is a corruption/clock fixture, not a supported publication API. + jdbc.update("UPDATE mate_goal_json_artifact SET json_body='{}' WHERE artifact_id=?", version.artifactId()); + assertEquals("CORRUPT", bindings.state(goal.getId(), alice).getFirst().status()); + assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, version), alice)); + var next = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":true}"), alice); + bindings.check(goal.getId(), "r", checkRequest(1, next), alice); + jdbc.update("UPDATE mate_goal_json_artifact SET expires_at=? WHERE artifact_id=?", java.sql.Timestamp.from(java.time.Instant.now().minusSeconds(1)), next.artifactId()); + assertEquals("EXPIRED", bindings.state(goal.getId(), alice).getFirst().status()); + assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, next), alice)); + } + + @Test void rollbackRemovesBindingAndDoesNotAdvanceGoalVersion() { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice); + long before = goals.getById(goal.getId()).getVersion(); + new TransactionTemplate(transactions).executeWithoutResult(status -> { + assertTrue(bindings.check(goal.getId(), "r", checkRequest(1, version), alice).acceptanceEligible()); + status.setRollbackOnly(); + }); + assertEquals("UNBOUND", bindings.state(goal.getId(), alice).getFirst().status()); + assertEquals(before, goals.getById(goal.getId()).getVersion().longValue()); + } + + @Test void racingRequirementEditCannotLeaveAnEligibleOldBinding() throws Exception { + GoalEntity goal = goal(false); + acceptance.configure(goal.getId(), "r", request(0, "summary"), alice); + var version = artifacts.publish(goal.getId(), "report", publication(0, "{\"summary\":true}"), alice); + var start = new java.util.concurrent.CountDownLatch(1); + try (var workers = java.util.concurrent.Executors.newFixedThreadPool(2)) { + var check = workers.submit(() -> { + start.await(); + try { bindings.check(goal.getId(), "r", checkRequest(1, version), alice); return true; } + catch (MateClawException changed) { return false; } + }); + var edit = workers.submit(() -> { start.await(); return acceptance.configure(goal.getId(), "r", request(1, "sources"), alice); }); + start.countDown(); + check.get(10, java.util.concurrent.TimeUnit.SECONDS); + assertEquals(2, edit.get(10, java.util.concurrent.TimeUnit.SECONDS).revision()); + } + assertFalse(bindings.state(goal.getId(), alice).getFirst().acceptanceEligible()); + } + } diff --git a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java index a53ab89a..ad9cfbb3 100644 --- a/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java +++ b/mateclaw-server/src/test/java/vip/mate/tool/builtin/DelegateAgentToolDenyListTest.java @@ -78,7 +78,7 @@ class DelegateAgentToolDenyListTest { // Memory writers (canonical Spring AI tool method names — do not include // any speculative names that would silently no-op). assertThat(defaults).contains("remember", "remember_structured", "forget_structured"); - assertThat(defaults).contains("waitForGoalInput", "getManagedGoalJsonSlots", "publishManagedGoalJson"); + assertThat(defaults).contains("waitForGoalInput", "getManagedGoalJsonSlots", "publishManagedGoalJson", "checkManagedGoalJson"); // Shell stays out by design — see comment on DEFAULT_CHILD_DENIED_TOOLS. assertThat(defaults).doesNotContain("execute_shell_command"); }