mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 03:55:09 +08:00
fix(goal): preserve absolute JSON expiry across timezone changes
This commit is contained in:
parent
30795da1ba
commit
da73826a1a
@ -100,9 +100,9 @@ public class GoalJsonBindingService {
|
||||
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 (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
recipe_id,recipe_revision,check_status,checked_at,expires_at,expires_epoch_second) 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()));
|
||||
result.recipeId(), result.recipeRevision(), result.status(), Timestamp.from(checkedAt), Timestamp.from(current.expiresAt()), current.expiresAt().getEpochSecond());
|
||||
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()));
|
||||
@ -168,7 +168,7 @@ public class GoalJsonBindingService {
|
||||
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);
|
||||
r.getString("sha256"), r.getInt("byte_length"), Instant.ofEpochSecond(r.getLong("expires_epoch_second"))), goalId, slot);
|
||||
return rows.size() == 1 ? rows.getFirst() : null;
|
||||
}
|
||||
|
||||
@ -176,7 +176,7 @@ public class GoalJsonBindingService {
|
||||
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);
|
||||
r.getString("check_status"), Instant.ofEpochSecond(r.getLong("expires_epoch_second"))), goalId, key);
|
||||
return rows.size() == 1 ? rows.getFirst() : null;
|
||||
}
|
||||
|
||||
|
||||
@ -153,16 +153,17 @@ public class ManagedGoalJsonService {
|
||||
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.
|
||||
// Epoch seconds are authoritative across JDBC/JVM timezone changes; SQL timestamps are audit-only.
|
||||
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_id,goal_id,artifact_slot,generation,json_body,sha256,byte_length,producer_kind,producer_id,created_at,expires_at,created_epoch_second,expires_epoch_second)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""", artifact.artifactId(), goal.id(), slot, next, content, artifact.sha256(), bytes.length,
|
||||
producerKind, producerId, Timestamp.from(created), Timestamp.from(artifact.expiresAt()));
|
||||
producerKind, producerId, Timestamp.from(created), Timestamp.from(artifact.expiresAt()),
|
||||
created.getEpochSecond(), artifact.expiresAt().getEpochSecond());
|
||||
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=?",
|
||||
@ -188,7 +189,8 @@ public class ManagedGoalJsonService {
|
||||
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());
|
||||
r.getLong("created_epoch_second") == 0 ? r.getTimestamp("created_at").toInstant() : Instant.ofEpochSecond(r.getLong("created_epoch_second")),
|
||||
Instant.ofEpochSecond(r.getLong("expires_epoch_second")));
|
||||
}
|
||||
|
||||
static String digest(byte[] bytes) {
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
-- Absolute acceptance times must not depend on a JDBC/JVM session timezone.
|
||||
-- Legacy wall-clock timestamps have no recoverable zone: keep their bodies and
|
||||
-- generations, but expire their eligibility rather than guessing an offset.
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN created_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_binding ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
@ -0,0 +1,6 @@
|
||||
-- Absolute acceptance times must not depend on a JDBC/JVM session timezone.
|
||||
-- Legacy wall-clock timestamps have no recoverable zone: keep their bodies and
|
||||
-- generations, but expire their eligibility rather than guessing an offset.
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN created_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_binding ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
@ -0,0 +1,6 @@
|
||||
-- Absolute acceptance times must not depend on a JDBC/JVM session timezone.
|
||||
-- Legacy wall-clock timestamps have no recoverable zone: keep their bodies and
|
||||
-- generations, but expire their eligibility rather than guessing an offset.
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN created_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_artifact ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
ALTER TABLE mate_goal_json_binding ADD COLUMN expires_epoch_second BIGINT NOT NULL DEFAULT 0;
|
||||
@ -16,7 +16,7 @@ Prefix: `/api/v1/goals/{goalId}/json-acceptance`. An enabled account with conver
|
||||
|
||||
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.
|
||||
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. The JSON service contract has been exercised on H2, MySQL 8.0.46 and PostgreSQL 16.14. The MySQL run isolated an existing V192 migration failure using a test-only migration copy; PostgreSQL used the original Kingbase migration tree while skipping an unrelated bundled-skill import failure. These are protocol tests, not confirmation that an unmodified full installation succeeds. The proprietary Kingbase engine has not been tested.
|
||||
|
||||
## Agent publication
|
||||
|
||||
@ -31,7 +31,7 @@ After publication, call `POST /checks/{criterionKey}` with `expectedRequirementR
|
||||
|
||||
`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. Completion events retain the accepted requirement revisions, artifact IDs and generations. Transaction rollback emits neither a completion event nor completion memory.
|
||||
|
||||
This is an explicit per-goal managed JSON protocol with a limited scope. The broad execution-evidence ledger retains its existing prerequisites for global ENFORCE. Ordinary tool-success text and diagnostic MATCH results never become bindings automatically. Backend services cover success, invalidation, races and rollback; full browser/service flows, restart and external database validation are still in progress.
|
||||
This is an explicit per-goal managed JSON protocol with a limited scope. The broad execution-evidence ledger retains its existing prerequisites for global ENFORCE. Ordinary tool-success text and diagnostic MATCH results never become bindings automatically. Backend services cover success, invalidation, races and rollback; a real JWT/browser/service fixture and file-backed H2 upgrade/restart have also passed. Online-model execution and complete product end-to-end coverage are not implied.
|
||||
|
||||
ReAct, Plan and persistent-goal continuations receive managed JSON instructions. Business-skill tool allowlists retain the three goal-level read, publish and check tools, while service identity checks and child-agent restrictions still apply. For selected goals, follow-up and scheduling projections cannot end on a model completion claim or segment Complete alone: the Goal must already have committed completed status. Rejected automatic completion produces a continue result with recheck guidance.
|
||||
|
||||
@ -40,3 +40,11 @@ Runtime completion must also carry server-issued identity. The completeGoal tool
|
||||
The Goal panel's Versions and checks section lets users inspect stored JSON, paste content and explicitly publish a version, then check each requirement. It shows requirement revisions, versions, expiry, quota and snapshot load time; final completion still checks current state. Conflicts require a reload, revoked access clears old content, and terminal goals are read-only. JSON is displayed as text rather than rendered HTML.
|
||||
|
||||
`GET /snapshot` returns requirements, current slots, check eligibility, goal status and version count under one goal lock. The agent's `getManagedGoalJsonSlots` uses this snapshot too, avoiding a mixed view from separate requirement and artifact reads.
|
||||
|
||||
## Deployment and upgrades
|
||||
|
||||
Deploy the managed JSON service and all goal writers together. Stop old application/scheduler instances before enabling requirements; do not run older binaries against goals using this protocol. Old writers do not know its completion gate. A rollback must preserve a compatible writer or restore a coordinated pre-upgrade application/database backup; never clear the required flag or delete requirements to make an old binary proceed.
|
||||
|
||||
V197 makes expiry authoritative in epoch seconds, independent of the JVM/JDBC timezone. Earlier managed records have wall-clock timestamps without a recoverable timezone, so upgrading keeps their bodies, requirements, generations and history but expires their acceptance eligibility. Publish a new version and check it under the current requirement. Existing completed goals remain historical completions; the migration does not reopen them. The 32-version quota still counts retained history.
|
||||
|
||||
The host clock, database credentials and service host remain trusted. Run arbitrary external code without service/database credentials and outside the service's storage permissions if it is not trusted; setting a working directory or scanning paths does not provide OS isolation. Use coordinated backups of the database for requirements, bodies, pointers and bindings; workspace-file backups alone cannot restore managed acceptance.
|
||||
|
||||
@ -16,7 +16,7 @@
|
||||
|
||||
仅当前要求引用的槽可发布,Goal 必须 active 或 paused。正文必须是严格 JSON 对象,拒绝重复键、尾随文档、超过 32 层的嵌套及超过 1 MiB 的 UTF-8 内容。每个 Goal 最多保存 32 个版本;达到配额拒绝继续发布,不覆盖旧版本。每版有效期 24 小时,重复发布同样正文也产生新版本。客户端遇到 generation 冲突应重新读取,不自动覆盖他人发布。
|
||||
|
||||
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文;所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。当前未对 MySQL、Kingbase/PostgreSQL 实例执行迁移验收;H2 服务集成测试不等于外部数据库验证。
|
||||
这些版本独立存储在数据库,不能用普通工作区文件、缓存路径或文字中的 hash 替代。发布接口不支持更新历史正文;所有版本与槽指针同事务保存。SHA-256 用于标识及完整性核对,不能隔离拥有数据库凭据或宿主权限的攻击者;数据库和服务宿主是此有限协议的可信基础。JSON 服务契约已在 H2、MySQL 8.0.46 和 PostgreSQL 16.14 上实测。MySQL 使用仅修正既有 V192 失败的临时迁移副本;PostgreSQL 使用原始 Kingbase 迁移树,跳过无关的内置技能导入失败。这是协议验证,不能代表未修改的完整安装成功;尚未实测 Kingbase 专有引擎。
|
||||
|
||||
## 代理发布
|
||||
|
||||
@ -31,7 +31,7 @@
|
||||
|
||||
`GET /checks` 读取每条要求的当前资格。要求修改、Goal 定义修改、槽出现新版本、版本过期或正文完整性失败都会使旧绑定失效;需要按当前条件重新检查。每次绑定与 Goal version 更新同事务,失败回滚不留下通过凭据。历史诊断接口的 `acceptanceEligible=false` 保持不变,只有此受管版本检查产生绑定。完成事件保留本次受管绑定的条件修订、产物 ID 和 generation 引用;事务回滚不发布完成事件或完成记忆。
|
||||
|
||||
这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;完整浏览器服务闭环、重启和外部数据库验证仍在推进。
|
||||
这是逐 Goal 显式选择的有限受管 JSON 协议;宽泛执行证据账本的全局 ENFORCE 配置仍遵循原有准入限制。此协议不把任何普通工具成功文本或诊断 MATCH 自动升级为绑定。后端服务已覆盖成功、失效、竞争和回滚;真实 JWT/浏览器/服务夹具及磁盘 H2 升级重启也已通过;这不代表在线模型运行或完整产品端到端覆盖。
|
||||
|
||||
代理在 ReAct、Plan 和持久 Goal 续跑入口都会收到受管 JSON 操作指引。业务技能的工具白名单保留读取、发布和检查这三个 Goal 通用工具,仍执行服务端身份校验与子代理禁用。选中模式下,follow-up 和调度投影不能凭模型的“已完成”或 segment Complete 声明结束;必须先有已提交的 Goal completed 状态。自动完成被拒绝时,向运行时返回 continue 和重检指引,不暴露已接受完成的信号。
|
||||
|
||||
@ -40,3 +40,11 @@
|
||||
在 Goal 面板的“产物版本与检查”中可以查看当前受管 JSON、粘贴正文并显式发布新版本,以及逐条执行检查。界面显示条件修订、版本、有效期、已使用配额和读取时间;这是读取时的快照,最终完成仍复核当前状态。发生冲突后必须重新读取,访问撤销会清空旧内容,终态只读。正文按文本显示,不渲染其中的 HTML。
|
||||
|
||||
`GET /snapshot` 在同一 Goal 锁内返回要求、当前槽、检查资格、Goal 状态和版本计数。代理 `getManagedGoalJsonSlots` 也使用此快照,避免分别读取要求和产物造成混合视图。
|
||||
|
||||
## 部署与升级
|
||||
|
||||
受管 JSON 服务与所有 Goal 写入实例必须统一部署。启用要求前停止旧应用和调度实例,不得让不认识此完成门的旧二进制继续写入选中协议的 Goal。回滚须保留兼容的写入实例,或协调恢复升级前的应用及数据库备份;不能清除 required 标记或删除要求来让旧版本继续完成。
|
||||
|
||||
V197 使用 epoch 秒作为有效期依据,不受 JVM/JDBC 时区变化影响。此前受管记录的本地时间戳无法可靠还原原始时区,因此升级保留正文、要求、generation 和历史,但使旧验收资格过期。需要重新发布版本并按当前要求检查。已经完成的 Goal 保持历史完成状态,不被重新打开;保留历史仍计入 32 个版本的配额。
|
||||
|
||||
宿主时钟、数据库凭据和服务宿主仍属于可信基础。若外部任意代码不可信,应在没有服务/数据库凭据、没有服务存储权限的隔离环境执行;仅设置工作目录或扫描路径不是操作系统隔离。备份应协调保存数据库中的要求、正文、指针和绑定,仅备份工作区文件无法恢复受管验收。
|
||||
|
||||
@ -405,7 +405,7 @@ class GoalJsonAcceptanceIntegrationTest {
|
||||
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());
|
||||
jdbc.update("UPDATE mate_goal_json_artifact SET expires_epoch_second=? WHERE artifact_id=?", java.time.Instant.now().minusSeconds(1).getEpochSecond(), next.artifactId());
|
||||
assertEquals("EXPIRED", bindings.state(goal.getId(), alice).getFirst().status());
|
||||
assertThrows(MateClawException.class, () -> bindings.check(goal.getId(), "r", checkRequest(1, next), alice));
|
||||
}
|
||||
@ -488,7 +488,7 @@ class GoalJsonAcceptanceIntegrationTest {
|
||||
case "requirement" -> { acceptance.configure(goal.getId(), "r", request(1, "summary", "sources"), alice); revision = 2; }
|
||||
case "definition" -> { GoalUpdateRequest edit = new GoalUpdateRequest(); edit.setDescription("new definition"); goals.update(goal.getId(), edit, alice); }
|
||||
case "superseded" -> version = artifacts.publish(goal.getId(), "report", publication(1, "{\"summary\":true}"), alice);
|
||||
case "expired" -> jdbc.update("UPDATE mate_goal_json_artifact SET expires_at=? WHERE artifact_id=?", java.sql.Timestamp.from(java.time.Instant.now().minusSeconds(1)), version.artifactId());
|
||||
case "expired" -> jdbc.update("UPDATE mate_goal_json_artifact SET expires_epoch_second=? WHERE artifact_id=?", java.time.Instant.now().minusSeconds(1).getEpochSecond(), version.artifactId());
|
||||
case "corrupt" -> jdbc.update("UPDATE mate_goal_json_artifact SET json_body='{}' WHERE artifact_id=?", version.artifactId());
|
||||
}
|
||||
assertThrows(MateClawException.class, () -> goals.markCompleted(goal.getId(), null), invalidation);
|
||||
|
||||
@ -96,6 +96,57 @@ class GoalJsonRestartIntegrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test void upgradingAmbiguousLegacyTimestampsExpiresEvidenceWithoutDisablingRequirements() throws Exception {
|
||||
String url = "jdbc:h2:file:" + directory.resolve("legacy-json")
|
||||
+ ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE";
|
||||
Flyway.configure().dataSource(url, "sa", "").locations("classpath:db/migration/h2")
|
||||
.placeholderReplacement(false).target("196").load().migrate();
|
||||
var jdbc = new JdbcTemplate(new DriverManagerDataSource(url, "sa", ""));
|
||||
jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (88101,'legacy-json-owner','unused',TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)");
|
||||
jdbc.update("INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted) VALUES (88102,'legacy-json','legacy-json-owner',1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)");
|
||||
jdbc.update("INSERT INTO mate_agent_goal(id,conversation_id,agent_id,workspace_id,created_by,title,description,json_acceptance_required,create_time,update_time) VALUES (88102,'legacy-json',1,1,'legacy-json-owner','Legacy JSON','Migration fixture',TRUE,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||
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 (88102,'r','report',1,'[\"summary\"]','legacy-json-owner','legacy-json-owner',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)");
|
||||
String body = "{\"summary\":false}";
|
||||
String artifact = java.util.UUID.randomUUID().toString();
|
||||
String sha = java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256")
|
||||
.digest(body.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
var expires = java.sql.Timestamp.from(java.time.Instant.now().plusSeconds(86400));
|
||||
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 (?,88102,'report',1,?,?,?,'user','legacy-json-owner',CURRENT_TIMESTAMP,?)", artifact, body, sha, body.length(), expires);
|
||||
jdbc.update("INSERT INTO mate_goal_json_slot(goal_id,artifact_slot,generation,artifact_id) VALUES (88102,'report',1,?)", artifact);
|
||||
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 (88102,'r',1,0,?,1,?,'json-required-fields',1,'MATCH',CURRENT_TIMESTAMP,?)", artifact, sha, expires);
|
||||
try (var context = start(url)) {
|
||||
var goals = context.getBean(GoalService.class);
|
||||
var artifacts = context.getBean(ManagedGoalJsonService.class);
|
||||
var bindings = context.getBean(GoalJsonBindingService.class);
|
||||
assertTrue(goals.getById(88102L).isJsonAcceptanceRequired());
|
||||
assertEquals(body, artifacts.read(88102L, artifact, "legacy-json-owner").jsonContent());
|
||||
assertEquals("EXPIRED", bindings.state(88102L, "legacy-json-owner").getFirst().status());
|
||||
assertThrows(MateClawException.class, () -> goals.markCompleted(88102L, null));
|
||||
var current = artifacts.publish(88102L, "report", new ManagedGoalJsonService.PublishRequest(1L, body), "legacy-json-owner");
|
||||
assertEquals(2, current.generation());
|
||||
assertTrue(bindings.check(88102L, "r", new GoalJsonBindingService.CheckRequest(1L, current.artifactId(), 2L), "legacy-json-owner").acceptanceEligible());
|
||||
assertEquals(GoalStatus.COMPLETED, goals.markCompleted(88102L, null).getStatus());
|
||||
}
|
||||
}
|
||||
|
||||
@Test void expiryDoesNotChangeWhenASeparateJvmUsesAnotherTimezone() throws Exception {
|
||||
for (String phase : List.of("write", "read")) {
|
||||
Path log = directory.resolve(phase + ".log");
|
||||
Process child = new ProcessBuilder(Path.of(System.getProperty("java.home"), "bin", "java").toString(),
|
||||
"-Xmx768m", "-Duser.timezone=" + (phase.equals("write") ? "Asia/Shanghai" : "UTC"),
|
||||
"-cp", System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")),
|
||||
GoalJsonTimezoneProcessProbe.class.getName(), directory.toString(), phase)
|
||||
.redirectErrorStream(true).redirectOutput(log.toFile()).start();
|
||||
if (!child.waitFor(60, java.util.concurrent.TimeUnit.SECONDS)) {
|
||||
child.destroyForcibly(); fail("Timezone child JVM timed out: " + phase);
|
||||
}
|
||||
assertEquals(0, child.exitValue(), () -> {
|
||||
try { return java.nio.file.Files.readString(log); }
|
||||
catch (java.io.IOException error) { return error.toString(); }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private ConfigurableApplicationContext start(String url) {
|
||||
var application = new SpringApplication(MateClawApplication.class, MemoryFixture.class);
|
||||
application.setWebApplicationType(WebApplicationType.NONE);
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
package vip.mate.goal;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.WebApplicationType;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import vip.mate.goal.model.GoalCreateRequest;
|
||||
import vip.mate.goal.service.*;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Properties;
|
||||
|
||||
/** Child-JVM fixture for changing host timezone across a real process restart. */
|
||||
public class GoalJsonTimezoneProcessProbe {
|
||||
public static void main(String[] args) throws Exception {
|
||||
Path directory = Path.of(args[0]);
|
||||
var application = new SpringApplication(vip.mate.MateClawApplication.class, GoalJsonRestartIntegrationTest.MemoryFixture.class);
|
||||
application.setWebApplicationType(WebApplicationType.NONE);
|
||||
try (var context = application.run(
|
||||
"--spring.datasource.url=jdbc:h2:file:" + directory.resolve("timezone") + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE",
|
||||
"--spring.ai.dashscope.api-key=timezone-fixture-no-provider",
|
||||
"--mateclaw.goal.enabled=false", "--mateclaw.plugin.enabled=false",
|
||||
"--mateclaw.skill.workspace.auto-init=false", "--mateclaw.skill.workspace.root=" + directory.resolve("skills"))) {
|
||||
var jdbc = context.getBean(JdbcTemplate.class);
|
||||
var goals = context.getBean(GoalService.class);
|
||||
var requirements = context.getBean(GoalJsonAcceptanceService.class);
|
||||
var artifacts = context.getBean(ManagedGoalJsonService.class);
|
||||
var bindings = context.getBean(GoalJsonBindingService.class);
|
||||
Properties receipt = new Properties();
|
||||
Path file = directory.resolve("receipt.properties");
|
||||
if (args[1].equals("write")) {
|
||||
jdbc.update("INSERT INTO mate_user(id,username,password,enabled,role,create_time,update_time,deleted) VALUES (99001,'timezone-owner','unused',TRUE,'user',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)");
|
||||
for (String name : List.of("valid", "expired")) {
|
||||
String conversation = "timezone-" + name;
|
||||
jdbc.update("INSERT INTO mate_conversation(id,conversation_id,username,workspace_id,agent_id,create_time,update_time,deleted) VALUES (?,?,'timezone-owner',1,1,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP,0)", name.equals("valid") ? 99002L : 99003L, conversation);
|
||||
var request = new GoalCreateRequest(); request.setConversationId(conversation);
|
||||
request.setWorkspaceId(1L); request.setAgentId(1L); request.setTitle(name); request.setDescription("Timezone restart fixture");
|
||||
request.setPersistentExecution(false); request.setAutoFollowupEnabled(false);
|
||||
long goal = goals.create(request, "timezone-owner").getId();
|
||||
requirements.configure(goal, "r", new GoalJsonAcceptanceService.ConfigureRequest(0L, "report", List.of("summary")), "timezone-owner");
|
||||
var version = artifacts.publish(goal, "report", new ManagedGoalJsonService.PublishRequest(0L, "{\"summary\":false}"), "timezone-owner");
|
||||
bindings.check(goal, "r", new GoalJsonBindingService.CheckRequest(1L, version.artifactId(), 1L), "timezone-owner");
|
||||
receipt.setProperty(name + ".goal", String.valueOf(goal));
|
||||
receipt.setProperty(name + ".artifact", version.artifactId());
|
||||
receipt.setProperty(name + ".created", String.valueOf(version.createdAt().getEpochSecond()));
|
||||
receipt.setProperty(name + ".expires", String.valueOf(version.expiresAt().getEpochSecond()));
|
||||
if (name.equals("expired")) {
|
||||
Timestamp expired = Timestamp.from(Instant.now().minusSeconds(60));
|
||||
jdbc.update("UPDATE mate_goal_json_artifact SET expires_at=?,expires_epoch_second=? WHERE goal_id=?", expired, expired.toInstant().getEpochSecond(), goal);
|
||||
jdbc.update("UPDATE mate_goal_json_binding SET expires_at=?,expires_epoch_second=? WHERE goal_id=?", expired, expired.toInstant().getEpochSecond(), goal);
|
||||
if (!bindings.state(goal, "timezone-owner").getFirst().status().equals("EXPIRED")) throw new AssertionError("Expiry fixture must initially be expired");
|
||||
}
|
||||
}
|
||||
try (var output = Files.newOutputStream(file)) { receipt.store(output, "Disposable timezone fixture"); }
|
||||
} else {
|
||||
try (var input = Files.newInputStream(file)) { receipt.load(input); }
|
||||
long expiredGoal = Long.parseLong(receipt.getProperty("expired.goal"));
|
||||
if (bindings.state(expiredGoal, "timezone-owner").getFirst().acceptanceEligible()) {
|
||||
throw new AssertionError("Previously expired JSON became eligible after host timezone changed");
|
||||
}
|
||||
long validGoal = Long.parseLong(receipt.getProperty("valid.goal"));
|
||||
var version = artifacts.read(validGoal, receipt.getProperty("valid.artifact"), "timezone-owner").artifact();
|
||||
if (version.createdAt().getEpochSecond() != Long.parseLong(receipt.getProperty("valid.created"))
|
||||
|| version.expiresAt().getEpochSecond() != Long.parseLong(receipt.getProperty("valid.expires"))) {
|
||||
throw new AssertionError("Managed JSON absolute timestamps changed across process restart");
|
||||
}
|
||||
if (!bindings.state(validGoal, "timezone-owner").getFirst().acceptanceEligible()) throw new AssertionError("Valid binding lost after timezone change");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user