mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-11 21:40:41 +00:00
feat: add coding harness runtime and CMS module
This commit is contained in:
@@ -112,6 +112,12 @@ services:
|
||||
SPRING_DATA_REDIS_HOST: redis
|
||||
SPRING_DATA_REDIS_PORT: 6379
|
||||
SPRING_DATA_REDIS_DATABASE: 0
|
||||
# Allow large presentation files for knowledge-base ingestion.
|
||||
SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: 600MB
|
||||
SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: 620MB
|
||||
VECTOR_STORE_TYPE: weaviate
|
||||
VECTOR_STORE_WEAVIATE_PROTOCOL: http
|
||||
VECTOR_STORE_WEAVIATE_HOST: weaviate:8080
|
||||
# 日志配置
|
||||
LOGGING_LEVEL_ORG_RUOYI: info
|
||||
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: warn
|
||||
|
||||
23
docs/script/sql/update/2026-08-15-cms-content.sql
Normal file
23
docs/script/sql/update/2026-08-15-cms-content.sql
Normal file
@@ -0,0 +1,23 @@
|
||||
-- ----------------------------
|
||||
-- 内容管理表 cms_content
|
||||
-- 2026-08-15 创建
|
||||
-- ----------------------------
|
||||
CREATE TABLE IF NOT EXISTS `cms_content` (
|
||||
`content_id` bigint NOT NULL COMMENT '内容ID',
|
||||
`tenant_id` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '000000' COMMENT '租户编号',
|
||||
`title` varchar(200) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '内容标题',
|
||||
`summary` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '摘要',
|
||||
`content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '正文内容',
|
||||
`cover_image` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '封面图片',
|
||||
`status` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '状态(0草稿 1已发布 2下架)',
|
||||
`publish_time` datetime NULL DEFAULT NULL COMMENT '发布时间',
|
||||
`sort_order` int NULL DEFAULT 0 COMMENT '排序顺序',
|
||||
`del_flag` char(1) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT '0' COMMENT '删除标志(0代表存在 1代表删除)',
|
||||
`remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注',
|
||||
`create_dept` bigint NULL DEFAULT NULL COMMENT '创建部门',
|
||||
`create_by` bigint NULL DEFAULT NULL COMMENT '创建者',
|
||||
`create_time` datetime NULL DEFAULT NULL COMMENT '创建时间',
|
||||
`update_by` bigint NULL DEFAULT NULL COMMENT '更新者',
|
||||
`update_time` datetime NULL DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`content_id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = '内容管理表' ROW_FORMAT = DYNAMIC;
|
||||
62
docs/script/sql/update/2026-08-15-cms-menu.sql
Normal file
62
docs/script/sql/update/2026-08-15-cms-menu.sql
Normal file
@@ -0,0 +1,62 @@
|
||||
-- ============================================================
|
||||
-- 内容管理(CMS)菜单权限脚本
|
||||
-- 2026-08-15
|
||||
-- 菜单 ID 使用 2099010100000000040–2099010100000000045 段(已验证空区间),
|
||||
-- 与现有 snowflake id(2xxxxxxxxxxxxxxxxx)及 2099010100000000xxx 既有段不冲突。
|
||||
-- 幂等:每行通过 WHERE NOT EXISTS 同时检查固定 menu_id 与对应的 path/component/perms 语义,
|
||||
-- 避免同语义菜单已用其他 ID 存在时重复插入。
|
||||
-- ============================================================
|
||||
|
||||
-- 目录:内容管理 (parent_id=0, path='cms', menu_type='M')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000040, '内容管理', 0, 6, 'cms', '', '', 1, 0, 'M', '0', '0', '', 'fluent:content-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容管理目录'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000040
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `path` = 'cms' AND `menu_type` = 'M' AND `parent_id` = 0
|
||||
);
|
||||
|
||||
-- 页面:内容列表 (parent_id=2099010100000000040, component='cms/content/index', menu_type='C')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000041, '内容列表', 2099010100000000040, 1, 'content', 'cms/content/index', NULL, 1, 0, 'C', '0', '0', 'cms:content:list', 'fluent:document-text-24-regular', 103, 1, '2026-08-15 00:00:00', NULL, NULL, '内容列表菜单'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000041
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `component` = 'cms/content/index' AND `menu_type` = 'C'
|
||||
);
|
||||
|
||||
-- 按钮:内容查询 (perms='cms:content:query')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000042, '内容查询', 2099010100000000041, 1, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:query', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000042
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:query' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容新增 (perms='cms:content:add')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000043, '内容新增', 2099010100000000041, 2, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:add', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000043
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:add' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容修改 (perms='cms:content:edit')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000044, '内容修改', 2099010100000000041, 3, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:edit', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000044
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:edit' AND `menu_type` = 'F'
|
||||
);
|
||||
|
||||
-- 按钮:内容删除 (perms='cms:content:remove')
|
||||
INSERT INTO `sys_menu` (`menu_id`, `menu_name`, `parent_id`, `order_num`, `path`, `component`, `query_param`, `is_frame`, `is_cache`, `menu_type`, `visible`, `status`, `perms`, `icon`, `create_dept`, `create_by`, `create_time`, `update_by`, `update_time`, `remark`)
|
||||
SELECT 2099010100000000045, '内容删除', 2099010100000000041, 4, '#', '', NULL, 1, 0, 'F', '0', '0', 'cms:content:remove', '#', 103, 1, '2026-08-15 00:00:00', NULL, NULL, ''
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM `sys_menu` WHERE `menu_id` = 2099010100000000045
|
||||
UNION
|
||||
SELECT 1 FROM `sys_menu` WHERE `perms` = 'cms:content:remove' AND `menu_type` = 'F'
|
||||
);
|
||||
@@ -20,6 +20,17 @@ server:
|
||||
# 阻塞任务线程池, 当执行类似servlet请求阻塞操作, undertow会从这个线程池中取得线程,它的值设置取决于系统的负载
|
||||
worker: 256
|
||||
|
||||
# Harness 命令仍受工作区租约、执行计划和工具审批约束;开启后才能验证并运行生成的网站。
|
||||
coding:
|
||||
harness:
|
||||
budget:
|
||||
# 模型回合上限;工具额度按每回合最多 3 次保持同比例,避免工具预算先于回合耗尽。
|
||||
max-iterations: 200
|
||||
max-tool-calls: 600
|
||||
tools:
|
||||
execute-process:
|
||||
enabled: true
|
||||
|
||||
--- # 小程序对话 WebSocket 兜底默认模型(前端未传 model 且无智能体绑定时使用)
|
||||
chat:
|
||||
default-model: deepseek-v4-flash
|
||||
@@ -129,7 +140,6 @@ security:
|
||||
- /*/api-docs/**
|
||||
- /warm-flow-ui/config
|
||||
- /workflow/run
|
||||
- /coding/**
|
||||
# 多租户配置
|
||||
tenant:
|
||||
# 是否开启
|
||||
|
||||
@@ -8,6 +8,7 @@ import org.ruoyi.common.core.utils.SpringUtils;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Primary;
|
||||
import org.springframework.core.task.VirtualThreadTaskExecutor;
|
||||
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
@@ -39,6 +40,7 @@ public class ThreadPoolConfig {
|
||||
/**
|
||||
* 执行周期性或定时任务
|
||||
*/
|
||||
@Primary
|
||||
@Bean(name = "scheduledExecutorService")
|
||||
protected ScheduledExecutorService scheduledExecutorService() {
|
||||
// daemon 必须为 true
|
||||
|
||||
@@ -9,6 +9,7 @@ import cn.dev33.satoken.router.SaRouter;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.dev33.satoken.util.SaResult;
|
||||
import cn.dev33.satoken.util.SaTokenConsts;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
@@ -50,9 +51,11 @@ public class SecurityConfig implements WebMvcConfigurer {
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 注册路由拦截器,自定义验证规则
|
||||
registry.addInterceptor(new SaInterceptor(handler -> {
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
// 异步线程中 SaToken 上下文不存在,跳过检查
|
||||
// 这避免了 SSE 流式响应完成后 emitter.complete() 触发的问题
|
||||
if (SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
|
||||
if (request.getDispatcherType() != DispatcherType.REQUEST
|
||||
|| SaTokenContextForThreadLocalStaff.getModelBoxOrNull() == null) {
|
||||
return;
|
||||
}
|
||||
AllUrlHandler allUrlHandler = SpringUtils.getBean(AllUrlHandler.class);
|
||||
@@ -62,9 +65,6 @@ public class SecurityConfig implements WebMvcConfigurer {
|
||||
.match(allUrlHandler.getUrls())
|
||||
// 对未排除的路径进行检查
|
||||
.check(() -> {
|
||||
HttpServletRequest request = ServletUtils.getRequest();
|
||||
HttpServletResponse response = ServletUtils.getResponse();
|
||||
response.setContentType(SaTokenConsts.CONTENT_TYPE_APPLICATION_JSON);
|
||||
// 检查是否登录 是否有token
|
||||
StpUtil.checkLogin();
|
||||
|
||||
|
||||
@@ -84,6 +84,13 @@
|
||||
<artifactId>HikariCP</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Pure-Java, read-only worktree inspection for the shell-free coding Harness. -->
|
||||
<dependency>
|
||||
<groupId>org.eclipse.jgit</groupId>
|
||||
<artifactId>org.eclipse.jgit</artifactId>
|
||||
<version>7.6.0.202603022253-r</version>
|
||||
</dependency>
|
||||
|
||||
<!--文档解析-->
|
||||
<dependency>
|
||||
<groupId>dev.langchain4j</groupId>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package org.ruoyi.controller.coding;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
@@ -11,6 +12,8 @@ import org.ruoyi.enums.ModelType;
|
||||
import org.ruoyi.service.coding.CodingWorkspaceService;
|
||||
import org.ruoyi.service.coding.ICodingService;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -20,15 +23,15 @@ import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 编程能力接口(B 路径,不走 Supervisor 调度)
|
||||
*
|
||||
* <p>第一阶段 {@code /coding/**} 在 {@code application.yml} 的 security.excludes 中,
|
||||
* 免鉴权直连。Controller 只做参数绑定 + 同步取 userId(Sa-Token 异步上下文丢失,
|
||||
* 见 SecurityConfig 注释)+ 转发 Service。
|
||||
* <p>{@code /coding/**} 使用全局 Sa-Token 拦截器验证登录态。Controller 只做参数绑定
|
||||
* 与同步获取 userId(Sa-Token 异步上下文不传播),然后转发 Service。
|
||||
*
|
||||
* @author ageerle
|
||||
*/
|
||||
@@ -36,12 +39,20 @@ import java.util.List;
|
||||
@RestController
|
||||
@RequiredArgsConstructor
|
||||
@RequestMapping("/coding")
|
||||
@SaCheckPermission("coding:harness:use")
|
||||
public class CodingController {
|
||||
|
||||
private final ICodingService codingService;
|
||||
private final CodingWorkspaceService workspaceService;
|
||||
private final IChatModelService chatModelService;
|
||||
|
||||
/**
|
||||
* The legacy endpoint exposes an unbounded AiServices loop with delete and process tools. It is
|
||||
* kept only as an explicit migration escape hatch; the durable Harness API is the safe default.
|
||||
*/
|
||||
@Value("${coding.legacy.enabled:false}")
|
||||
private boolean legacyEnabled;
|
||||
|
||||
/**
|
||||
* 编程对话(SSE 流式)
|
||||
*
|
||||
@@ -49,8 +60,10 @@ public class CodingController {
|
||||
* @return SseEmitter
|
||||
*/
|
||||
@PostMapping(value = "/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
@SaCheckPermission({"coding:harness:write", "coding:harness:legacy-command"})
|
||||
public SseEmitter chat(@Valid @RequestBody CodingRequestBo bo) {
|
||||
// 同步线程取 userId;第一阶段免鉴权,可能为 null
|
||||
requireLegacyEnabled();
|
||||
// 在进入异步执行前于已鉴权的 HTTP 线程取 userId。
|
||||
Long userId = LoginHelper.getUserId();
|
||||
return codingService.chat(bo, userId);
|
||||
}
|
||||
@@ -58,11 +71,13 @@ public class CodingController {
|
||||
@GetMapping("/workspace")
|
||||
public R<CodingWorkspaceService.WorkspaceResult> workspace(
|
||||
@RequestParam(required = false) String workspacePath) throws Exception {
|
||||
requireLegacyEnabled();
|
||||
return R.ok(workspaceService.list(workspacePath));
|
||||
}
|
||||
|
||||
@GetMapping("/models")
|
||||
public R<List<ModelOption>> models() {
|
||||
requireLegacyEnabled();
|
||||
// 编程对话只能用聊天模型,按 category=chat 过滤
|
||||
ChatModelBo bo = new ChatModelBo();
|
||||
bo.setCategory(ModelType.CHAT.getKey());
|
||||
@@ -76,19 +91,31 @@ public class CodingController {
|
||||
public R<CodingWorkspaceService.FileContent> file(
|
||||
@RequestParam(required = false) String workspacePath,
|
||||
@RequestParam String path) throws Exception {
|
||||
requireLegacyEnabled();
|
||||
return R.ok(workspaceService.read(workspacePath, path));
|
||||
}
|
||||
|
||||
@PutMapping("/file")
|
||||
@SaCheckPermission("coding:harness:write")
|
||||
public R<CodingWorkspaceService.FileContent> saveFile(@RequestBody FileWriteRequest request) throws Exception {
|
||||
requireLegacyEnabled();
|
||||
return R.ok(workspaceService.write(request.workspacePath(), request.path(), request.content()));
|
||||
}
|
||||
|
||||
@PostMapping("/command")
|
||||
@SaCheckPermission({"coding:harness:write", "coding:harness:legacy-command"})
|
||||
public R<CodingWorkspaceService.CommandResult> command(@RequestBody CommandRequest request) {
|
||||
requireLegacyEnabled();
|
||||
return R.ok(workspaceService.execute(request.workspacePath(), request.command()));
|
||||
}
|
||||
|
||||
private void requireLegacyEnabled() {
|
||||
if (!legacyEnabled) {
|
||||
throw new ResponseStatusException(HttpStatus.GONE,
|
||||
"Legacy coding endpoints are disabled; use /coding/harness");
|
||||
}
|
||||
}
|
||||
|
||||
public record FileWriteRequest(String workspacePath, String path, String content) { }
|
||||
public record CommandRequest(String workspacePath, String command) { }
|
||||
public record ModelOption(Long id, String name, String provider) { }
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
package org.ruoyi.controller.coding;
|
||||
|
||||
import cn.dev33.satoken.annotation.SaCheckPermission;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.common.satoken.utils.LoginHelper;
|
||||
import org.ruoyi.service.coding.harness.app.CodingHarnessApplicationService;
|
||||
import org.ruoyi.service.coding.harness.app.CreateHarnessRunCommand;
|
||||
import org.ruoyi.service.coding.harness.app.CreateHarnessSessionCommand;
|
||||
import org.ruoyi.service.coding.harness.app.QueueHarnessInputCommand;
|
||||
import org.ruoyi.service.coding.harness.approval.ApprovalDecision;
|
||||
import org.ruoyi.service.coding.harness.event.HarnessEventHub;
|
||||
import org.ruoyi.service.coding.harness.event.HarnessEventSubscription;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessBudget;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessApprovalPolicy;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessEvent;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessInputKind;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessMessage;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessRunState;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessSessionState;
|
||||
import org.ruoyi.service.coding.harness.modelruntime.HarnessModelRegistry;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
/** Durable coding-agent Harness API. Authentication is mandatory for every endpoint. */
|
||||
@Validated
|
||||
@RestController
|
||||
@RequestMapping("/coding/harness")
|
||||
@SaCheckPermission("coding:harness:use")
|
||||
public class CodingHarnessController {
|
||||
|
||||
private static final long STREAM_TIMEOUT_MILLIS = 24 * 60 * 60 * 1000L;
|
||||
|
||||
private final CodingHarnessApplicationService applicationService;
|
||||
private final HarnessEventHub eventHub;
|
||||
private final HarnessModelRegistry modelRegistry;
|
||||
|
||||
public CodingHarnessController(CodingHarnessApplicationService applicationService,
|
||||
HarnessEventHub eventHub,
|
||||
HarnessModelRegistry modelRegistry) {
|
||||
this.applicationService = applicationService;
|
||||
this.eventHub = eventHub;
|
||||
this.modelRegistry = modelRegistry;
|
||||
}
|
||||
|
||||
@PostMapping("/sessions")
|
||||
public R<HarnessSessionState> createSession(@Valid @RequestBody CreateSessionRequest request) {
|
||||
if (request.permissionMode() != HarnessPermissionMode.READ_ONLY) {
|
||||
// Permission mode is a requested ceiling, never a client-granted authority.
|
||||
StpUtil.checkPermission("coding:harness:write");
|
||||
}
|
||||
modelRegistry.requireConfigured(request.model());
|
||||
return R.ok(applicationService.createSession(owner(), new CreateHarnessSessionCommand(
|
||||
request.workspacePath(), request.model(), request.permissionMode(),
|
||||
request.approvalPolicy(), request.title(), request.idempotencyKey())));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions")
|
||||
public R<List<HarnessSessionState>> listSessions() {
|
||||
return R.ok(applicationService.listSessions(owner()));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}")
|
||||
public R<HarnessSessionState> getSession(@PathVariable String sessionId) {
|
||||
return R.ok(applicationService.getSession(owner(), sessionId));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/messages")
|
||||
public R<List<HarnessMessage>> messages(
|
||||
@PathVariable String sessionId,
|
||||
@RequestParam(defaultValue = "0") @Min(0) long afterSequence,
|
||||
@RequestParam(defaultValue = "500") @Min(1) @Max(10_000) int limit) {
|
||||
return R.ok(applicationService.readMessages(owner(), sessionId, afterSequence, limit));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs")
|
||||
public R<HarnessRunState> createRun(@PathVariable String sessionId,
|
||||
@Valid @RequestBody CreateRunRequest request) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.createRun(owner(), sessionId,
|
||||
new CreateHarnessRunCommand(request.requirement(), request.budget(),
|
||||
request.idempotencyKey())));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/runs")
|
||||
public R<List<HarnessRunState>> listRuns(@PathVariable String sessionId) {
|
||||
return R.ok(applicationService.listRuns(owner(), sessionId));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/runs/{runId}")
|
||||
public R<HarnessRunState> getRun(@PathVariable String sessionId, @PathVariable String runId) {
|
||||
return R.ok(applicationService.getRun(owner(), sessionId, runId));
|
||||
}
|
||||
|
||||
@GetMapping("/sessions/{sessionId}/runs/{runId}/events")
|
||||
public R<List<HarnessEvent>> events(
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable String runId,
|
||||
@RequestParam(defaultValue = "0") @Min(0) long afterSequence,
|
||||
@RequestParam(defaultValue = "1000") @Min(1) @Max(10_000) int limit) {
|
||||
return R.ok(applicationService.readEvents(owner(), sessionId, runId, afterSequence, limit));
|
||||
}
|
||||
|
||||
@GetMapping(value = "/sessions/{sessionId}/runs/{runId}/events/stream",
|
||||
produces = MediaType.TEXT_EVENT_STREAM_VALUE)
|
||||
public SseEmitter streamEvents(
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable String runId,
|
||||
@RequestParam(required = false) Long afterSequence,
|
||||
@RequestHeader(value = "Last-Event-ID", required = false) String lastEventId) {
|
||||
HarnessOwner owner = owner();
|
||||
applicationService.getRun(owner, sessionId, runId);
|
||||
long cursor = resolveCursor(afterSequence, lastEventId);
|
||||
SseEmitter emitter = new SseEmitter(STREAM_TIMEOUT_MILLIS);
|
||||
AtomicReference<HarnessEventSubscription> subscription = new AtomicReference<>();
|
||||
AtomicBoolean transportClosed = new AtomicBoolean(false);
|
||||
|
||||
Runnable closeSubscription = () -> {
|
||||
transportClosed.set(true);
|
||||
HarnessEventSubscription current = subscription.getAndSet(null);
|
||||
if (current != null) {
|
||||
current.close();
|
||||
}
|
||||
};
|
||||
emitter.onCompletion(closeSubscription);
|
||||
emitter.onTimeout(closeSubscription);
|
||||
emitter.onError(ignored -> closeSubscription.run());
|
||||
|
||||
HarnessEventSubscription created = eventHub.subscribe(owner, sessionId, runId, cursor,
|
||||
event -> sendEvent(emitter, event, closeSubscription), error -> {
|
||||
closeSubscription.run();
|
||||
try {
|
||||
emitter.completeWithError(error);
|
||||
} catch (IllegalStateException ignored) {
|
||||
// The client has already disconnected.
|
||||
}
|
||||
});
|
||||
subscription.set(created);
|
||||
if (transportClosed.get() && subscription.compareAndSet(created, null)) {
|
||||
created.close();
|
||||
}
|
||||
return emitter;
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/inputs")
|
||||
public R<HarnessRunState> queueInput(@PathVariable String sessionId, @PathVariable String runId,
|
||||
@Valid @RequestBody QueueInputRequest request) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.queueInput(owner(), sessionId, runId,
|
||||
new QueueHarnessInputCommand(request.kind(), request.content(),
|
||||
request.idempotencyKey())));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/cancel")
|
||||
public R<HarnessRunState> cancel(@PathVariable String sessionId, @PathVariable String runId) {
|
||||
return R.ok(applicationService.cancel(owner(), sessionId, runId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/resume")
|
||||
public R<HarnessRunState> resume(@PathVariable String sessionId, @PathVariable String runId) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.resume(owner(), sessionId, runId));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/approvals/{approvalId}/resolve")
|
||||
@SaCheckPermission("coding:harness:approve")
|
||||
public R<HarnessRunState> resolveApproval(
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable String runId,
|
||||
@PathVariable String approvalId,
|
||||
@Valid @RequestBody ResolveApprovalRequest request) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.resolveToolApproval(owner(), sessionId, runId, approvalId,
|
||||
request.decisionId(), request.decision(), request.expectedRevision(),
|
||||
request.argumentsSha256(), request.note()));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/plan/approve")
|
||||
public R<HarnessRunState> approvePlan(
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable String runId,
|
||||
@Valid @RequestBody ApprovePlanRequest request) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.approvePlan(owner(), sessionId, runId, request.taskId(),
|
||||
request.expectedRevision(), request.expectedHash(), request.idempotencyKey()));
|
||||
}
|
||||
|
||||
@PostMapping("/sessions/{sessionId}/runs/{runId}/plan/revision")
|
||||
public R<HarnessRunState> requestPlanRevision(
|
||||
@PathVariable String sessionId,
|
||||
@PathVariable String runId,
|
||||
@Valid @RequestBody RequestPlanRevisionRequest request) {
|
||||
requireCurrentPermissionCeiling(sessionId);
|
||||
return R.ok(applicationService.requestPlanRevision(owner(), sessionId, runId,
|
||||
request.taskId(), request.expectedRevision(), request.expectedHash(),
|
||||
request.feedbackId(), request.content()));
|
||||
}
|
||||
|
||||
private void sendEvent(SseEmitter emitter, HarnessEvent event, Runnable closeSubscription) {
|
||||
try {
|
||||
emitter.send(SseEmitter.event().id(Long.toString(event.sequence()))
|
||||
.name(event.type()).data(event));
|
||||
if (event.type().equals("run.completed") || event.type().equals("run.failed")
|
||||
|| event.type().equals("run.cancelled")) {
|
||||
closeSubscription.run();
|
||||
emitter.complete();
|
||||
}
|
||||
} catch (IOException | IllegalStateException error) {
|
||||
closeSubscription.run();
|
||||
try {
|
||||
emitter.completeWithError(error);
|
||||
} catch (IllegalStateException ignored) {
|
||||
// The response is already complete.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private long resolveCursor(Long queryCursor, String lastEventId) {
|
||||
long cursor = queryCursor == null ? 0 : queryCursor;
|
||||
if (lastEventId != null && !lastEventId.isBlank()) {
|
||||
try {
|
||||
cursor = Math.max(cursor, Long.parseLong(lastEventId));
|
||||
} catch (NumberFormatException invalid) {
|
||||
throw new IllegalArgumentException("Last-Event-ID must be a non-negative sequence");
|
||||
}
|
||||
}
|
||||
if (cursor < 0) {
|
||||
throw new IllegalArgumentException("Event cursor cannot be negative");
|
||||
}
|
||||
return cursor;
|
||||
}
|
||||
|
||||
private HarnessOwner owner() {
|
||||
return new HarnessOwner(LoginHelper.getTenantId(), LoginHelper.getUserId());
|
||||
}
|
||||
|
||||
private void requireCurrentPermissionCeiling(String sessionId) {
|
||||
HarnessSessionState session = applicationService.getSession(owner(), sessionId);
|
||||
if (session.permissionMode() != HarnessPermissionMode.READ_ONLY) {
|
||||
StpUtil.checkPermission("coding:harness:write");
|
||||
}
|
||||
}
|
||||
|
||||
public record CreateSessionRequest(
|
||||
String workspacePath,
|
||||
@NotBlank @Size(max = 200) String model,
|
||||
@NotNull HarnessPermissionMode permissionMode,
|
||||
HarnessApprovalPolicy approvalPolicy,
|
||||
@Size(max = 200) String title,
|
||||
@NotBlank @Size(max = 256) String idempotencyKey
|
||||
) { }
|
||||
|
||||
public record CreateRunRequest(
|
||||
@NotBlank @Size(max = 200_000) String requirement,
|
||||
@Valid HarnessBudget budget,
|
||||
@NotBlank @Size(max = 256) String idempotencyKey
|
||||
) { }
|
||||
|
||||
public record QueueInputRequest(
|
||||
@NotNull HarnessInputKind kind,
|
||||
@NotBlank @Size(max = 200_000) String content,
|
||||
@NotBlank @Size(max = 256) String idempotencyKey
|
||||
) { }
|
||||
|
||||
public record ResolveApprovalRequest(
|
||||
@NotBlank @Size(max = 256) String decisionId,
|
||||
@NotNull ApprovalDecision decision,
|
||||
@Min(0) long expectedRevision,
|
||||
@NotBlank @Size(min = 64, max = 64) String argumentsSha256,
|
||||
@Size(max = 2_048) String note
|
||||
) { }
|
||||
|
||||
public record ApprovePlanRequest(
|
||||
@NotNull UUID taskId,
|
||||
@Min(0) long expectedRevision,
|
||||
@NotBlank @Size(min = 64, max = 64) String expectedHash,
|
||||
@NotBlank @Size(max = 256) String idempotencyKey
|
||||
) { }
|
||||
|
||||
public record RequestPlanRevisionRequest(
|
||||
@NotNull UUID taskId,
|
||||
@Min(0) long expectedRevision,
|
||||
@NotBlank @Size(min = 64, max = 64) String expectedHash,
|
||||
@NotBlank @Size(max = 256) String feedbackId,
|
||||
@NotBlank @Size(max = 20_000) String content
|
||||
) { }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package org.ruoyi.controller.coding;
|
||||
|
||||
import org.ruoyi.common.core.domain.R;
|
||||
import org.ruoyi.service.coding.harness.app.HarnessConflictException;
|
||||
import org.ruoyi.service.coding.harness.app.HarnessNotFoundException;
|
||||
import org.ruoyi.service.coding.harness.runtime.HarnessSchedulerCapacityException;
|
||||
import org.ruoyi.service.coding.harness.store.HarnessOptimisticLockException;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
@RestControllerAdvice(assignableTypes = CodingHarnessController.class)
|
||||
public class CodingHarnessExceptionHandler {
|
||||
|
||||
@ExceptionHandler(HarnessNotFoundException.class)
|
||||
public ResponseEntity<R<Void>> notFound(HarnessNotFoundException error) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(R.fail(HttpStatus.NOT_FOUND.value(), error.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HarnessConflictException.class)
|
||||
public ResponseEntity<R<Void>> conflict(HarnessConflictException error) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(R.fail(HttpStatus.CONFLICT.value(), error.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HarnessSchedulerCapacityException.class)
|
||||
public ResponseEntity<R<Void>> tooManyRequests(HarnessSchedulerCapacityException error) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header("Retry-After", "5")
|
||||
.body(R.fail(HttpStatus.TOO_MANY_REQUESTS.value(), error.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(HarnessOptimisticLockException.class)
|
||||
public ResponseEntity<R<Void>> optimisticConflict(HarnessOptimisticLockException error) {
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(R.fail(HttpStatus.CONFLICT.value(), error.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<R<Void>> badRequest(IllegalArgumentException error) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(R.fail(HttpStatus.BAD_REQUEST.value(), error.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package org.ruoyi.service.chat;
|
||||
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.ruoyi.domain.vo.chat.ChatSessionVo;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Fail-closed ownership check shared by HTTP and WebSocket chat entry points. */
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class ChatSessionOwnershipGuard {
|
||||
|
||||
private final IChatSessionService chatSessionService;
|
||||
|
||||
public void requireOwned(Long userId, Long sessionId) {
|
||||
if (userId == null || userId <= 0 || sessionId == null || sessionId <= 0) {
|
||||
throw new IllegalArgumentException("An authenticated chat session is required");
|
||||
}
|
||||
ChatSessionVo session = chatSessionService.queryById(sessionId);
|
||||
if (session == null || !userId.equals(session.getUserId())) {
|
||||
// Do not reveal whether a guessed identifier exists.
|
||||
throw new IllegalArgumentException("Chat session is unavailable");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,11 +8,6 @@ import dev.langchain4j.data.message.AiMessage;
|
||||
import dev.langchain4j.data.message.ChatMessage;
|
||||
import dev.langchain4j.data.message.SystemMessage;
|
||||
import dev.langchain4j.data.message.UserMessage;
|
||||
import dev.langchain4j.mcp.McpToolProvider;
|
||||
import dev.langchain4j.mcp.client.DefaultMcpClient;
|
||||
import dev.langchain4j.mcp.client.McpClient;
|
||||
import dev.langchain4j.mcp.client.transport.McpTransport;
|
||||
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
|
||||
import dev.langchain4j.memory.chat.MessageWindowChatMemory;
|
||||
import dev.langchain4j.model.chat.ChatModel;
|
||||
import dev.langchain4j.model.chat.StreamingChatModel;
|
||||
@@ -22,9 +17,6 @@ import dev.langchain4j.model.chat.response.StreamingChatResponseHandler;
|
||||
import dev.langchain4j.rag.content.Content;
|
||||
import dev.langchain4j.rag.content.retriever.ContentRetriever;
|
||||
import dev.langchain4j.service.tool.ToolProvider;
|
||||
import dev.langchain4j.skills.FileSystemSkill;
|
||||
import dev.langchain4j.skills.FileSystemSkillLoader;
|
||||
import dev.langchain4j.skills.shell.ShellSkills;
|
||||
import dev.langchain4j.rag.AugmentationRequest;
|
||||
import dev.langchain4j.rag.AugmentationResult;
|
||||
import dev.langchain4j.rag.DefaultRetrievalAugmentor;
|
||||
@@ -37,7 +29,6 @@ import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.agent.ChartGenerationAgent;
|
||||
import org.ruoyi.agent.ChitChatAgent;
|
||||
import org.ruoyi.agent.EchartsAgent;
|
||||
import org.ruoyi.agent.SkillsAgent;
|
||||
import org.ruoyi.agent.SqlAgent;
|
||||
import org.ruoyi.agent.WebSearchAgent;
|
||||
import org.ruoyi.agent.tool.ExecuteSqlQueryTool;
|
||||
@@ -66,7 +57,6 @@ import org.ruoyi.common.trace.domain.TraceNode;
|
||||
import org.ruoyi.common.trace.domain.TraceRun;
|
||||
import org.ruoyi.common.trace.service.TraceRecordService;
|
||||
import org.ruoyi.common.trace.util.TracePayloadUtils;
|
||||
import org.ruoyi.config.agent.SkillsPathResolver;
|
||||
import org.ruoyi.domain.bo.vector.QueryVectorBo;
|
||||
import org.ruoyi.domain.vo.agent.AgentVo;
|
||||
import org.ruoyi.domain.vo.knowledge.KnowledgeInfoVo;
|
||||
@@ -75,6 +65,7 @@ import org.ruoyi.mcp.service.core.LangChain4jMcpToolProviderService;
|
||||
import org.ruoyi.observability.*;
|
||||
import org.ruoyi.service.agent.IAgentService;
|
||||
import org.ruoyi.service.chat.AbstractChatService;
|
||||
import org.ruoyi.service.chat.ChatSessionOwnershipGuard;
|
||||
import org.ruoyi.service.chat.IChatMessageService;
|
||||
import org.ruoyi.service.chat.impl.memory.PersistentChatMemoryStore;
|
||||
import org.ruoyi.service.knowledge.IKnowledgeInfoService;
|
||||
@@ -124,6 +115,8 @@ public class ChatServiceFacade implements IChatService {
|
||||
|
||||
private final IChatMessageService chatMessageService;
|
||||
|
||||
private final ChatSessionOwnershipGuard chatSessionOwnershipGuard;
|
||||
|
||||
private final IWorkFlowStarterService workFlowStarterService;
|
||||
|
||||
|
||||
@@ -152,6 +145,7 @@ public class ChatServiceFacade implements IChatService {
|
||||
public SseEmitter sseChat(ChatRequest chatRequest) {
|
||||
Long userId = LoginHelper.getUserId();
|
||||
String tokenValue = StpUtil.getTokenValue();
|
||||
chatSessionOwnershipGuard.requireOwned(userId, chatRequest.getSessionId());
|
||||
|
||||
boolean workflowMode = Boolean.TRUE.equals(chatRequest.getEnableWorkFlow());
|
||||
boolean agentMode = chatRequest.getAgentId() != null;
|
||||
@@ -288,34 +282,28 @@ public class ChatServiceFacade implements IChatService {
|
||||
Long userId = chatRequest.getUserId();
|
||||
String sessionId = String.valueOf(chatRequest.getSessionId());
|
||||
|
||||
// 工具装配:智能体有关联工具ID时按ID装配,否则回退到原有硬编码 MCP 客户端
|
||||
ToolProvider toolProvider;
|
||||
// Only explicitly configured legacy MCP tools are installed. The old implicit
|
||||
// Playwright/filesystem fallback bypassed Harness leases and approvals.
|
||||
ToolProvider toolProvider = null;
|
||||
if (agentVo != null && agentVo.getMcpToolIds() != null && !agentVo.getMcpToolIds().isEmpty()) {
|
||||
toolProvider = langChain4jMcpToolProviderService.getToolProvider(agentVo.getMcpToolIds());
|
||||
} else {
|
||||
toolProvider = buildDefaultMcpToolProvider(sessionId);
|
||||
}
|
||||
|
||||
// Skills 装配:智能体有勾选技能名时按名过滤磁盘 skills,否则加载全部
|
||||
ShellSkills skills = buildShellSkills(agentVo);
|
||||
|
||||
// 构建子 Agent
|
||||
WebSearchAgent searchAgent = AgenticServices.agentBuilder(WebSearchAgent.class)
|
||||
var searchAgentBuilder = AgenticServices.agentBuilder(WebSearchAgent.class)
|
||||
.chatModel(plannerModel)
|
||||
.toolProvider(toolProvider)
|
||||
.listener(new MyAgentListener())
|
||||
.build();
|
||||
|
||||
// SkillsAgent:仅当有可用 skills 时才注入 systemMessage + toolProvider
|
||||
var skillsAgentBuilder = AgenticServices.agentBuilder(SkillsAgent.class)
|
||||
.chatModel(plannerModel);
|
||||
if (skills != null) {
|
||||
skillsAgentBuilder
|
||||
.systemMessage("You have access to the following skills:\n" + skills.formatAvailableSkills()
|
||||
+ "\nWhen the user's request relates to one of these skills, activate it first using the `activate_skill` tool before proceeding.")
|
||||
.toolProvider(skills.toolProvider());
|
||||
.listener(new MyAgentListener());
|
||||
if (toolProvider != null) {
|
||||
searchAgentBuilder.toolProvider(toolProvider);
|
||||
}
|
||||
WebSearchAgent searchAgent = searchAgentBuilder.build();
|
||||
|
||||
// Disk skills previously exposed unrestricted run_shell_command. Coding skills now live
|
||||
// behind the Harness catalog, policy engine, and per-call approval flow.
|
||||
if (agentVo != null && agentVo.getSkillNames() != null
|
||||
&& !agentVo.getSkillNames().isEmpty()) {
|
||||
log.warn("Legacy shell-backed skills are disabled; use the coding Harness skill runtime");
|
||||
}
|
||||
SkillsAgent skillsAgent = skillsAgentBuilder.build();
|
||||
|
||||
// 构建子 Agent 3: SqlAgent - 负责数据库查询
|
||||
SqlAgent sqlAgent = AgenticServices.agentBuilder(SqlAgent.class)
|
||||
@@ -345,7 +333,7 @@ public class ChatServiceFacade implements IChatService {
|
||||
// 构建监督者 Agent - 管理多个子 Agent
|
||||
var supervisorBuilder = AgenticServices.supervisorBuilder()
|
||||
.chatModel(plannerModel)
|
||||
.subAgents(skillsAgent, searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, chitChatAgent)
|
||||
.subAgents(searchAgent, sqlAgent, chartGenerationAgent, echartsAgent, chitChatAgent)
|
||||
.supervisorContext("仅当请求是问候或简单闲聊、不需要任何数据、搜索、技能或图表时,才使用 chitChatAgent;"
|
||||
+ "其余情况必须使用对应的专业 Agent")
|
||||
.responseStrategy(SupervisorResponseStrategy.LAST);
|
||||
@@ -503,67 +491,6 @@ public class ChatServiceFacade implements IChatService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底 MCP 工具装配(无智能体时使用,保留原有 3 个硬编码客户端逻辑)
|
||||
*
|
||||
* @param sessionId 会话ID,用于 MCP 工具事件按会话推送 SSE
|
||||
*/
|
||||
private ToolProvider buildDefaultMcpToolProvider(String sessionId) {
|
||||
String npxCommand = resolveNpxCommand();
|
||||
McpTransport playwrightTransport = new StdioMcpTransport.Builder()
|
||||
.command(List.of(npxCommand, "-y", "@playwright/mcp@latest"))
|
||||
.logEvents(true)
|
||||
.build();
|
||||
McpClient playwrightMcpClient = new DefaultMcpClient.Builder()
|
||||
.transport(playwrightTransport)
|
||||
.listener(new MyMcpClientListener(sessionId))
|
||||
.build();
|
||||
|
||||
String userDir = System.getProperty("user.dir");
|
||||
McpTransport filesystemTransport = new StdioMcpTransport.Builder()
|
||||
.command(List.of(npxCommand, "-y",
|
||||
"@modelcontextprotocol/server-filesystem", userDir))
|
||||
.logEvents(true)
|
||||
.build();
|
||||
McpClient filesystemMcpClient = new DefaultMcpClient.Builder()
|
||||
.transport(filesystemTransport)
|
||||
.listener(new MyMcpClientListener(sessionId))
|
||||
.build();
|
||||
|
||||
return McpToolProvider.builder()
|
||||
.mcpClients(List.of(playwrightMcpClient, filesystemMcpClient))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveNpxCommand() {
|
||||
String configured = System.getProperty("mcp.npx.command");
|
||||
if (StringUtils.isNotBlank(configured)) return configured;
|
||||
String fromEnv = System.getenv("MCP_NPX_COMMAND");
|
||||
if (StringUtils.isNotBlank(fromEnv)) return fromEnv;
|
||||
return System.getProperty("os.name", "").toLowerCase().contains("win") ? "npx.cmd" : "npx";
|
||||
}
|
||||
|
||||
/**
|
||||
* 装配磁盘 ShellSkills:智能体勾选了技能名时按名过滤,否则加载全部。
|
||||
* 无 skills 时返回 null(调用方据此跳过 SkillsAgent 的 toolProvider 注入)
|
||||
*/
|
||||
private ShellSkills buildShellSkills(AgentVo agentVo) {
|
||||
java.nio.file.Path skillsPath = SkillsPathResolver.resolveSkillsPath();
|
||||
List<FileSystemSkill> skillsList = FileSystemSkillLoader.loadSkills(skillsPath);
|
||||
if (skillsList == null || skillsList.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (agentVo != null && agentVo.getSkillNames() != null && !agentVo.getSkillNames().isEmpty()) {
|
||||
skillsList = skillsList.stream()
|
||||
.filter(s -> agentVo.getSkillNames().contains(s.name()))
|
||||
.toList();
|
||||
if (skillsList.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return ShellSkills.from(skillsList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 智能体对话下的输入增强:智能体绑定知识库时,对原始 content 做多知识库 RAG 增强。
|
||||
* 无知识库时原样返回 content。
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.ruoyi.service.coding;
|
||||
|
||||
import org.ruoyi.mcp.tools.ExecuteCommandTool;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -9,7 +11,10 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.StandardOpenOption;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.Comparator;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
|
||||
/** Basic, workspace-scoped file and command operations for the Copilot UI. */
|
||||
@@ -20,6 +25,21 @@ public class CodingWorkspaceService {
|
||||
private static final long MAX_FILE_BYTES = 1024 * 1024;
|
||||
private static final int MAX_ENTRIES = 500;
|
||||
|
||||
@Value("${coding.harness.workspace.owner-root:./data/coding-workspaces}")
|
||||
private String harnessOwnerRoot = "./data/coding-workspaces";
|
||||
|
||||
/** Empty by default. Setting this is an explicit operator opt-in to a shared repository. */
|
||||
@Value("${coding.harness.workspace.shared-root:}")
|
||||
private String harnessSharedRoot = "";
|
||||
|
||||
public CodingWorkspaceService() {
|
||||
}
|
||||
|
||||
CodingWorkspaceService(Path ownerRoot, Path sharedRoot) {
|
||||
this.harnessOwnerRoot = ownerRoot.toString();
|
||||
this.harnessSharedRoot = sharedRoot == null ? "" : sharedRoot.toString();
|
||||
}
|
||||
|
||||
public WorkspaceResult list(String workspacePath) throws IOException {
|
||||
Path root = resolveRoot(workspacePath);
|
||||
Files.createDirectories(root);
|
||||
@@ -76,11 +96,67 @@ public class CodingWorkspaceService {
|
||||
if (workspacePath == null || workspacePath.isBlank()) return configured;
|
||||
Path requested = Paths.get(workspacePath).toAbsolutePath().normalize();
|
||||
if (!requested.equals(configured)) {
|
||||
throw new IllegalArgumentException("Workspace is not allowed: " + requested);
|
||||
throw new IllegalArgumentException(
|
||||
"Workspace is not allowed. Allowed workspace: " + configured + "; requested: " + requested);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a Harness lease from authenticated ownership, never from a caller-selected path.
|
||||
* A shared root is available only when the operator explicitly configures one; otherwise each
|
||||
* tenant/user receives a physically distinct directory beneath the owner root.
|
||||
*/
|
||||
public Path resolveHarnessRoot(HarnessOwner owner, String requestedPath) {
|
||||
if (owner == null) {
|
||||
throw new IllegalArgumentException("Harness owner is required");
|
||||
}
|
||||
Path configuredShared = harnessSharedRoot == null || harnessSharedRoot.isBlank()
|
||||
? null : Paths.get(harnessSharedRoot).toAbsolutePath().normalize();
|
||||
Path expected;
|
||||
if (configuredShared != null) {
|
||||
expected = configuredShared;
|
||||
} else {
|
||||
Path ownerBase = Paths.get(harnessOwnerRoot).toAbsolutePath().normalize();
|
||||
expected = ownerBase.resolve(tenantKey(owner.tenantId()))
|
||||
.resolve(Long.toString(owner.userId())).normalize();
|
||||
if (!expected.startsWith(ownerBase)) {
|
||||
throw new IllegalArgumentException("Owner workspace escaped its configured root");
|
||||
}
|
||||
}
|
||||
if (requestedPath != null && !requestedPath.isBlank()) {
|
||||
Path requested = Paths.get(requestedPath).toAbsolutePath().normalize();
|
||||
if (!requested.equals(expected)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Workspace lease is not owned by the authenticated principal");
|
||||
}
|
||||
}
|
||||
try {
|
||||
Path boundary = configuredShared != null
|
||||
? configuredShared : Paths.get(harnessOwnerRoot).toAbsolutePath().normalize();
|
||||
Files.createDirectories(boundary);
|
||||
Files.createDirectories(expected);
|
||||
Path boundaryReal = boundary.toRealPath();
|
||||
Path expectedReal = expected.toRealPath();
|
||||
if (!expectedReal.startsWith(boundaryReal)) {
|
||||
throw new IllegalArgumentException("Workspace lease resolves outside its boundary");
|
||||
}
|
||||
return expectedReal;
|
||||
} catch (IOException error) {
|
||||
throw new IllegalArgumentException("Cannot provision Harness workspace lease", error);
|
||||
}
|
||||
}
|
||||
|
||||
private String tenantKey(String tenantId) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
byte[] hash = digest.digest(tenantId.getBytes(StandardCharsets.UTF_8));
|
||||
return "t_" + HexFormat.of().formatHex(hash, 0, 12);
|
||||
} catch (NoSuchAlgorithmException impossible) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", impossible);
|
||||
}
|
||||
}
|
||||
|
||||
private Path resolveFile(Path root, String relativePath) {
|
||||
if (relativePath == null || relativePath.isBlank()) {
|
||||
throw new IllegalArgumentException("File path cannot be empty");
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package org.ruoyi.service.coding;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.LinkOption;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
@@ -11,9 +13,9 @@ import java.nio.file.Path;
|
||||
*
|
||||
* <p>实现要点:
|
||||
* <ul>
|
||||
* <li>{@code root.toRealPath()} 解析符号链接(防软链接逃逸)</li>
|
||||
* <li>{@code target.normalize()} 消除 {@code ..} 穿越段</li>
|
||||
* <li>{@code startsWith} 是 Path 段前缀匹配,非字符串前缀({@code /workspace/abc} 不会误判成 {@code /workspace-evil})</li>
|
||||
* <li>词法范围检查拦截外部绝对路径和 {@code ..} 穿越</li>
|
||||
* <li>现存目标直接校验其真实路径</li>
|
||||
* <li>待创建目标校验最近的现存父链,拦截软链接、junction 或 reparse point 逃逸</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author ageerle
|
||||
@@ -31,20 +33,45 @@ public final class WorkspaceGuard {
|
||||
* @return true 表示在 workspace 内,安全
|
||||
*/
|
||||
public static boolean isWithinWorkspace(Path root, Path target) {
|
||||
if (root == null || target == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
Path realRoot = root.toRealPath().normalize();
|
||||
Path realTarget = target.normalize();
|
||||
return realTarget.startsWith(realRoot);
|
||||
} catch (IOException e) {
|
||||
// 目标路径不存在或无法解析(如新建文件前其父目录链中有不存在的段)
|
||||
// 退化为 normalize 后做段前缀匹配,仍能拦住明显的越界
|
||||
try {
|
||||
Path realRoot = root.toRealPath().normalize();
|
||||
Path normalizedTarget = target.normalize();
|
||||
return normalizedTarget.startsWith(realRoot);
|
||||
} catch (IOException ignore) {
|
||||
Path normalizedRoot = root.toAbsolutePath().normalize();
|
||||
Path normalizedTarget = target.isAbsolute()
|
||||
? target.toAbsolutePath().normalize()
|
||||
: normalizedRoot.resolve(target).normalize();
|
||||
|
||||
// 先用调用方看到的 workspace 路径做词法边界检查。
|
||||
if (!normalizedTarget.startsWith(normalizedRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
Path realRoot = normalizedRoot.toRealPath().normalize();
|
||||
Path nearestExisting = nearestExistingAncestor(normalizedTarget);
|
||||
Path realExisting = nearestExisting.toRealPath().normalize();
|
||||
return realExisting.startsWith(realRoot);
|
||||
} catch (IOException | SecurityException e) {
|
||||
// 无法确定真实路径时必须 fail closed,不再退化为纯字符串判断。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找目标或其父链中最近的现存路径。
|
||||
*
|
||||
* <p>{@link LinkOption#NOFOLLOW_LINKS} 可识别悬空软链接;随后的
|
||||
* {@code toRealPath()} 会解析软链接以及 Windows junction/reparse point。
|
||||
*/
|
||||
private static Path nearestExistingAncestor(Path target) throws IOException {
|
||||
Path current = target;
|
||||
while (current != null) {
|
||||
if (Files.exists(current, LinkOption.NOFOLLOW_LINKS)) {
|
||||
return current;
|
||||
}
|
||||
current = current.getParent();
|
||||
}
|
||||
throw new IOException("Target has no existing ancestor: " + target);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessBudget;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateHarnessRunCommand(String requirement, HarnessBudget budget,
|
||||
String idempotencyKey) {
|
||||
|
||||
public CreateHarnessRunCommand(String requirement, HarnessBudget budget) {
|
||||
this(requirement, budget, UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public CreateHarnessRunCommand {
|
||||
if (requirement == null || requirement.isBlank()) {
|
||||
throw new IllegalArgumentException("requirement is required");
|
||||
}
|
||||
if (idempotencyKey == null || idempotencyKey.isBlank()
|
||||
|| idempotencyKey.length() > 256) {
|
||||
throw new IllegalArgumentException("idempotencyKey is required and must not exceed 256 characters");
|
||||
}
|
||||
idempotencyKey = idempotencyKey.strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessApprovalPolicy;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record CreateHarnessSessionCommand(
|
||||
String workspacePath,
|
||||
String model,
|
||||
HarnessPermissionMode permissionMode,
|
||||
HarnessApprovalPolicy approvalPolicy,
|
||||
String title,
|
||||
String idempotencyKey
|
||||
) {
|
||||
|
||||
public CreateHarnessSessionCommand(String workspacePath, String model,
|
||||
HarnessPermissionMode permissionMode, String title) {
|
||||
this(workspacePath, model, permissionMode, HarnessApprovalPolicy.ON_REQUEST,
|
||||
title, UUID.randomUUID().toString());
|
||||
}
|
||||
|
||||
public CreateHarnessSessionCommand(String workspacePath, String model,
|
||||
HarnessPermissionMode permissionMode, String title,
|
||||
String idempotencyKey) {
|
||||
this(workspacePath, model, permissionMode, HarnessApprovalPolicy.ON_REQUEST,
|
||||
title, idempotencyKey);
|
||||
}
|
||||
|
||||
public CreateHarnessSessionCommand {
|
||||
approvalPolicy = approvalPolicy == null ? HarnessApprovalPolicy.ON_REQUEST : approvalPolicy;
|
||||
if (idempotencyKey == null || idempotencyKey.isBlank()
|
||||
|| idempotencyKey.length() > 256) {
|
||||
throw new IllegalArgumentException("idempotencyKey is required and must not exceed 256 characters");
|
||||
}
|
||||
idempotencyKey = idempotencyKey.strip();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessBudget;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Server-authoritative ceiling; zero leaves a token dimension unbounded. */
|
||||
@Component
|
||||
public final class HarnessBudgetPolicy {
|
||||
|
||||
private static final HarnessBudget LEGACY_DEFAULT =
|
||||
new HarnessBudget(80, 240, 0, 100_000, 6 * 60 * 60 * 1000L);
|
||||
|
||||
private final HarnessBudget ceiling;
|
||||
|
||||
@Autowired
|
||||
public HarnessBudgetPolicy(
|
||||
@Value("${coding.harness.budget.max-iterations:200}") int maxIterations,
|
||||
@Value("${coding.harness.budget.max-tool-calls:600}") int maxToolCalls,
|
||||
@Value("${coding.harness.budget.max-input-tokens:0}") long maxInputTokens,
|
||||
@Value("${coding.harness.budget.max-output-tokens:100000}") long maxOutputTokens,
|
||||
@Value("${coding.harness.budget.max-wall-time-millis:21600000}") long maxWallTimeMillis
|
||||
) {
|
||||
this(new HarnessBudget(maxIterations, maxToolCalls, maxInputTokens, maxOutputTokens,
|
||||
maxWallTimeMillis));
|
||||
if (maxOutputTokens == 0) {
|
||||
throw new IllegalArgumentException("Server Harness output-token ceiling must be finite");
|
||||
}
|
||||
}
|
||||
|
||||
public HarnessBudgetPolicy(HarnessBudget ceiling) {
|
||||
if (ceiling == null || ceiling.maxOutputTokens() == 0) {
|
||||
throw new IllegalArgumentException("A finite Harness output-token ceiling is required");
|
||||
}
|
||||
this.ceiling = ceiling;
|
||||
}
|
||||
|
||||
public static HarnessBudgetPolicy secureDefaults() {
|
||||
return new HarnessBudgetPolicy(HarnessBudget.defaults());
|
||||
}
|
||||
|
||||
public HarnessBudget enforce(HarnessBudget requested) {
|
||||
HarnessBudget value = requested == null ? HarnessBudget.defaults() : requested;
|
||||
return new HarnessBudget(
|
||||
Math.min(value.maxIterations(), ceiling.maxIterations()),
|
||||
Math.min(value.maxToolCalls(), ceiling.maxToolCalls()),
|
||||
bounded(value.maxInputTokens(), ceiling.maxInputTokens()),
|
||||
bounded(value.maxOutputTokens(), ceiling.maxOutputTokens()),
|
||||
Math.min(value.maxWallTimeMillis(), ceiling.maxWallTimeMillis()));
|
||||
}
|
||||
|
||||
/** Upgrades only the former server default; explicitly smaller custom budgets stay smaller. */
|
||||
public HarnessBudget forFollowUp(HarnessBudget inherited) {
|
||||
return enforce(LEGACY_DEFAULT.equals(inherited) ? null : inherited);
|
||||
}
|
||||
|
||||
private long bounded(long requested, long maximum) {
|
||||
if (maximum == 0) {
|
||||
return 0;
|
||||
}
|
||||
return requested == 0 ? maximum : Math.min(requested, maximum);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
public class HarnessConflictException extends RuntimeException {
|
||||
public HarnessConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public HarnessConflictException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
public class HarnessNotFoundException extends RuntimeException {
|
||||
public HarnessNotFoundException(String resource, String id) {
|
||||
super("Unknown Harness " + resource + ": " + id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.ruoyi.service.coding.harness.app;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessInputKind;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public record QueueHarnessInputCommand(HarnessInputKind kind, String content,
|
||||
String idempotencyKey) {
|
||||
public QueueHarnessInputCommand {
|
||||
if (kind == null || kind == HarnessInputKind.INITIAL || content == null || content.isBlank()
|
||||
|| idempotencyKey == null || idempotencyKey.isBlank()
|
||||
|| idempotencyKey.length() > 256) {
|
||||
throw new IllegalArgumentException("A queued input must be STEER or FOLLOW_UP and have content");
|
||||
}
|
||||
content = content.strip();
|
||||
idempotencyKey = idempotencyKey.strip();
|
||||
}
|
||||
|
||||
public QueueHarnessInputCommand(HarnessInputKind kind, String content) {
|
||||
this(kind, content, UUID.randomUUID().toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** The exact invocation arguments no longer match the approved digest. */
|
||||
public class ApprovalArgumentsMismatchException extends SecurityException {
|
||||
|
||||
public ApprovalArgumentsMismatchException() {
|
||||
super("Tool arguments do not match the approval-bound SHA-256 digest");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
|
||||
|
||||
/** Persisted receipt proving which worker consumed the one-shot approval. */
|
||||
public record ApprovalClaimReceipt(
|
||||
String claimId,
|
||||
String workerId,
|
||||
long expectedRevision,
|
||||
long consumedRevision,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision,
|
||||
long consumedAt
|
||||
) {
|
||||
|
||||
public ApprovalClaimReceipt {
|
||||
claimId = ApprovalValidation.requireId(claimId, "claimId");
|
||||
workerId = ApprovalValidation.requireId(workerId, "workerId");
|
||||
if (expectedRevision < 0 || consumedRevision != expectedRevision + 1 || owner == null
|
||||
|| permissionMode == null || permissionRevision < 0 || consumedAt <= 0) {
|
||||
throw new IllegalArgumentException("Invalid approval claim receipt");
|
||||
}
|
||||
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
|
||||
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
|
||||
}
|
||||
|
||||
public boolean matches(ClaimApprovalCommand command) {
|
||||
return command != null
|
||||
&& claimId.equals(command.claimId())
|
||||
&& workerId.equals(command.workerId())
|
||||
&& expectedRevision == command.expectedRevision()
|
||||
&& argumentsSha256.equals(command.argumentsSha256())
|
||||
&& owner.equals(command.owner())
|
||||
&& sessionId.equals(command.sessionId())
|
||||
&& permissionMode == command.permissionMode()
|
||||
&& permissionRevision == command.permissionRevision();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** A command id or one-shot approval was already used by a different payload. */
|
||||
public class ApprovalConflictException extends IllegalStateException {
|
||||
|
||||
public ApprovalConflictException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Human control-plane decision. A decision never executes the tool. */
|
||||
public enum ApprovalDecision {
|
||||
APPROVE,
|
||||
DENY
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Persisted idempotency receipt for an HTTP/control-plane decision. */
|
||||
public record ApprovalDecisionReceipt(
|
||||
String decisionId,
|
||||
ApprovalDecision decision,
|
||||
long expectedRevision,
|
||||
long resolvedRevision,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
String note,
|
||||
long resolvedAt
|
||||
) {
|
||||
|
||||
public ApprovalDecisionReceipt {
|
||||
decisionId = ApprovalValidation.requireId(decisionId, "decisionId");
|
||||
if (decision == null || expectedRevision < 0 || resolvedRevision != expectedRevision + 1
|
||||
|| owner == null || resolvedAt <= 0) {
|
||||
throw new IllegalArgumentException("Invalid approval decision receipt");
|
||||
}
|
||||
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
|
||||
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
|
||||
note = ApprovalValidation.normalizeOptional(note);
|
||||
}
|
||||
|
||||
public boolean matches(ResolveApprovalCommand command) {
|
||||
return command != null
|
||||
&& decisionId.equals(command.decisionId())
|
||||
&& decision == command.decision()
|
||||
&& expectedRevision == command.expectedRevision()
|
||||
&& argumentsSha256.equals(command.argumentsSha256())
|
||||
&& owner.equals(command.owner())
|
||||
&& sessionId.equals(command.sessionId())
|
||||
&& Objects.equals(note, command.note());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** The tool approval deadline has passed and execution is denied. */
|
||||
public class ApprovalExpiredException extends IllegalStateException {
|
||||
|
||||
public ApprovalExpiredException() {
|
||||
super(ApprovalOutcomeReason.EXPIRED.code());
|
||||
}
|
||||
|
||||
public ApprovalOutcomeReason reason() {
|
||||
return ApprovalOutcomeReason.EXPIRED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Stable machine-readable reasons used to synthesize a fail-closed tool outcome. */
|
||||
public enum ApprovalOutcomeReason {
|
||||
DENIED("approval_denied", "Tool call was denied before execution."),
|
||||
EXPIRED("approval_expired", "Tool approval expired before execution."),
|
||||
PERMISSION_CHANGED("approval_permission_changed",
|
||||
"Tool approval no longer matches the active permission snapshot.");
|
||||
|
||||
private final String code;
|
||||
private final String message;
|
||||
|
||||
ApprovalOutcomeReason(String code, String message) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public String code() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public String message() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Owner/session context does not match the approval authority boundary. */
|
||||
public class ApprovalOwnershipException extends SecurityException {
|
||||
|
||||
public ApprovalOwnershipException() {
|
||||
super("Tool approval owner or session does not match");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** A worker's current permission snapshot differs from the snapshot awaiting approval. */
|
||||
public class ApprovalPermissionChangedException extends SecurityException {
|
||||
|
||||
public ApprovalPermissionChangedException() {
|
||||
super(ApprovalOutcomeReason.PERMISSION_CHANGED.code());
|
||||
}
|
||||
|
||||
public ApprovalOutcomeReason reason() {
|
||||
return ApprovalOutcomeReason.PERMISSION_CHANGED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Authoritative lifecycle of one exact tool invocation approval. */
|
||||
public enum ApprovalState {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
DENIED,
|
||||
EXPIRED,
|
||||
CONSUMED;
|
||||
|
||||
public boolean isTerminal() {
|
||||
return this == DENIED || this == EXPIRED || this == CONSUMED;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Stable synthetic error result for a denied or expired invocation. */
|
||||
public record ApprovalSyntheticOutcome(
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
ApprovalState state,
|
||||
String reason,
|
||||
String message
|
||||
) {
|
||||
|
||||
public ApprovalSyntheticOutcome {
|
||||
if (toolCallId == null || toolCallId.isBlank() || toolName == null || toolName.isBlank()
|
||||
|| state == null || reason == null || reason.isBlank()
|
||||
|| message == null || message.isBlank()) {
|
||||
throw new IllegalArgumentException("Invalid synthetic approval outcome");
|
||||
}
|
||||
if (state != ApprovalState.DENIED && state != ApprovalState.EXPIRED) {
|
||||
throw new IllegalArgumentException("Synthetic outcome requires DENIED or EXPIRED state");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
final class ApprovalValidation {
|
||||
|
||||
private static final Pattern SHA_256 = Pattern.compile("[0-9a-f]{64}");
|
||||
|
||||
private ApprovalValidation() {
|
||||
}
|
||||
|
||||
static String requireId(String value, String field) {
|
||||
if (value == null || value.isBlank() || value.length() > 256) {
|
||||
throw new IllegalArgumentException(field + " must be a non-blank bounded id");
|
||||
}
|
||||
String normalized = value.strip();
|
||||
for (int i = 0; i < normalized.length(); i++) {
|
||||
char character = normalized.charAt(i);
|
||||
if (Character.isISOControl(character)) {
|
||||
throw new IllegalArgumentException(field + " must not contain control characters");
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
static String requireSha256(String value) {
|
||||
if (value == null || !SHA_256.matcher(value).matches()) {
|
||||
throw new IllegalArgumentException(
|
||||
"argumentsSha256 must be an exact lowercase SHA-256 digest");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
static String normalizeOptional(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String normalized = value.strip();
|
||||
if (normalized.length() > 2_048) {
|
||||
throw new IllegalArgumentException("Approval note exceeds its limit");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
|
||||
|
||||
/** Worker-only one-shot claim that binds execution to the current security snapshot. */
|
||||
public record ClaimApprovalCommand(
|
||||
String claimId,
|
||||
String workerId,
|
||||
long expectedRevision,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision
|
||||
) {
|
||||
|
||||
public ClaimApprovalCommand {
|
||||
claimId = ApprovalValidation.requireId(claimId, "claimId");
|
||||
workerId = ApprovalValidation.requireId(workerId, "workerId");
|
||||
if (expectedRevision < 0 || owner == null || permissionMode == null
|
||||
|| permissionRevision < 0) {
|
||||
throw new IllegalArgumentException("Invalid approval claim command");
|
||||
}
|
||||
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
|
||||
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.ruoyi.service.coding.harness.event.HarnessEventHub;
|
||||
import org.ruoyi.service.coding.harness.event.HarnessEventOutboxService;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessApproval;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessApprovalStatus;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessEvent;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessRunState;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessRunStatus;
|
||||
import org.ruoyi.service.coding.harness.runtime.HarnessRunRequest;
|
||||
import org.ruoyi.service.coding.harness.runtime.HarnessScheduler;
|
||||
import org.ruoyi.service.coding.harness.runtime.HarnessSessionGate;
|
||||
import org.ruoyi.service.coding.harness.store.HarnessRunScanPage;
|
||||
import org.ruoyi.service.coding.harness.store.HarnessStore;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Expires abandoned approval waits and continuously redispatches durable runnable work. The
|
||||
* rotating cursor also completes a bounded startup-recovery scan without retaining an unbounded
|
||||
* in-memory continuation.
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public final class HarnessApprovalExpiryMonitor {
|
||||
|
||||
private final HarnessStore store;
|
||||
private final HarnessScheduler scheduler;
|
||||
private final HarnessSessionGate sessionGate;
|
||||
private final HarnessEventHub eventHub;
|
||||
private final HarnessEventOutboxService eventOutboxService;
|
||||
private final ScheduledExecutorService maintenance;
|
||||
private final boolean enabled;
|
||||
private final long intervalMillis;
|
||||
private final int pageSize;
|
||||
private final int maxRuns;
|
||||
private final AtomicBoolean started = new AtomicBoolean();
|
||||
private volatile ScheduledFuture<?> future;
|
||||
private volatile String scanCursor;
|
||||
|
||||
@Autowired
|
||||
public HarnessApprovalExpiryMonitor(
|
||||
HarnessStore store,
|
||||
HarnessScheduler scheduler,
|
||||
HarnessSessionGate sessionGate,
|
||||
HarnessEventHub eventHub,
|
||||
HarnessEventOutboxService eventOutboxService,
|
||||
@Qualifier("codingHarnessMaintenanceScheduler") ScheduledExecutorService maintenance,
|
||||
@Value("${coding.harness.approvals.expiry-monitor.enabled:true}") boolean enabled,
|
||||
@Value("${coding.harness.approvals.expiry-monitor.interval-millis:5000}")
|
||||
long intervalMillis,
|
||||
@Value("${coding.harness.approvals.expiry-monitor.page-size:64}") int pageSize,
|
||||
@Value("${coding.harness.approvals.expiry-monitor.max-runs:1000}") int maxRuns
|
||||
) {
|
||||
if (intervalMillis < 100 || pageSize < 1 || pageSize > 1_000 || maxRuns < 1) {
|
||||
throw new IllegalArgumentException("Invalid approval expiry monitor limits");
|
||||
}
|
||||
this.store = store;
|
||||
this.scheduler = scheduler;
|
||||
this.sessionGate = sessionGate;
|
||||
this.eventHub = eventHub;
|
||||
this.eventOutboxService = eventOutboxService;
|
||||
this.maintenance = maintenance;
|
||||
this.enabled = enabled;
|
||||
this.intervalMillis = intervalMillis;
|
||||
this.pageSize = pageSize;
|
||||
this.maxRuns = maxRuns;
|
||||
}
|
||||
|
||||
public HarnessApprovalExpiryMonitor(
|
||||
HarnessStore store,
|
||||
HarnessScheduler scheduler,
|
||||
HarnessSessionGate sessionGate,
|
||||
HarnessEventHub eventHub,
|
||||
ScheduledExecutorService maintenance,
|
||||
boolean enabled,
|
||||
long intervalMillis,
|
||||
int pageSize,
|
||||
int maxRuns
|
||||
) {
|
||||
this(store, scheduler, sessionGate, eventHub,
|
||||
new HarnessEventOutboxService(store, eventHub), maintenance, enabled,
|
||||
intervalMillis, pageSize, maxRuns);
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
@Order(Ordered.LOWEST_PRECEDENCE)
|
||||
public void start() {
|
||||
if (enabled && started.compareAndSet(false, true)) {
|
||||
future = maintenance.scheduleWithFixedDelay(this::sweepSafely, 0,
|
||||
intervalMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
|
||||
private void sweepSafely() {
|
||||
try {
|
||||
sweepOnce();
|
||||
} catch (RuntimeException failure) {
|
||||
// One maintenance failure must not terminate future expiry passes.
|
||||
log.error("Harness approval expiry sweep failed", failure);
|
||||
}
|
||||
}
|
||||
|
||||
/** Visible for deterministic tests and operational probes. */
|
||||
public int sweepOnce() {
|
||||
int scanned = 0;
|
||||
int expired = 0;
|
||||
String cursor = scanCursor;
|
||||
long now = System.currentTimeMillis();
|
||||
while (scanned < maxRuns) {
|
||||
HarnessRunScanPage page = store.scanRunsForRecovery(cursor,
|
||||
Math.min(pageSize, maxRuns - scanned));
|
||||
for (HarnessRunState discovered : page.runs()) {
|
||||
scanned++;
|
||||
HarnessRunState run = eventOutboxService.drainBestEffort(discovered.owner(),
|
||||
discovered);
|
||||
if (run.status() == HarnessRunStatus.WAITING_FOR_APPROVAL) {
|
||||
expired += expireRun(run, now);
|
||||
} else if (run.status() == HarnessRunStatus.QUEUED
|
||||
|| run.status() == HarnessRunStatus.RUNNING) {
|
||||
scheduleRunnable(run);
|
||||
}
|
||||
}
|
||||
if (!page.hasMore() || page.runs().isEmpty()) {
|
||||
scanCursor = null;
|
||||
break;
|
||||
}
|
||||
cursor = page.nextCursor();
|
||||
scanCursor = cursor;
|
||||
if (scanned >= maxRuns) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return expired;
|
||||
}
|
||||
|
||||
private int expireRun(HarnessRunState discovered, long now) {
|
||||
return sessionGate.withSession(discovered.owner(), discovered.sessionId(), () -> {
|
||||
HarnessRunState run = store.findRun(discovered.owner(), discovered.sessionId(),
|
||||
discovered.runId()).orElse(null);
|
||||
if (run == null || run.status() != HarnessRunStatus.WAITING_FOR_APPROVAL) {
|
||||
return 0;
|
||||
}
|
||||
HarnessRunState next = run;
|
||||
int count = 0;
|
||||
for (ToolCallApprovalAggregate approval : run.toolApprovals().values()) {
|
||||
if ((approval.state() == ApprovalState.PENDING
|
||||
|| approval.state() == ApprovalState.APPROVED)
|
||||
&& now >= approval.expiresAt()) {
|
||||
ToolCallApprovalAggregate expired = approval.expire(now);
|
||||
next = next.withToolApproval(expired, now);
|
||||
HarnessApproval preview = next.approvals().get(approval.approvalId());
|
||||
if (preview != null) {
|
||||
next = next.withApproval(new HarnessApproval(preview.approvalId(),
|
||||
preview.toolCallId(), preview.toolName(), preview.capability(),
|
||||
preview.summary(), preview.argumentsPreview(),
|
||||
HarnessApprovalStatus.EXPIRED, preview.createdAt(), now, null,
|
||||
"Approval expired before execution"), now);
|
||||
}
|
||||
count++;
|
||||
}
|
||||
}
|
||||
if (count == 0) {
|
||||
return 0;
|
||||
}
|
||||
boolean unresolved = next.toolApprovals().values().stream().anyMatch(approval ->
|
||||
approval.state() == ApprovalState.PENDING
|
||||
|| approval.state() == ApprovalState.APPROVED);
|
||||
if (!unresolved) {
|
||||
next = next.transition(HarnessRunStatus.QUEUED, null, now);
|
||||
}
|
||||
HarnessRunState saved = store.saveRun(run.owner(), next, run.revision());
|
||||
for (ToolCallApprovalAggregate approval : saved.toolApprovals().values()) {
|
||||
if (approval.state() == ApprovalState.EXPIRED
|
||||
&& approval.updatedAt() == now) {
|
||||
eventHub.publish(run.owner(), HarnessEvent.draft(run.sessionId(), run.runId(),
|
||||
"approval.expired", null, approval.toolCallId(), approval.approvalId(),
|
||||
Map.of("state", approval.state().name()), now));
|
||||
}
|
||||
}
|
||||
if (saved.status() == HarnessRunStatus.QUEUED) {
|
||||
scheduleRunnable(saved);
|
||||
eventHub.publish(run.owner(), HarnessEvent.draft(run.sessionId(), run.runId(),
|
||||
"run.queued", null, null, null,
|
||||
Map.of("reason", "approval_expired"), now));
|
||||
}
|
||||
return count;
|
||||
});
|
||||
}
|
||||
|
||||
private void scheduleRunnable(HarnessRunState run) {
|
||||
try {
|
||||
scheduler.schedule(new HarnessRunRequest(run.owner(), run.sessionId(), run.runId()));
|
||||
} catch (RuntimeException rejected) {
|
||||
// QUEUED/RUNNING is the durable dispatch state. A later cursor rotation retries it.
|
||||
log.warn("Harness runnable-run dispatch will be retried for run {}", run.runId(),
|
||||
rejected);
|
||||
}
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void stop() {
|
||||
ScheduledFuture<?> scheduled = future;
|
||||
if (scheduled != null) {
|
||||
scheduled.cancel(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Requested action is incompatible with the authoritative approval state. */
|
||||
public class InvalidApprovalTransitionException extends IllegalStateException {
|
||||
|
||||
public InvalidApprovalTransitionException(ApprovalState state, String action) {
|
||||
super("Cannot " + action + " while tool approval is " + state);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
|
||||
/** Idempotent control-plane command; resolving it cannot execute a tool. */
|
||||
public record ResolveApprovalCommand(
|
||||
String decisionId,
|
||||
ApprovalDecision decision,
|
||||
long expectedRevision,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
String note
|
||||
) {
|
||||
|
||||
public ResolveApprovalCommand {
|
||||
decisionId = ApprovalValidation.requireId(decisionId, "decisionId");
|
||||
if (decision == null || expectedRevision < 0 || owner == null) {
|
||||
throw new IllegalArgumentException("Invalid approval resolution command");
|
||||
}
|
||||
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
|
||||
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
|
||||
note = ApprovalValidation.normalizeOptional(note);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
/** Optimistic revision supplied by a decision or worker no longer matches. */
|
||||
public class StaleApprovalException extends IllegalStateException {
|
||||
|
||||
public StaleApprovalException(long expected, long actual) {
|
||||
super("Stale tool approval revision: expected=" + expected + ", actual=" + actual);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
package org.ruoyi.service.coding.harness.approval;
|
||||
|
||||
import org.ruoyi.service.coding.harness.model.HarnessOwner;
|
||||
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.HexFormat;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Persistable source of truth for one exact tool-call approval.
|
||||
*
|
||||
* <p>{@link #resolve(ResolveApprovalCommand, long)} is deliberately only a control-plane state
|
||||
* transition. It has no executor callback and never consumes or runs the tool. A worker must
|
||||
* separately call {@link #claimForExecution(ClaimApprovalCommand, long)}, which atomically moves
|
||||
* an approved invocation to the one-shot {@link ApprovalState#CONSUMED} state.</p>
|
||||
*/
|
||||
public record ToolCallApprovalAggregate(
|
||||
int schemaVersion,
|
||||
String approvalId,
|
||||
String runId,
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision,
|
||||
ApprovalState state,
|
||||
long revision,
|
||||
ApprovalDecisionReceipt decisionReceipt,
|
||||
ApprovalClaimReceipt claimReceipt,
|
||||
ApprovalOutcomeReason outcomeReason,
|
||||
long createdAt,
|
||||
long expiresAt,
|
||||
long updatedAt
|
||||
) {
|
||||
|
||||
public static final int CURRENT_SCHEMA_VERSION = 1;
|
||||
|
||||
public ToolCallApprovalAggregate {
|
||||
if (schemaVersion < 1 || owner == null || permissionMode == null || permissionRevision < 0
|
||||
|| state == null || revision < 0) {
|
||||
throw new IllegalArgumentException("Invalid tool approval identity or state");
|
||||
}
|
||||
approvalId = ApprovalValidation.requireId(approvalId, "approvalId");
|
||||
runId = ApprovalValidation.requireId(runId, "runId");
|
||||
toolCallId = ApprovalValidation.requireId(toolCallId, "toolCallId");
|
||||
toolName = ApprovalValidation.requireId(toolName, "toolName");
|
||||
argumentsSha256 = ApprovalValidation.requireSha256(argumentsSha256);
|
||||
sessionId = ApprovalValidation.requireId(sessionId, "sessionId");
|
||||
if (createdAt <= 0 || expiresAt <= createdAt || updatedAt < createdAt) {
|
||||
throw new IllegalArgumentException("Invalid tool approval timestamps");
|
||||
}
|
||||
validateReceipts(argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
|
||||
revision, createdAt, expiresAt, updatedAt, decisionReceipt, claimReceipt);
|
||||
validateState(state, revision, decisionReceipt, claimReceipt, outcomeReason);
|
||||
}
|
||||
|
||||
public static ToolCallApprovalAggregate create(
|
||||
String approvalId,
|
||||
String runId,
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision,
|
||||
long createdAt,
|
||||
long expiresAt
|
||||
) {
|
||||
return new ToolCallApprovalAggregate(CURRENT_SCHEMA_VERSION, approvalId, runId,
|
||||
toolCallId, toolName, argumentsSha256, owner, sessionId, permissionMode,
|
||||
permissionRevision, ApprovalState.PENDING, 0, null, null, null,
|
||||
createdAt, expiresAt, createdAt);
|
||||
}
|
||||
|
||||
public static ToolCallApprovalAggregate create(
|
||||
String runId,
|
||||
String toolCallId,
|
||||
String toolName,
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision,
|
||||
long createdAt,
|
||||
long expiresAt
|
||||
) {
|
||||
return create(UUID.randomUUID().toString(), runId, toolCallId, toolName,
|
||||
argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
|
||||
createdAt, expiresAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the human decision only. An APPROVE result remains non-executable until claimed by
|
||||
* a worker. Replaying the exact decisionId and payload returns this aggregate unchanged.
|
||||
*/
|
||||
public ToolCallApprovalAggregate resolve(ResolveApprovalCommand command, long now) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
if (decisionReceipt != null) {
|
||||
if (decisionReceipt.decisionId().equals(command.decisionId())) {
|
||||
if (decisionReceipt.matches(command)) {
|
||||
return this;
|
||||
}
|
||||
throw new ApprovalConflictException(
|
||||
"decisionId was already used with a different payload");
|
||||
}
|
||||
throw new ApprovalConflictException("Tool approval was already resolved");
|
||||
}
|
||||
requireState(ApprovalState.PENDING, "resolve");
|
||||
requireActive(now);
|
||||
requireOwnerAndSession(command.owner(), command.sessionId());
|
||||
requireRevision(command.expectedRevision());
|
||||
requireArguments(command.argumentsSha256());
|
||||
|
||||
long nextRevision = revision + 1;
|
||||
ApprovalDecisionReceipt receipt = new ApprovalDecisionReceipt(command.decisionId(),
|
||||
command.decision(), command.expectedRevision(), nextRevision,
|
||||
command.argumentsSha256(), command.owner(), command.sessionId(), command.note(), now);
|
||||
ApprovalState nextState = command.decision() == ApprovalDecision.APPROVE
|
||||
? ApprovalState.APPROVED : ApprovalState.DENIED;
|
||||
ApprovalOutcomeReason reason = command.decision() == ApprovalDecision.DENY
|
||||
? ApprovalOutcomeReason.DENIED : null;
|
||||
return next(nextState, nextRevision, receipt, null, reason, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-only one-shot transition. Successful return authorizes only the exact bound arguments
|
||||
* under the unchanged owner/session and permission snapshot recorded by the receipt.
|
||||
*/
|
||||
public ToolCallApprovalAggregate claimForExecution(ClaimApprovalCommand command, long now) {
|
||||
Objects.requireNonNull(command, "command");
|
||||
if (claimReceipt != null) {
|
||||
if (claimReceipt.claimId().equals(command.claimId())) {
|
||||
if (claimReceipt.matches(command)) {
|
||||
return this;
|
||||
}
|
||||
throw new ApprovalConflictException(
|
||||
"claimId was already used with a different payload");
|
||||
}
|
||||
throw new ApprovalConflictException("Tool approval was already consumed");
|
||||
}
|
||||
requireState(ApprovalState.APPROVED, "claim for execution");
|
||||
requireActive(now);
|
||||
requireOwnerAndSession(command.owner(), command.sessionId());
|
||||
requireRevision(command.expectedRevision());
|
||||
requireArguments(command.argumentsSha256());
|
||||
if (permissionMode != command.permissionMode()
|
||||
|| permissionRevision != command.permissionRevision()) {
|
||||
throw new ApprovalPermissionChangedException();
|
||||
}
|
||||
|
||||
long nextRevision = revision + 1;
|
||||
ApprovalClaimReceipt receipt = new ApprovalClaimReceipt(command.claimId(),
|
||||
command.workerId(), command.expectedRevision(), nextRevision,
|
||||
command.argumentsSha256(), command.owner(), command.sessionId(),
|
||||
command.permissionMode(), command.permissionRevision(), now);
|
||||
return next(ApprovalState.CONSUMED, nextRevision, decisionReceipt, receipt, null, now);
|
||||
}
|
||||
|
||||
/** Durably marks an unconsumed approval expired once its deadline is reached. */
|
||||
public ToolCallApprovalAggregate expire(long now) {
|
||||
if (state == ApprovalState.EXPIRED) {
|
||||
return this;
|
||||
}
|
||||
if (state != ApprovalState.PENDING && state != ApprovalState.APPROVED) {
|
||||
throw new InvalidApprovalTransitionException(state, "expire");
|
||||
}
|
||||
if (now < expiresAt) {
|
||||
throw new IllegalStateException("Tool approval has not reached its expiry time");
|
||||
}
|
||||
return next(ApprovalState.EXPIRED, revision + 1, decisionReceipt, null,
|
||||
ApprovalOutcomeReason.EXPIRED, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional durable invalidation hook for a permission update observed before worker claim.
|
||||
* A mismatch always fails closed, even when the new mode is more permissive.
|
||||
*/
|
||||
public ToolCallApprovalAggregate invalidateForPermissionChange(
|
||||
HarnessPermissionMode currentMode, long currentPermissionRevision, long now) {
|
||||
if (currentMode == null || currentPermissionRevision < 0) {
|
||||
throw new IllegalArgumentException("Invalid current permission snapshot");
|
||||
}
|
||||
if (permissionMode == currentMode && permissionRevision == currentPermissionRevision) {
|
||||
return this;
|
||||
}
|
||||
if (state == ApprovalState.EXPIRED
|
||||
&& outcomeReason == ApprovalOutcomeReason.PERMISSION_CHANGED) {
|
||||
return this;
|
||||
}
|
||||
if (state != ApprovalState.PENDING && state != ApprovalState.APPROVED) {
|
||||
throw new InvalidApprovalTransitionException(state,
|
||||
"invalidate after a permission change");
|
||||
}
|
||||
return next(ApprovalState.EXPIRED, revision + 1, decisionReceipt, null,
|
||||
ApprovalOutcomeReason.PERMISSION_CHANGED, now);
|
||||
}
|
||||
|
||||
public ApprovalSyntheticOutcome syntheticOutcome() {
|
||||
if (state != ApprovalState.DENIED && state != ApprovalState.EXPIRED) {
|
||||
throw new InvalidApprovalTransitionException(state, "create a synthetic outcome");
|
||||
}
|
||||
return new ApprovalSyntheticOutcome(toolCallId, toolName, state,
|
||||
outcomeReason.code(), outcomeReason.message());
|
||||
}
|
||||
|
||||
/** Utility for hashing the exact canonical argument bytes before creating a request. */
|
||||
public static String sha256(byte[] canonicalArguments) {
|
||||
Objects.requireNonNull(canonicalArguments, "canonicalArguments");
|
||||
try {
|
||||
return HexFormat.of().formatHex(
|
||||
MessageDigest.getInstance("SHA-256").digest(canonicalArguments));
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("SHA-256 is unavailable", e);
|
||||
}
|
||||
}
|
||||
|
||||
private ToolCallApprovalAggregate next(ApprovalState nextState, long nextRevision,
|
||||
ApprovalDecisionReceipt nextDecisionReceipt,
|
||||
ApprovalClaimReceipt nextClaimReceipt,
|
||||
ApprovalOutcomeReason nextOutcomeReason,
|
||||
long now) {
|
||||
if (now < updatedAt) {
|
||||
throw new IllegalArgumentException("Tool approval update time must be monotonic");
|
||||
}
|
||||
return new ToolCallApprovalAggregate(schemaVersion, approvalId, runId, toolCallId,
|
||||
toolName, argumentsSha256, owner, sessionId, permissionMode, permissionRevision,
|
||||
nextState, nextRevision, nextDecisionReceipt, nextClaimReceipt, nextOutcomeReason,
|
||||
createdAt, expiresAt, now);
|
||||
}
|
||||
|
||||
private void requireState(ApprovalState expected, String action) {
|
||||
if (state != expected) {
|
||||
if (state == ApprovalState.EXPIRED) {
|
||||
throw new ApprovalExpiredException();
|
||||
}
|
||||
throw new InvalidApprovalTransitionException(state, action);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireActive(long now) {
|
||||
if (now < updatedAt) {
|
||||
throw new IllegalArgumentException("Tool approval update time must be monotonic");
|
||||
}
|
||||
if (now >= expiresAt) {
|
||||
throw new ApprovalExpiredException();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireRevision(long expectedRevision) {
|
||||
if (revision != expectedRevision) {
|
||||
throw new StaleApprovalException(expectedRevision, revision);
|
||||
}
|
||||
}
|
||||
|
||||
private void requireOwnerAndSession(HarnessOwner commandOwner, String commandSessionId) {
|
||||
if (!owner.equals(commandOwner) || !sessionId.equals(commandSessionId)) {
|
||||
throw new ApprovalOwnershipException();
|
||||
}
|
||||
}
|
||||
|
||||
private void requireArguments(String commandHash) {
|
||||
if (!MessageDigest.isEqual(argumentsSha256.getBytes(StandardCharsets.US_ASCII),
|
||||
commandHash.getBytes(StandardCharsets.US_ASCII))) {
|
||||
throw new ApprovalArgumentsMismatchException();
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateReceipts(
|
||||
String argumentsSha256,
|
||||
HarnessOwner owner,
|
||||
String sessionId,
|
||||
HarnessPermissionMode permissionMode,
|
||||
long permissionRevision,
|
||||
long revision,
|
||||
long createdAt,
|
||||
long expiresAt,
|
||||
long updatedAt,
|
||||
ApprovalDecisionReceipt decisionReceipt,
|
||||
ApprovalClaimReceipt claimReceipt
|
||||
) {
|
||||
if (decisionReceipt != null) {
|
||||
if (!decisionReceipt.argumentsSha256().equals(argumentsSha256)
|
||||
|| !decisionReceipt.owner().equals(owner)
|
||||
|| !decisionReceipt.sessionId().equals(sessionId)
|
||||
|| decisionReceipt.resolvedRevision() > revision
|
||||
|| decisionReceipt.resolvedAt() < createdAt
|
||||
|| decisionReceipt.resolvedAt() >= expiresAt
|
||||
|| decisionReceipt.resolvedAt() > updatedAt) {
|
||||
throw new IllegalArgumentException("Decision receipt does not belong to this approval");
|
||||
}
|
||||
}
|
||||
if (claimReceipt != null) {
|
||||
if (!claimReceipt.argumentsSha256().equals(argumentsSha256)
|
||||
|| !claimReceipt.owner().equals(owner)
|
||||
|| !claimReceipt.sessionId().equals(sessionId)
|
||||
|| claimReceipt.permissionMode() != permissionMode
|
||||
|| claimReceipt.permissionRevision() != permissionRevision
|
||||
|| claimReceipt.consumedRevision() > revision
|
||||
|| claimReceipt.consumedAt() < createdAt
|
||||
|| claimReceipt.consumedAt() >= expiresAt
|
||||
|| claimReceipt.consumedAt() > updatedAt) {
|
||||
throw new IllegalArgumentException("Claim receipt does not belong to this approval");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void validateState(ApprovalState state,
|
||||
long revision,
|
||||
ApprovalDecisionReceipt decisionReceipt,
|
||||
ApprovalClaimReceipt claimReceipt,
|
||||
ApprovalOutcomeReason outcomeReason) {
|
||||
switch (state) {
|
||||
case PENDING -> requireInvariant(decisionReceipt == null && claimReceipt == null
|
||||
&& outcomeReason == null && revision == 0,
|
||||
"PENDING approval cannot have receipts, outcome, or advanced revision");
|
||||
case APPROVED -> requireInvariant(decisionReceipt != null
|
||||
&& decisionReceipt.decision() == ApprovalDecision.APPROVE
|
||||
&& decisionReceipt.resolvedRevision() == revision
|
||||
&& claimReceipt == null && outcomeReason == null,
|
||||
"APPROVED approval requires only an approve receipt");
|
||||
case DENIED -> requireInvariant(decisionReceipt != null
|
||||
&& decisionReceipt.decision() == ApprovalDecision.DENY
|
||||
&& decisionReceipt.resolvedRevision() == revision
|
||||
&& claimReceipt == null && outcomeReason == ApprovalOutcomeReason.DENIED,
|
||||
"DENIED approval requires a deny receipt and reason");
|
||||
case EXPIRED -> requireInvariant(claimReceipt == null
|
||||
&& (decisionReceipt == null
|
||||
|| decisionReceipt.decision() == ApprovalDecision.APPROVE)
|
||||
&& (decisionReceipt == null ? revision == 1
|
||||
: revision == decisionReceipt.resolvedRevision() + 1)
|
||||
&& (outcomeReason == ApprovalOutcomeReason.EXPIRED
|
||||
|| outcomeReason == ApprovalOutcomeReason.PERMISSION_CHANGED),
|
||||
"EXPIRED approval has invalid receipts or reason");
|
||||
case CONSUMED -> requireInvariant(decisionReceipt != null
|
||||
&& decisionReceipt.decision() == ApprovalDecision.APPROVE
|
||||
&& claimReceipt != null
|
||||
&& claimReceipt.expectedRevision() == decisionReceipt.resolvedRevision()
|
||||
&& claimReceipt.consumedRevision() == revision
|
||||
&& outcomeReason == null,
|
||||
"CONSUMED approval requires approve and claim receipts");
|
||||
}
|
||||
}
|
||||
|
||||
private static void requireInvariant(boolean valid, String message) {
|
||||
if (!valid) {
|
||||
throw new IllegalArgumentException(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Raised when stored bytes no longer match their content-addressed SHA-256 id. */
|
||||
public class ArtifactCorruptedException extends ArtifactStoreException {
|
||||
|
||||
public ArtifactCorruptedException(String expectedHash, String actualHash) {
|
||||
super("Artifact integrity check failed: expected=" + expectedHash + ", actual=" + actualHash);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Raised before an oversized artifact or read can be accepted. */
|
||||
public class ArtifactLimitExceededException extends ArtifactStoreException {
|
||||
|
||||
public ArtifactLimitExceededException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Durable artifact discovery metadata; content remains available only through bounded reads. */
|
||||
public record ArtifactManifestEntry(
|
||||
String sourceRunId,
|
||||
String artifactId,
|
||||
long byteSize,
|
||||
long createdAt
|
||||
) { }
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ArtifactManifestPage(
|
||||
List<ArtifactManifestEntry> artifacts,
|
||||
String nextCursor,
|
||||
boolean hasMore
|
||||
) {
|
||||
public ArtifactManifestPage {
|
||||
artifacts = artifacts == null ? List.of() : List.copyOf(artifacts);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Raised when a valid scoped artifact id has no stored object. */
|
||||
public class ArtifactNotFoundException extends ArtifactStoreException {
|
||||
|
||||
public ArtifactNotFoundException(String artifactId) {
|
||||
super("Artifact was not found: " + artifactId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
public record ArtifactReadResult(
|
||||
String artifactId,
|
||||
long offset,
|
||||
int byteLength,
|
||||
String content
|
||||
) { }
|
||||
@@ -0,0 +1,44 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/** Durable, portable reference to a content-addressed artifact. */
|
||||
public record ArtifactRef(
|
||||
String hash,
|
||||
String relativeHandle,
|
||||
String mediaType,
|
||||
long byteSize,
|
||||
String headPreview,
|
||||
String tailPreview,
|
||||
long createdAt
|
||||
) {
|
||||
|
||||
public ArtifactRef {
|
||||
hash = ArtifactStore.requireHash(hash);
|
||||
relativeHandle = requireSafeHandle(relativeHandle);
|
||||
mediaType = ArtifactStore.requireMediaType(mediaType);
|
||||
if (byteSize < 0) {
|
||||
throw new IllegalArgumentException("Artifact byteSize must not be negative");
|
||||
}
|
||||
headPreview = Objects.requireNonNull(headPreview, "headPreview");
|
||||
tailPreview = Objects.requireNonNull(tailPreview, "tailPreview");
|
||||
if (createdAt <= 0) {
|
||||
throw new IllegalArgumentException("Artifact createdAt must be positive");
|
||||
}
|
||||
}
|
||||
|
||||
private static String requireSafeHandle(String handle) {
|
||||
if (handle == null || handle.isBlank() || handle.startsWith("/")
|
||||
|| handle.endsWith("/") || handle.contains("\\") || handle.contains(":")) {
|
||||
throw new IllegalArgumentException("Artifact relativeHandle is not a safe relative path");
|
||||
}
|
||||
String[] segments = handle.split("/", -1);
|
||||
for (String segment : segments) {
|
||||
if (segment.isEmpty() || segment.equals(".") || segment.equals("..")
|
||||
|| !segment.matches("[a-z0-9][a-z0-9_-]{0,127}")) {
|
||||
throw new IllegalArgumentException("Artifact relativeHandle contains an unsafe segment");
|
||||
}
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Canonical owner/session/run namespace for an artifact. */
|
||||
public record ArtifactScope(
|
||||
String ownerId,
|
||||
String sessionId,
|
||||
String runId
|
||||
) {
|
||||
|
||||
private static final Pattern SAFE_ID =
|
||||
Pattern.compile("[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?");
|
||||
private static final Set<String> WINDOWS_RESERVED = Set.of(
|
||||
"con", "prn", "aux", "nul",
|
||||
"com1", "com2", "com3", "com4", "com5", "com6", "com7", "com8", "com9",
|
||||
"lpt1", "lpt2", "lpt3", "lpt4", "lpt5", "lpt6", "lpt7", "lpt8", "lpt9"
|
||||
);
|
||||
|
||||
public ArtifactScope {
|
||||
ownerId = canonicalId(ownerId, "ownerId");
|
||||
sessionId = canonicalId(sessionId, "sessionId");
|
||||
runId = canonicalId(runId, "runId");
|
||||
}
|
||||
|
||||
private static String canonicalId(String value, String field) {
|
||||
if (value == null || value.isBlank()) {
|
||||
throw new IllegalArgumentException(field + " must not be blank");
|
||||
}
|
||||
String canonical = value.strip().toLowerCase(Locale.ROOT);
|
||||
if (!SAFE_ID.matcher(canonical).matches() || WINDOWS_RESERVED.contains(canonical)) {
|
||||
throw new IllegalArgumentException(field + " is not a safe artifact scope id");
|
||||
}
|
||||
return canonical;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Raised when an artifact path is unsafe or escapes its configured root. */
|
||||
public class ArtifactSecurityException extends ArtifactStoreException {
|
||||
|
||||
public ArtifactSecurityException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ArtifactSecurityException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,13 @@
|
||||
package org.ruoyi.service.coding.harness.artifact;
|
||||
|
||||
/** Base exception for artifact persistence and integrity failures. */
|
||||
public class ArtifactStoreException extends RuntimeException {
|
||||
|
||||
public ArtifactStoreException(String message) {
|
||||
super(message);
|
||||
}
|
||||
|
||||
public ArtifactStoreException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user