diff --git a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java index a18560d1..8a3ddd12 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/AgentGraphBuilder.java @@ -1000,10 +1000,10 @@ public class AgentGraphBuilder { } String basePrompt = basePromptBuilder.toString(); - // 使用 skill runtime 构建技能增强(per-agent 绑定过滤) + // 使用 skill runtime 构建技能增强(per-agent 绑定过滤 + 工作区隔离) Set boundSkillIds = agentBindingService.getBoundSkillIds(entity.getId()); String skillEnhancement = skillRuntimeService.buildSkillPromptEnhancement( - boundSkillIds, boundTools, maxInputTokens, entity.getId()); + boundSkillIds, boundTools, maxInputTokens, entity.getId(), entity.getWorkspaceId()); // 工具调用指导 String toolGuidance = """ diff --git a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java index ea5391b4..f5b51ca1 100644 --- a/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java +++ b/mateclaw-server/src/main/java/vip/mate/agent/binding/service/AgentBindingService.java @@ -200,11 +200,11 @@ public class AgentBindingService { * then apply the same workspace comparison. * * - *

Most {@code mate_skill} rows currently sit in the default workspace - * (id=1) because skill creation doesn't yet honor the - * {@code X-Workspace-Id} header; the real-skill branch is therefore - * defense-in-depth right now and flips on automatically the moment - * workspace-scoped skill creation lands. ACP enforcement is live today. + *

Builtin skills are exempt: they are global capabilities seeded + * once into the default workspace and shared with every workspace, so + * any agent may bind them regardless of its own workspace. Only + * workspace-owned skills (dynamic / installed / synthesized) are + * tenancy-checked. * * @throws MateClawException 404 if the agent or skill doesn't exist; * 403 on a workspace mismatch. @@ -241,6 +241,12 @@ public class AgentBindingService { throw new MateClawException("err.skill.not_found", 404, "Skill 不存在: " + skillId); } } + // Builtin skills are global — shared across every workspace, so any + // agent in any workspace may bind them (same stance as MCP virtuals + // above). Only workspace-owned skills are tenancy-checked. + if (Boolean.TRUE.equals(skill.getBuiltin())) { + return; + } long agentWs = agent.getWorkspaceId() == null ? 1L : agent.getWorkspaceId(); long skillWs = skill.getWorkspaceId() == null ? 1L : skill.getWorkspaceId(); if (agentWs != skillWs) { diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java index d15378e9..5cc49899 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobLifecycleService.java @@ -127,6 +127,41 @@ public class CronJobLifecycleService { .set(CronJobRunEntity::getErrorMessage, StrUtil.maxLength(message, 1000))); } + /** + * Insert a {@code running} run row for a task type that does not produce + * a conversation (e.g. {@code wiki_process}). No header / user message is + * saved and no conversation row is touched, because there is no recipient + * to surface the run to. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CronJobRunEntity startSystemRun(CronJobEntity job, String triggerType) { + CronJobRunEntity run = new CronJobRunEntity(); + run.setCronJobId(job.getId()); + run.setConversationId(null); + run.setStatus("running"); + run.setTriggerType(triggerType != null ? triggerType : "scheduled"); + run.setStartedAt(LocalDateTime.now()); + run.setDeliveryStatus("NONE"); + runMapper.insert(run); + return run; + } + + /** + * Mark a {@link #startSystemRun system run} as succeeded with a short + * description of what was done (e.g. "queued 5 raw materials"). The + * description lands in {@code error_message} so the dashboard's existing + * "last run" line surfaces it without a schema change. + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markRunSucceeded(CronJobRunEntity run, String description) { + runMapper.update(null, new LambdaUpdateWrapper() + .eq(CronJobRunEntity::getId, run.getId()) + .set(CronJobRunEntity::getStatus, "succeeded") + .set(CronJobRunEntity::getFinishedAt, LocalDateTime.now()) + .set(CronJobRunEntity::getErrorMessage, + description != null ? StrUtil.maxLength(description, 1000) : null)); + } + /** * T2 — short transaction: persist the assistant reply, mark the run * succeeded, then publish the two domain events. The diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java index 5e58de63..bec4e049 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobRunner.java @@ -1,5 +1,7 @@ package vip.mate.cron.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.messages.AssistantMessage; @@ -9,6 +11,7 @@ import vip.mate.agent.context.ChatOrigin; import vip.mate.cron.CronChatOriginFactory; import vip.mate.cron.model.CronJobEntity; import vip.mate.dashboard.model.CronJobRunEntity; +import vip.mate.wiki.service.WikiProcessingService; /** * RFC-063r §2.7.1: scheduler-facing orchestrator that decomposes one cron @@ -42,6 +45,8 @@ public class CronJobRunner { private final AgentService agentService; private final CronChatOriginFactory originFactory; private final vip.mate.cron.CronConversationResolver conversationResolver; + private final WikiProcessingService wikiProcessingService; + private final ObjectMapper objectMapper; /** * Scheduler-facing entry. Runs three logical segments: @@ -66,6 +71,16 @@ public class CronJobRunner { log.warn("[CronRunner] executeJob called with null job — ignoring"); return; } + + // task_type='wiki_process' — system task with no conversation / + // channel delivery. Parse the wiki-process payload from request_body, + // queue the KB's raw materials for asynchronous processing, and write + // a standalone run record. + if ("wiki_process".equals(job.getTaskType())) { + executeWikiProcess(job, triggerType); + return; + } + String userMessage = "agent".equals(job.getTaskType()) ? job.getRequestBody() : job.getTriggerMessage(); @@ -144,6 +159,90 @@ public class CronJobRunner { } } + /** + * Execute a {@code wiki_process} job: parse the KB id (+ force flag) from + * {@link CronJobEntity#getRequestBody()}, queue the KB's raw materials, + * and record a standalone run row. No conversation, no LLM call, no + * channel delivery — every other cron task type goes through the agent + * path, but this one talks directly to the wiki processing service. + */ + private void executeWikiProcess(CronJobEntity job, String triggerType) { + CronJobRunEntity run; + try { + run = lifecycle.startSystemRun(job, triggerType); + } catch (Exception e) { + log.error("[CronRunner] startSystemRun failed for wiki_process job {}: {}", + job.getId(), e.getMessage(), e); + return; + } + + Long kbId; + boolean force; + try { + JsonNode payload = parsePayload(job.getRequestBody()); + kbId = readKbId(payload); + force = payload != null && payload.hasNonNull("force") && payload.get("force").asBoolean(false); + } catch (Exception e) { + log.error("[CronRunner] wiki_process payload parse failed for job {}: {}", + job.getId(), e.getMessage()); + try { + lifecycle.markRunFailed(run, e); + } catch (Exception markErr) { + log.warn("[CronRunner] markRunFailed after payload-parse failure also failed for run {}: {}", + run.getId(), markErr.getMessage()); + } + return; + } + + try { + int queued = wikiProcessingService.processKB(kbId, force); + String description = "queued " + queued + " raw material(s)" + (force ? " (force)" : ""); + lifecycle.markRunSucceeded(run, description); + } catch (Exception e) { + log.error("[CronRunner] wiki_process job {} failed for kbId={}: {}", + job.getId(), kbId, e.getMessage(), e); + try { + lifecycle.markRunFailed(run, e); + } catch (Exception markErr) { + log.warn("[CronRunner] markRunFailed after wiki_process failure also failed for run {}: {}", + run.getId(), markErr.getMessage()); + } + } + } + + private JsonNode parsePayload(String requestBody) throws Exception { + if (requestBody == null || requestBody.isBlank()) { + throw new IllegalArgumentException("wiki_process requires a non-empty request_body"); + } + return objectMapper.readTree(requestBody); + } + + /** + * Read {@code kbId} from a wiki_process payload, accepting either a JSON + * number or a JSON string (mirrors the Snowflake precision contract: + * frontend may send IDs as strings to avoid Number truncation). + */ + private Long readKbId(JsonNode payload) { + if (payload == null || !payload.hasNonNull("kbId")) { + throw new IllegalArgumentException("wiki_process payload missing kbId"); + } + JsonNode v = payload.get("kbId"); + if (v.isNumber()) { + return v.asLong(); + } + if (v.isTextual()) { + String text = v.asText().trim(); + if (!text.isEmpty()) { + try { + return Long.parseLong(text); + } catch (NumberFormatException ignored) { + // fall through to exception below + } + } + } + throw new IllegalArgumentException("wiki_process payload has unparseable kbId: " + v); + } + /** * Runs the agent with the cron-derived {@link ChatOrigin} and the * RFC-063r §2.13 system-prompt guard prepended when the cron is bound to diff --git a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java index fa121173..c83c4923 100644 --- a/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java +++ b/mateclaw-server/src/main/java/vip/mate/cron/service/CronJobService.java @@ -2,6 +2,8 @@ package vip.mate.cron.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -24,6 +26,8 @@ import vip.mate.cron.model.CronJobDTO; import vip.mate.cron.model.CronJobEntity; import vip.mate.cron.repository.CronJobMapper; import vip.mate.exception.MateClawException; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; import java.time.Duration; import java.time.Instant; @@ -56,6 +60,8 @@ public class CronJobService implements ApplicationRunner { private final CronJobMapper cronJobMapper; private final AgentMapper agentMapper; private final ChannelMapper channelMapper; + private final WikiKnowledgeBaseMapper wikiKnowledgeBaseMapper; + private final ObjectMapper objectMapper; /** * RFC-03 Lane G2: distributed lock for fire-time execution. ShedLock's * JDBC provider is configured in {@link vip.mate.cron.config.ShedLockConfig} @@ -245,6 +251,13 @@ public class CronJobService implements ApplicationRunner { if (entity.getTimezone() == null) entity.setTimezone("Asia/Shanghai"); if (entity.getTaskType() == null) entity.setTaskType("text"); if (entity.getEnabled() == null) entity.setEnabled(true); + // System task types (e.g. wiki_process) have no agent binding, but the + // mate_cron_job.agent_id column is NOT NULL — substitute a 0 sentinel + // so the row inserts and the natural unique key (ws, agent, name) + // still detects duplicates. + if (entity.getAgentId() == null && "wiki_process".equals(entity.getTaskType())) { + entity.setAgentId(0L); + } entity.setNextRunTime(calcNextRunTime(springCron, entity.getTimezone())); try { @@ -330,7 +343,13 @@ public class CronJobService implements ApplicationRunner { existing.setName(dto.getName()); existing.setCronExpression(dto.getCronExpression()); existing.setTimezone(dto.getTimezone() != null ? dto.getTimezone() : "Asia/Shanghai"); - existing.setAgentId(dto.getAgentId()); + // wiki_process has no agent binding — keep the NOT NULL constraint + // satisfied with a 0 sentinel (same convention as create()). + Long newAgentIdForUpdate = dto.getAgentId(); + if (newAgentIdForUpdate == null && "wiki_process".equals(dto.getTaskType())) { + newAgentIdForUpdate = 0L; + } + existing.setAgentId(newAgentIdForUpdate); existing.setTaskType(dto.getTaskType()); existing.setTriggerMessage(dto.getTriggerMessage()); existing.setRequestBody(dto.getRequestBody()); @@ -651,13 +670,15 @@ public class CronJobService implements ApplicationRunner { if (dto.getName() == null || dto.getName().isBlank()) { throw new MateClawException("err.cron.name_required", "任务名称不能为空"); } - if (dto.getAgentId() == null) { - throw new MateClawException("err.cron.agent_required", "请选择关联 Agent"); - } if (dto.getCronExpression() == null || dto.getCronExpression().isBlank()) { throw new MateClawException("err.cron.expression_required", "Cron 表达式不能为空"); } String taskType = dto.getTaskType() != null ? dto.getTaskType() : "text"; + // wiki_process is a system task with no agent; every other task type + // needs an agent binding. + if (!"wiki_process".equals(taskType) && dto.getAgentId() == null) { + throw new MateClawException("err.cron.agent_required", "请选择关联 Agent"); + } // 'text' (LLM chat) and 'reminder' (direct push) both rely on triggerMessage. if (("text".equals(taskType) || "reminder".equals(taskType)) && (dto.getTriggerMessage() == null || dto.getTriggerMessage().isBlank())) { @@ -666,5 +687,52 @@ public class CronJobService implements ApplicationRunner { if ("agent".equals(taskType) && (dto.getRequestBody() == null || dto.getRequestBody().isBlank())) { throw new MateClawException("err.cron.target_required", "执行目标不能为空"); } + if ("wiki_process".equals(taskType)) { + validateWikiProcessPayload(dto.getRequestBody()); + } + } + + /** + * Validate a {@code wiki_process} payload: the body must be JSON with a + * {@code kbId} field (number or string) that resolves to an existing + * knowledge base. Accepting both number and string mirrors the Snowflake + * precision contract — the UI may serialize the id as a string to avoid + * losing the trailing digits in JS Number. + */ + private void validateWikiProcessPayload(String requestBody) { + if (requestBody == null || requestBody.isBlank()) { + throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库"); + } + Long kbId; + try { + JsonNode payload = objectMapper.readTree(requestBody); + if (payload == null || !payload.hasNonNull("kbId")) { + throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库"); + } + JsonNode v = payload.get("kbId"); + if (v.isNumber()) { + kbId = v.asLong(); + } else if (v.isTextual()) { + String text = v.asText().trim(); + if (text.isEmpty()) { + throw new MateClawException("err.cron.wiki_kb_required", "请选择知识库"); + } + try { + kbId = Long.parseLong(text); + } catch (NumberFormatException nfe) { + throw new MateClawException("err.cron.wiki_kb_invalid", "知识库 ID 格式不合法"); + } + } else { + throw new MateClawException("err.cron.wiki_kb_invalid", "知识库 ID 格式不合法"); + } + } catch (MateClawException e) { + throw e; + } catch (Exception e) { + throw new MateClawException("err.cron.wiki_kb_invalid", "知识库参数解析失败"); + } + WikiKnowledgeBaseEntity kb = wikiKnowledgeBaseMapper.selectById(kbId); + if (kb == null) { + throw new MateClawException("err.cron.wiki_kb_not_found", "知识库不存在"); + } } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java index 9250c409..3a86bdb5 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillController.java @@ -65,6 +65,7 @@ public class SkillController { @GetMapping @RequireWorkspaceRole("member") public R> list( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, @RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "20") int size, @RequestParam(required = false) String keyword, @@ -78,9 +79,10 @@ public class SkillController { Set pinnedSkillIds = agentId != null ? agentBindingService.getBoundSkillIds(agentId) : Set.of(); if (pinnedSkillIds == null) pinnedSkillIds = Set.of(); IPage dbPage = skillService.pageSkills( - page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, pinnedSkillIds); + page, size, keyword, skillType, enabled, scanStatus, sort, source, runtime, + pinnedSkillIds, workspaceId); List virtualSkills = visibleVirtualSkills( - keyword, skillType, enabled, scanStatus, sort, source, runtime); + workspaceId, keyword, skillType, enabled, scanStatus, sort, source, runtime); if (!virtualSkills.isEmpty()) { VirtualPageMergeResult merged = mergeVirtualTailPageRecords( dbPage.getRecords(), virtualSkills, dbPage.getTotal(), page, size); @@ -93,9 +95,10 @@ public class SkillController { @Operation(summary = "获取各类型技能计数(tab 徽章用)") @GetMapping("/counts") @RequireWorkspaceRole("member") - public R> counts() { - Map result = skillService.countByType(); - Set realNames = realSkillNames(); + public R> counts( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + Map result = skillService.countByType(workspaceId); + Set realNames = realSkillNames(workspaceId); // RFC-090 §3.2 — virtual MCP-derived skills aren't in mate_skill, // so countByType() misses them. Fold in the live count so the // "MCP" and "all" tab badges match what the list endpoint shows. @@ -185,7 +188,8 @@ public class SkillController { record VirtualPageMergeResult(List records, long total) {} - private List visibleVirtualSkills(String keyword, + private List visibleVirtualSkills(Long workspaceId, + String keyword, String skillType, Boolean enabled, String scanStatus, @@ -197,7 +201,7 @@ public class SkillController { boolean includeAcpVirtuals = isAllSkillType(effectiveSource) || "acp".equalsIgnoreCase(effectiveSource); if (!includeMcpVirtuals && !includeAcpVirtuals) return List.of(); - Set realNames = realSkillNames(); + Set realNames = realSkillNames(workspaceId); List result = new ArrayList<>(); if (includeMcpVirtuals) { try { @@ -245,8 +249,14 @@ public class SkillController { return value != null && value.toLowerCase().contains(lowerCaseNeedle); } - private Set realSkillNames() { - return skillService.listSkills().stream() + /** + * Names of every real {@code mate_skill} row visible in {@code + * workspaceId} (builtin + workspace-owned). Used to shadow same-named + * MCP/ACP virtual skills so the catalog never shows two cards for one + * capability. + */ + private Set realSkillNames(Long workspaceId) { + return skillService.listSkills(workspaceId).stream() .map(SkillEntity::getName) .collect(java.util.stream.Collectors.toSet()); } @@ -254,8 +264,10 @@ public class SkillController { @Operation(summary = "重新扫描单个技能(RFC-042 §2.3.4)") @PostMapping("/{id}/rescan") @RequireWorkspaceRole("admin") - public R rescan(@PathVariable Long id) { + public R rescan(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); return R.ok(skillService.rescanSecurity(id)); } @@ -265,9 +277,11 @@ public class SkillController { "down to disk; if no rows exist yet but local files do, ingests them into the canonical store.") @PostMapping("/{id}/sync-files") @RequireWorkspaceRole("admin") - public R> syncFiles(@PathVariable Long id) { + public R> syncFiles(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); var report = skillFileSyncer.syncOne(skill); Map body = new LinkedHashMap<>(); body.put("skillId", id); @@ -314,18 +328,41 @@ public class SkillController { } } + /** + * Reject access to a skill that the request's workspace doesn't own. + * Builtin skills are global and exempt — every workspace may read and + * (where role permits) toggle them. The interceptor already verified + * the caller's role inside {@code workspaceId}; this guard closes the + * remaining gap where a member of workspace B targets a skill id that + * actually belongs to workspace A. + */ + private void verifyResourceWorkspace(SkillEntity skill, Long headerWorkspaceId) { + if (skill == null || Boolean.TRUE.equals(skill.getBuiltin())) { + return; + } + long requested = headerWorkspaceId != null + ? headerWorkspaceId : SkillService.DEFAULT_WORKSPACE_ID; + long owner = skill.getWorkspaceId() != null + ? skill.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID; + if (owner != requested) { + throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403, + "Skill " + skill.getId() + " does not belong to the current workspace"); + } + } + @Operation(summary = "获取已启用技能列表") @GetMapping("/enabled") @RequireWorkspaceRole("member") - public R> listEnabled() { + public R> listEnabled( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // Mirror the merging the paginated /skills endpoint does so the agent // edit picker (which calls this endpoint) sees MCP- and ACP-derived // virtual skills alongside the persisted ones. The shadow base must // include all real skill names — including disabled ones — so a // disabled real skill correctly suppresses its same-named virtual // twin, matching /skills and /counts. - List result = new ArrayList<>(skillService.listEnabledSkills()); - Set realNames = realSkillNames(); + List result = new ArrayList<>(skillService.listEnabledSkills(workspaceId)); + Set realNames = realSkillNames(workspaceId); try { result.addAll(filterShadowedVirtualSkills( @@ -345,21 +382,24 @@ public class SkillController { @Operation(summary = "按类型获取技能列表") @GetMapping("/type/{skillType}") @RequireWorkspaceRole("member") - public R> listByType(@PathVariable String skillType) { - return R.ok(skillService.listSkillsByType(skillType)); + public R> listByType(@PathVariable String skillType, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(skillService.listSkillsByType(skillType, workspaceId)); } @Operation(summary = "获取已启用技能摘要(按类型分组)") @GetMapping("/summary") @RequireWorkspaceRole("member") - public R>> summary() { - return R.ok(skillService.getEnabledSkillSummary()); + public R>> summary( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(skillService.getEnabledSkillSummary(workspaceId)); } @Operation(summary = "获取技能详情") @GetMapping("/{id}") @RequireWorkspaceRole("member") - public R get(@PathVariable Long id) { + public R get(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // RFC-090 §3.2 — virtual MCP-derived skills synthesize a row // on demand from the live MCP server entity. if (vip.mate.skill.mcp.McpSkillBridge.isVirtualMcpSkillId(id)) { @@ -375,21 +415,31 @@ public class SkillController { SkillEntity ent = acpSkillBridge.findEntityById(id); return ent != null ? R.ok(ent) : R.fail("ACP-derived skill not found: " + id); } - return R.ok(skillService.getSkill(id)); + SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); + return R.ok(skill); } @Operation(summary = "创建技能") @PostMapping @RequireWorkspaceRole("admin") - public R create(@RequestBody SkillEntity skill) { + public R create( + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId, + @RequestBody SkillEntity skill) { + // Always stamp the owning workspace from the request context — never + // trust a workspaceId in the request body. + skill.setWorkspaceId(workspaceId != null + ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID); return R.ok(skillService.createSkill(skill)); } @Operation(summary = "更新技能") @PutMapping("/{id}") @RequireWorkspaceRole("admin") - public R update(@PathVariable Long id, @RequestBody SkillEntity skill) { + public R update(@PathVariable Long id, @RequestBody SkillEntity skill, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); skill.setId(id); return R.ok(skillService.updateSkill(skill)); } @@ -406,8 +456,10 @@ public class SkillController { @Operation(summary = "硬删除技能 (admin only — 物理删除 + 工作区清空)") @DeleteMapping("/{id}") @RequireWorkspaceRole("admin") - public R delete(@PathVariable Long id) { + public R delete(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); skillService.hardDeleteSkill(id); return R.ok(); } @@ -415,8 +467,10 @@ public class SkillController { @Operation(summary = "启用/禁用技能") @PutMapping("/{id}/toggle") @RequireWorkspaceRole("admin") - public R toggle(@PathVariable Long id, @RequestParam boolean enabled) { + public R toggle(@PathVariable Long id, @RequestParam boolean enabled, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { rejectVirtualSkillMutation(id); + verifyResourceWorkspace(skillService.getSkill(id), workspaceId); return R.ok(skillService.toggleSkill(id, enabled)); } @@ -657,14 +711,16 @@ public class SkillController { @Operation(summary = "从对话历史合成 Skill(RFC-023)") @PostMapping("/synthesize-from-conversation") @RequireWorkspaceRole("admin") - public R> synthesizeFromConversation(@RequestBody Map body) { + public R> synthesizeFromConversation(@RequestBody Map body, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { String conversationId = (String) body.get("conversationId"); Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; if (conversationId == null || conversationId.isBlank()) { return R.fail("conversationId is required"); } - SkillSynthesisService.SynthesisResult result = synthesisService.synthesize(conversationId, agentId); + SkillSynthesisService.SynthesisResult result = synthesisService.synthesize( + conversationId, agentId, workspaceId); if (result.blocked()) { return R.ok(Map.of( @@ -690,8 +746,10 @@ public class SkillController { @Operation(summary = "将 skill 导出到工作区目录") @PostMapping("/{id}/export-workspace") @RequireWorkspaceRole("admin") - public R> exportToWorkspace(@PathVariable Long id) { + public R> exportToWorkspace(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); var path = workspaceManager.exportToWorkspace(skill.getName(), skill.getSkillContent()); if (path == null) { return R.ok(Map.of("success", false, "message", "Failed to export workspace")); @@ -702,8 +760,10 @@ public class SkillController { @Operation(summary = "获取 skill 工作区信息") @GetMapping("/{id}/workspace") @RequireWorkspaceRole("admin") - public R> getWorkspaceInfo(@PathVariable Long id) { + public R> getWorkspaceInfo(@PathVariable Long id, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { SkillEntity skill = skillService.getSkill(id); + verifyResourceWorkspace(skill, workspaceId); return R.ok(workspaceManager.getWorkspaceInfo(skill.getName())); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java index 4cdca62b..ac81767c 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/controller/SkillInstallController.java @@ -45,10 +45,14 @@ public class SkillInstallController { @Operation(summary = "开始异步安装 skill") @PostMapping("/start") @RequireWorkspaceRole("admin") - public R startInstall(@RequestBody InstallRequest request) { + public R startInstall(@RequestBody InstallRequest request, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { if (request.getBundleUrl() == null || request.getBundleUrl().isBlank()) { return R.fail("bundleUrl is required"); } + // Stamp the owning workspace from the request context — never trust + // a workspaceId smuggled in the JSON body. + request.setWorkspaceId(workspaceId); return R.ok(skillInstaller.startInstall(request)); } @@ -78,7 +82,8 @@ public class SkillInstallController { @RequestPart("file") MultipartFile zipFile, @RequestParam(defaultValue = "true") Boolean enable, @RequestParam(defaultValue = "false") Boolean overwrite, - @RequestParam(required = false) String targetName) { + @RequestParam(required = false) String targetName, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { // 校验文件类型 String filename = zipFile.getOriginalFilename(); if (filename == null || !filename.toLowerCase().endsWith(".zip")) { @@ -86,7 +91,8 @@ public class SkillInstallController { } try { SkillBundle bundle = ZipSkillFetcher.parse(zipFile, frontmatterParser); - Map result = skillInstaller.installFromBundle(bundle, enable, overwrite, targetName); + Map result = skillInstaller.installFromBundle( + bundle, enable, overwrite, targetName, workspaceId); return R.ok(result); } catch (IllegalArgumentException e) { return R.fail(400, e.getMessage()); @@ -98,8 +104,9 @@ public class SkillInstallController { @Operation(summary = "卸载 skill") @DeleteMapping("/{skillName}") @RequireWorkspaceRole("admin") - public R> uninstall(@PathVariable String skillName) { - skillInstaller.uninstall(skillName); + public R> uninstall(@PathVariable String skillName, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + skillInstaller.uninstall(skillName, workspaceId); return R.ok(Map.of("message", "Skill '" + skillName + "' uninstalled")); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java index 8e0e4cf1..a3dee481 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/SkillInstaller.java @@ -81,7 +81,7 @@ public class SkillInstaller { * admin-only physical removal, call * {@code SkillService.hardDeleteSkill} via {@code DELETE /skills/{id}}. */ - public void uninstall(String skillName) { + public void uninstall(String skillName, Long workspaceId) { List skills = skillService.listSkills(); SkillEntity target = skills.stream() .filter(s -> s.getName().equals(skillName)) @@ -89,6 +89,18 @@ public class SkillInstaller { .orElse(null); if (target != null) { + // A workspace may only uninstall the skills it owns. Builtin + // skills are global and rejected by uninstallSkill itself. + if (!Boolean.TRUE.equals(target.getBuiltin())) { + long requested = workspaceId != null + ? workspaceId : SkillService.DEFAULT_WORKSPACE_ID; + long owner = target.getWorkspaceId() != null + ? target.getWorkspaceId() : SkillService.DEFAULT_WORKSPACE_ID; + if (owner != requested) { + throw new vip.mate.exception.MateClawException("err.common.wrong_workspace", 403, + "Skill '" + skillName + "' does not belong to the current workspace"); + } + } skillService.uninstallSkill(target.getId()); } log.info("Uninstalled skill: {}", skillName); @@ -159,7 +171,7 @@ public class SkillInstaller { // 5. Register/update the skill row first so we have an id for the file rows. SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, - Boolean.TRUE.equals(request.getEnable())); + Boolean.TRUE.equals(request.getEnable()), request.getWorkspaceId()); if (task.isCancelRequested()) { task.markCancelled(); @@ -199,7 +211,8 @@ public class SkillInstaller { * * @return 安装结果 Map(skillId, name, version, filesCount) */ - public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, String targetName) { + public Map installFromBundle(SkillBundle bundle, boolean enable, boolean overwrite, + String targetName, Long workspaceId) { String skillName = (targetName != null && !targetName.isBlank()) ? targetName : bundle.name(); if (skillName == null || skillName.isBlank()) { throw new vip.mate.exception.MateClawException("err.skill.name_required", "Cannot determine skill name from bundle"); @@ -216,7 +229,7 @@ public class SkillInstaller { workspaceManager.initWorkspace(skillName, bundle.content(), exists); // Register/update skill row first so we have an id to anchor the file rows. - SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable); + SkillEntity skillEntity = upsertSkillRow(bundle, skillName, exists, enable, workspaceId); // DB-canonical, FS-cache. Empty-bundle guard on both sides. persistBundleFiles(skillEntity, bundle, false, "zip"); @@ -241,8 +254,13 @@ public class SkillInstaller { /** * Insert or update the {@code mate_skill} row from a bundle. Returns the * persisted entity so callers have its id for downstream file writes. + * + *

{@code workspaceId} is stamped only on the insert path — an + * existing skill keeps its current owning workspace so a re-install + * never silently migrates a skill between workspaces. */ - private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, boolean enable) { + private SkillEntity upsertSkillRow(SkillBundle bundle, String skillName, boolean exists, + boolean enable, Long workspaceId) { SkillEntity skillEntity; if (exists) { skillEntity = skillService.listSkills().stream() @@ -267,6 +285,7 @@ public class SkillInstaller { skillEntity.setSkillContent(bundle.content()); skillEntity.setConfigJson(buildConfigJson(bundle)); skillEntity.setEnabled(enable); + skillEntity.setWorkspaceId(workspaceId); skillService.createSkill(skillEntity); } return skillEntity; diff --git a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java index 47143c17..f7ba22d6 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/installer/model/InstallRequest.java @@ -33,4 +33,11 @@ public class InstallRequest { * an intentionally empty bundle. */ private Boolean forcePrune = false; + + /** + * Owning workspace for the installed skill. Stamped by the controller + * from the {@code X-Workspace-Id} header — never trusted from a raw + * client body. {@code null} falls back to the default workspace. + */ + private Long workspaceId; } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java index d6ee4115..74a2c645 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillPackageResolver.java @@ -335,6 +335,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .workspaceId(entity.getWorkspaceId()) .createTime(entity.getCreateTime()) .build(); } @@ -376,6 +377,7 @@ public class SkillPackageResolver { .enabled(Boolean.TRUE.equals(entity.getEnabled())) .icon(entity.getIcon()) .builtin(Boolean.TRUE.equals(entity.getBuiltin())) + .workspaceId(entity.getWorkspaceId()) .createTime(entity.getCreateTime()) .build(); } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java index 008d4553..706d3b0a 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/SkillRuntimeService.java @@ -345,13 +345,29 @@ public class SkillRuntimeService { public String buildSkillPromptEnhancement(Set boundSkillIds, Set effectiveToolNames, Integer maxInputTokens) { - return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null); + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, null, null); } public String buildSkillPromptEnhancement(Set boundSkillIds, Set effectiveToolNames, Integer maxInputTokens, Long agentId) { + return buildSkillPromptEnhancement(boundSkillIds, effectiveToolNames, maxInputTokens, agentId, null); + } + + /** + * 构建技能目录提示片段(支持按 Agent 工作区隔离)。 + * + * @param agentWorkspaceId 调用 Agent 的工作区 ID。非 null 时,目录只保留 + * 内置技能(全局)与该工作区拥有的技能;其他工作区 + * 的技能不会注入 prompt。null 表示不做工作区过滤 + * (调试预览等全局场景)。 + */ + public String buildSkillPromptEnhancement(Set boundSkillIds, + Set effectiveToolNames, + Integer maxInputTokens, + Long agentId, + Long agentWorkspaceId) { List activeSkills; if (boundSkillIds != null) { // Per-agent filter: pick the agent's bound subset from the @@ -381,6 +397,16 @@ public class SkillRuntimeService { activeSkills = activeSkills.stream() .filter(s -> matchesCurrentPlatform(s, currentOs)) .collect(java.util.stream.Collectors.toList()); + // Workspace filter — a workspace-B agent must not see workspace-A's + // skills in its catalog. Builtin skills are global; virtual MCP + // skills carry no workspace (null) and stay globally visible. Only + // applied when the caller supplies the agent's workspace; the debug + // preview passes null to keep its global view. + if (agentWorkspaceId != null) { + activeSkills = activeSkills.stream() + .filter(s -> matchesWorkspace(s, agentWorkspaceId)) + .collect(java.util.stream.Collectors.toList()); + } if (activeSkills.isEmpty()) { return ""; } @@ -576,4 +602,17 @@ public class SkillRuntimeService { } return false; } + + /** + * True when the skill is visible to an agent in {@code agentWorkspaceId}. + * Builtin skills are global, virtual MCP-derived skills carry no + * workspace ({@code null}) and are likewise global; every other skill is + * visible only inside its owning workspace. + */ + static boolean matchesWorkspace(ResolvedSkill skill, long agentWorkspaceId) { + if (skill.isBuiltin()) return true; + Long skillWs = skill.getWorkspaceId(); + if (skillWs == null) return true; + return skillWs == agentWorkspaceId; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java index 6df78e20..eda94028 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/runtime/model/ResolvedSkill.java @@ -73,6 +73,14 @@ public class ResolvedSkill { @Builder.Default private boolean builtin = false; + /** + * Owning workspace, copied from {@code mate_skill.workspace_id}. Builtin + * skills are global, so for them this is informational only. {@code null} + * for virtual MCP-derived skills (MCP servers carry no workspace) — the + * runtime treats a null workspace as globally visible. + */ + private Long workspaceId; + /** * Skill row create timestamp, copied from {@code mate_skill.create_time}. * Used by the prompt-catalog ranker to surface freshly installed skills diff --git a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java index 82310f5e..d99fc59f 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/service/SkillService.java @@ -67,6 +67,25 @@ public class SkillService { // ==================== CRUD ==================== + /** Default workspace id used when no {@code X-Workspace-Id} is supplied. */ + public static final long DEFAULT_WORKSPACE_ID = 1L; + + static long normalizeWorkspaceId(Long workspaceId) { + return workspaceId != null ? workspaceId : DEFAULT_WORKSPACE_ID; + } + + /** + * Restrict a query to skills visible inside {@code workspaceId}: builtin + * skills are global (shared across every workspace), every other skill is + * owned by exactly one workspace. Applied as a nested {@code AND (builtin + * OR workspace_id = ?)} group so it composes with other filters. + */ + private static void applyWorkspaceScope(LambdaQueryWrapper wrapper, Long workspaceId) { + long wsId = normalizeWorkspaceId(workspaceId); + wrapper.and(w -> w.eq(SkillEntity::getBuiltin, true) + .or().eq(SkillEntity::getWorkspaceId, wsId)); + } + /** * 获取所有技能列表(管理页面使用) * 排序:内置优先,然后按创建时间倒序 @@ -77,6 +96,18 @@ public class SkillService { .orderByDesc(SkillEntity::getCreateTime)); } + /** + * Workspace-scoped variant of {@link #listSkills()} — returns builtin + * skills plus the skills owned by {@code workspaceId}. + */ + public List listSkills(Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .orderByDesc(SkillEntity::getBuiltin) + .orderByDesc(SkillEntity::getCreateTime); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * Paginated skill listing for the SkillMarket admin UI. * @@ -88,33 +119,23 @@ public class SkillService { * security_scan_status}: {@code "FAILED"} surfaces blocked skills so the * admin can inspect findings and rescan, {@code "PASSED"} shows scanned * clean rows, {@code null} / empty means no scan filter. + * + *

{@code workspaceId} scopes the result to one workspace's catalog: + * builtin skills are always included (they're global), every other skill + * only when it belongs to {@code workspaceId}. A {@code null} workspace + * falls back to the default workspace. */ - public IPage pageSkills(int page, int size, String keyword, - String skillType, Boolean enabled, - String scanStatus) { - return pageSkills(page, size, keyword, skillType, enabled, scanStatus, - null, null, null, Set.of()); - } - - public IPage pageSkills(int page, int size, String keyword, - String skillType, Boolean enabled, - String scanStatus, - String sort, - String source, - String runtime) { - return pageSkills(page, size, keyword, skillType, enabled, scanStatus, - sort, source, runtime, Set.of()); - } - public IPage pageSkills(int page, int size, String keyword, String skillType, Boolean enabled, String scanStatus, String sort, String source, String runtime, - Set pinnedSkillIds) { + Set pinnedSkillIds, + Long workspaceId) { Page pageParam = new Page<>(Math.max(page, 1), Math.max(size, 1)); LambdaQueryWrapper wrapper = new LambdaQueryWrapper<>(); + applyWorkspaceScope(wrapper, workspaceId); if (keyword != null && !keyword.isBlank()) { String kw = keyword.trim(); @@ -191,14 +212,19 @@ public class SkillService { /** * Aggregate skill counts per {@code skill_type}, plus an {@code all} * rollup. Feeds the SkillMarket tab badges without pulling every row. + * Scoped to {@code workspaceId}: builtin skills count for every + * workspace, all other skills only for their owning workspace. */ - public Map countByType() { + public Map countByType(Long workspaceId) { Map result = new LinkedHashMap<>(); - result.put("all", skillMapper.selectCount(null)); + LambdaQueryWrapper allWrapper = new LambdaQueryWrapper<>(); + applyWorkspaceScope(allWrapper, workspaceId); + result.put("all", skillMapper.selectCount(allWrapper)); for (String type : List.of("builtin", "mcp", "dynamic")) { - result.put(type, skillMapper.selectCount( - new LambdaQueryWrapper() - .eq(SkillEntity::getSkillType, type))); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, type); + applyWorkspaceScope(wrapper, workspaceId); + result.put(type, skillMapper.selectCount(wrapper)); } return result; } @@ -217,6 +243,20 @@ public class SkillService { .orderByAsc(SkillEntity::getName)); } + /** + * Workspace-scoped variant of {@link #listEnabledSkills()} — builtin + * skills plus the enabled skills owned by {@code workspaceId}. + */ + public List listEnabledSkills(Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getEnabled, true) + .and(w -> w.isNull(SkillEntity::getSecurityScanStatus) + .or().eq(SkillEntity::getSecurityScanStatus, "PASSED")) + .orderByAsc(SkillEntity::getName); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * 按名称查找技能(RFC-023:SkillManageTool 重名检查用) */ @@ -235,6 +275,17 @@ public class SkillService { .orderByDesc(SkillEntity::getCreateTime)); } + /** + * Workspace-scoped variant of {@link #listSkillsByType(String)}. + */ + public List listSkillsByType(String skillType, Long workspaceId) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SkillEntity::getSkillType, skillType) + .orderByDesc(SkillEntity::getCreateTime); + applyWorkspaceScope(wrapper, workspaceId); + return skillMapper.selectList(wrapper); + } + /** * 获取技能详情 */ @@ -268,6 +319,14 @@ public class SkillService { if (skill.getEnabled() == null) { skill.setEnabled(true); } + // Every skill belongs to a workspace. Callers that carry an + // X-Workspace-Id header set this explicitly; the no-arg create + // path falls back to the default workspace instead of relying on + // the column DEFAULT, so the value is always populated on the + // returned entity. + if (skill.getWorkspaceId() == null) { + skill.setWorkspaceId(DEFAULT_WORKSPACE_ID); + } // 前端只识别 builtin/mcp/dynamic,用户新建默认为 dynamic if (skill.getSkillType() == null || skill.getSkillType().isBlank()) { skill.setSkillType("dynamic"); @@ -670,6 +729,17 @@ public class SkillService { )); } + /** + * Workspace-scoped variant of {@link #getEnabledSkillSummary()}. + */ + public Map> getEnabledSkillSummary(Long workspaceId) { + return listEnabledSkills(workspaceId).stream() + .collect(Collectors.groupingBy( + SkillEntity::getSkillType, + Collectors.mapping(SkillEntity::getName, Collectors.toList()) + )); + } + // ==================== Workspace 集成辅助方法 ==================== /** diff --git a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java index f337ef39..61fc5f6b 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/synthesis/SkillSynthesisService.java @@ -56,9 +56,10 @@ public class SkillSynthesisService { * * @param conversationId 源对话 ID * @param agentId Agent ID(用于记录来源) + * @param workspaceId 目标工作区 ID(决定新 Skill 的归属) * @return 合成结果(包含 skillId、name、status) */ - public SynthesisResult synthesize(String conversationId, Long agentId) { + public SynthesisResult synthesize(String conversationId, Long agentId, Long workspaceId) { // 1. 读取对话历史 List messages = messageMapper.selectList( new LambdaQueryWrapper() @@ -121,6 +122,7 @@ public class SkillSynthesisService { skill.setVersion(extractFrontmatterValue(skillMd, "version")); skill.setSourceConversationId(conversationId); skill.setSecurityScanStatus(scanStatus); + skill.setWorkspaceId(workspaceId); skillService.createSkill(skill); diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java index 57e1933d..90e8ad15 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateController.java @@ -53,7 +53,8 @@ public class SkillTemplateController { @RequireWorkspaceRole("admin") public R instantiate( @PathVariable String id, - @RequestBody Map values) { - return R.ok(templateService.instantiate(id, values)); + @RequestBody Map values, + @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { + return R.ok(templateService.instantiate(id, values, workspaceId)); } } diff --git a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java index b350daf9..6d261a64 100644 --- a/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java +++ b/mateclaw-server/src/main/java/vip/mate/skill/template/SkillTemplateService.java @@ -62,11 +62,12 @@ public class SkillTemplateService { * Instantiate the template by id, substituting fields, and create * the skill. Returns the created {@link SkillEntity}. * - * @param templateId id from the registry (e.g. {@code tcm-qa}) - * @param values user-supplied field values; missing required - * fields throw a translatable exception + * @param templateId id from the registry (e.g. {@code tcm-qa}) + * @param values user-supplied field values; missing required + * fields throw a translatable exception + * @param workspaceId owning workspace for the created skill */ - public SkillEntity instantiate(String templateId, Map values) { + public SkillEntity instantiate(String templateId, Map values, Long workspaceId) { SkillTemplate template = registry.find(templateId); if (template == null) { throw new MateClawException("err.skill_template.not_found", @@ -97,6 +98,7 @@ public class SkillTemplateService { entity.setAuthor("skill-template-wizard"); entity.setSkillContent(skillMd); entity.setEnabled(true); + entity.setWorkspaceId(workspaceId); SkillEntity created = skillService.createSkill(entity); diff --git a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java index d66314ad..93704acf 100644 --- a/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java +++ b/mateclaw-server/src/main/java/vip/mate/tool/builtin/SkillManageTool.java @@ -4,8 +4,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.chat.model.ToolContext; import org.springframework.ai.tool.annotation.Tool; +import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; +import vip.mate.agent.context.ChatOrigin; import vip.mate.skill.model.SkillEntity; import vip.mate.skill.runtime.SkillRuntimeService; import vip.mate.skill.runtime.SkillSecurityService; @@ -120,7 +123,12 @@ public class SkillManageTool { @JsonProperty @JsonPropertyDescription("For patch action: the new text to replace with") - String newText + String newText, + + // RFC-063r §2.5: carries the calling agent's ChatOrigin; hidden + // from the LLM by JsonSchemaGenerator. Used to stamp the new + // skill with the agent's owning workspace. + @Nullable ToolContext toolContext ) { if (action == null || action.isBlank()) { return "Error: action is required (create | edit | patch | delete)"; @@ -135,8 +143,10 @@ public class SkillManageTool { + "'. Must match: lowercase letters, digits, hyphens, dots (1-64 chars, start with letter/digit)"; } + Long workspaceId = ChatOrigin.from(toolContext).workspaceId(); + return switch (action.strip().toLowerCase()) { - case "create" -> doCreate(normalizedName, content); + case "create" -> doCreate(normalizedName, content, workspaceId); case "edit" -> doEdit(normalizedName, content); case "patch" -> doPatch(normalizedName, oldText, newText); case "delete" -> doDelete(normalizedName); @@ -146,7 +156,7 @@ public class SkillManageTool { // ==================== Create ==================== - private String doCreate(String name, String content) { + private String doCreate(String name, String content, Long workspaceId) { if (content == null || content.isBlank()) { return "Error: content is required for create action. Provide full SKILL.md content."; } @@ -175,6 +185,7 @@ public class SkillManageTool { skill.setBuiltin(false); skill.setVersion(extractVersion(content)); skill.setSecurityScanStatus("PASSED"); + skill.setWorkspaceId(workspaceId); skillService.createSkill(skill); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index 4b25e82b..ba33e4d9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -4,17 +4,16 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; +import vip.mate.audit.service.AuditEventService; import vip.mate.channel.web.Utf8SseEmitter; import vip.mate.common.result.R; import vip.mate.exception.MateClawException; import vip.mate.workspace.core.annotation.RequireWorkspaceRole; import vip.mate.wiki.WikiProperties; -import vip.mate.wiki.event.WikiProcessingEvent; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.model.WikiPageEntity; import vip.mate.wiki.model.WikiRawMaterialEntity; @@ -52,8 +51,8 @@ public class WikiController { private final WikiProcessingService processingService; private final WikiDirectoryScanService scanService; private final WikiProperties properties; - private final ApplicationEventPublisher eventPublisher; private final WikiProgressBus progressBus; + private final AuditEventService auditEventService; // ==================== Knowledge Base ==================== @@ -131,7 +130,12 @@ public class WikiController { public R deleteKB(@PathVariable Long id, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(id, workspaceId); - kbService.delete(id); + WikiKnowledgeBaseService.CascadeDeleteResult result = kbService.delete(id); + String detail = String.format( + "{\"rawMaterialCount\":%d,\"pageCount\":%d,\"chunkCount\":%d,\"citationCount\":%d,\"processingJobCount\":%d}", + result.rawMaterialCount(), result.pageCount(), result.chunkCount(), + result.citationCount(), result.processingJobCount()); + auditEventService.record("DELETE", "WIKI_KB", String.valueOf(id), result.kbName(), detail); return R.ok(); } @@ -501,21 +505,8 @@ public class WikiController { @RequestParam(value = "force", defaultValue = "false") boolean force, @RequestHeader(value = "X-Workspace-Id", required = false) Long workspaceId) { verifyKBWorkspace(kbId, workspaceId); - List targets; - if (force) { - // 强制重处理:所有非 pending 的材料重置为 pending,并清空 hash 短路 - targets = rawService.listByKbId(kbId); - for (WikiRawMaterialEntity r : targets) { - rawService.setLastProcessedHash(r.getId(), null); - rawService.reprocess(r.getId()); // reprocess 会把状态设为 pending 并发布事件 - } - return R.ok(Map.of("queued", targets.size(), "force", true)); - } - targets = rawService.listPending(kbId); - for (WikiRawMaterialEntity raw : targets) { - eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId)); - } - return R.ok(Map.of("queued", targets.size(), "force", false)); + int queued = processingService.processKB(kbId, force); + return R.ok(Map.of("queued", queued, "force", force)); } @RequireWorkspaceRole("viewer") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 6930004d..53aefff2 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -5,8 +5,18 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import vip.mate.wiki.job.model.WikiProcessingJobEntity; +import vip.mate.wiki.model.WikiChunkEntity; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; +import vip.mate.wiki.model.WikiPageCitationEntity; +import vip.mate.wiki.model.WikiPageEntity; +import vip.mate.wiki.model.WikiRawMaterialEntity; +import vip.mate.wiki.repository.WikiChunkMapper; import vip.mate.wiki.repository.WikiKnowledgeBaseMapper; +import vip.mate.wiki.repository.WikiPageCitationMapper; +import vip.mate.wiki.repository.WikiPageMapper; +import vip.mate.wiki.repository.WikiProcessingJobMapper; +import vip.mate.wiki.repository.WikiRawMaterialMapper; import java.util.List; @@ -21,6 +31,11 @@ import java.util.List; public class WikiKnowledgeBaseService { private final WikiKnowledgeBaseMapper kbMapper; + private final WikiRawMaterialMapper rawMapper; + private final WikiPageMapper pageMapper; + private final WikiChunkMapper chunkMapper; + private final WikiPageCitationMapper citationMapper; + private final WikiProcessingJobMapper processingJobMapper; /** * RFC-051 PR-2: optional system-page scaffold (overview / log). Marked @@ -32,6 +47,19 @@ public class WikiKnowledgeBaseService { @org.springframework.context.annotation.Lazy private WikiScaffoldService scaffoldService; + /** + * Summary returned from cascade delete — used by callers (e.g. the + * controller) to record an audit event with affected-row counts. + */ + public record CascadeDeleteResult( + String kbName, + int rawMaterialCount, + int pageCount, + int chunkCount, + int citationCount, + int processingJobCount) { + } + private static final String DEFAULT_CONFIG = """ # Wiki Processing Rules @@ -216,9 +244,54 @@ public class WikiKnowledgeBaseService { } } + /** + * Cascade-delete a knowledge base and all data that belongs to it. + *

+ * Single transaction: removes page citations (looked up via page IDs since + * the citation table has no {@code kb_id} column), then chunks, pages, raw + * materials, and processing jobs by {@code kb_id}, and finally the KB row + * itself. Returns a summary so callers can record audit metadata. + */ @Transactional - public void delete(Long id) { + public CascadeDeleteResult delete(Long id) { + WikiKnowledgeBaseEntity kb = kbMapper.selectById(id); + if (kb == null) { + throw new IllegalArgumentException("Knowledge base not found: " + id); + } + + List pageIds = pageMapper.selectList( + new LambdaQueryWrapper() + .select(WikiPageEntity::getId) + .eq(WikiPageEntity::getKbId, id)) + .stream() + .map(WikiPageEntity::getId) + .toList(); + + int citationCount = pageIds.isEmpty() ? 0 : citationMapper.delete( + new LambdaQueryWrapper() + .in(WikiPageCitationEntity::getPageId, pageIds)); + + int pageCount = pageMapper.delete( + new LambdaQueryWrapper() + .eq(WikiPageEntity::getKbId, id)); + + int chunkCount = chunkMapper.delete( + new LambdaQueryWrapper() + .eq(WikiChunkEntity::getKbId, id)); + + int rawCount = rawMapper.delete( + new LambdaQueryWrapper() + .eq(WikiRawMaterialEntity::getKbId, id)); + + int jobCount = processingJobMapper.delete( + new LambdaQueryWrapper() + .eq(WikiProcessingJobEntity::getKbId, id)); + kbMapper.deleteById(id); - log.info("[Wiki] Knowledge base deleted: id={}", id); + + log.info("[Wiki] Knowledge base cascade-deleted: id={}, name={}, raw={}, page={}, chunk={}, citation={}, job={}", + id, kb.getName(), rawCount, pageCount, chunkCount, citationCount, jobCount); + + return new CascadeDeleteResult(kb.getName(), rawCount, pageCount, chunkCount, citationCount, jobCount); } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java index da447ee2..981da91c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiProcessingService.java @@ -17,6 +17,7 @@ import vip.mate.llm.model.ModelConfigEntity; import vip.mate.llm.service.ModelConfigService; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.dto.WikiChunkDraft; +import vip.mate.wiki.event.WikiProcessingEvent; import vip.mate.wiki.job.WikiKbConfig; import vip.mate.wiki.job.WikiKbConfigParser; import vip.mate.wiki.model.WikiKnowledgeBaseEntity; @@ -507,6 +508,39 @@ public class WikiProcessingService { } } + /** + * Queue every raw material in a KB for asynchronous processing and + * return the number of materials queued. Does not block on processing. + *

+ * When {@code force=true}, every raw material is reset to {@code pending} + * and its {@code last_processed_hash} is cleared so the dedup short-circuit + * is bypassed; otherwise only currently-{@code pending} materials are + * republished to the event bus (the same shape as the manual + * {@code POST /knowledge-bases/{kbId}/process} endpoint). + * + * @return the number of raw materials queued + */ + public int processKB(Long kbId, boolean force) { + if (kbId == null) { + throw new IllegalArgumentException("kbId is required"); + } + if (force) { + List all = rawService.listByKbId(kbId); + for (WikiRawMaterialEntity raw : all) { + rawService.setLastProcessedHash(raw.getId(), null); + rawService.reprocess(raw.getId()); + } + log.info("[Wiki] processKB queued {} raw material(s) for kbId={} (force=true)", all.size(), kbId); + return all.size(); + } + List pending = rawService.listPending(kbId); + for (WikiRawMaterialEntity raw : pending) { + eventPublisher.publishEvent(new WikiProcessingEvent(this, raw.getId(), kbId)); + } + log.info("[Wiki] processKB queued {} pending raw material(s) for kbId={}", pending.size(), kbId); + return pending.size(); + } + /** * 处理知识库中所有待处理的原始材料 *

diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties index fe18b3b4..19047d27 100644 --- a/mateclaw-server/src/main/resources/messages.properties +++ b/mateclaw-server/src/main/resources/messages.properties @@ -180,6 +180,9 @@ err.cron.agent_required=\u8bf7\u9009\u62e9\u5173\u8054 Agent err.cron.expression_required=Cron \u8868\u8fbe\u5f0f\u4e0d\u80fd\u4e3a\u7a7a err.cron.trigger_required=\u89e6\u53d1\u6d88\u606f\u4e0d\u80fd\u4e3a\u7a7a err.cron.target_required=\u6267\u884c\u76ee\u6807\u4e0d\u80fd\u4e3a\u7a7a +err.cron.wiki_kb_required=\u8bf7\u9009\u62e9\u77e5\u8bc6\u5e93 +err.cron.wiki_kb_invalid=\u77e5\u8bc6\u5e93 ID \u683c\u5f0f\u4e0d\u5408\u6cd5 +err.cron.wiki_kb_not_found=\u77e5\u8bc6\u5e93\u4e0d\u5b58\u5728 # agent (extended) err.agent.no_default_model=\u65e0\u6cd5\u6784\u5efa Agent\uff1a\u8bf7\u5148\u914d\u7f6e\u5e76\u542f\u7528\u9ed8\u8ba4\u6a21\u578b err.agent.model_not_configured=\u6a21\u578b Provider \u672a\u5b8c\u6210\u914d\u7f6e diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties index acd419f6..1a1a750a 100644 --- a/mateclaw-server/src/main/resources/messages_en.properties +++ b/mateclaw-server/src/main/resources/messages_en.properties @@ -192,6 +192,9 @@ err.cron.agent_required=Please select an Agent err.cron.expression_required=Cron expression cannot be empty err.cron.trigger_required=Trigger message cannot be empty err.cron.target_required=Execution target cannot be empty +err.cron.wiki_kb_required=Please select a knowledge base +err.cron.wiki_kb_invalid=Knowledge base id is malformed +err.cron.wiki_kb_not_found=Knowledge base does not exist # agent (extended) err.agent.no_default_model=Cannot build Agent: please configure and enable a default model in Settings > Models err.agent.model_not_configured=Model provider not configured, please fill in API Key in Settings > Models diff --git a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java index d5136b1f..2bb5499d 100644 --- a/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java +++ b/mateclaw-server/src/test/java/vip/mate/agent/binding/AgentBindingServiceTest.java @@ -220,6 +220,29 @@ class AgentBindingServiceTest { assertEquals(0, count, "拒绝时不能写入绑定行"); } + @Test + @DisplayName("bindSkill 允许跨 workspace 的 builtin skill(builtin 为全局能力,不做 tenancy 校验)") + void bindSkillAllowsBuiltinSkillCrossWorkspace() { + // Builtin skills are seeded once into the default workspace but are + // global — an agent in any workspace must be able to bind them. Seed + // a builtin row whose workspace_id deliberately differs from the + // agent's (=1) to prove the builtin exemption, not a workspace match, + // is what lets the binding through. + long builtinSkillId = 7_777_350L; + jdbcTemplate.update( + "MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, 'builtin', '1.0.0', TRUE, TRUE, 2, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + builtinSkillId, "binding-test-builtin-" + builtinSkillId); + + assertDoesNotThrow(() -> bindingService.bindSkill(agentId, builtinSkillId)); + + Set ids = bindingService.getBoundSkillIds(agentId); + assertNotNull(ids); + assertTrue(ids.contains(builtinSkillId), "builtin skill 应当可跨 workspace 绑定"); + } + @Test @DisplayName("bindSkill 允许 MCP 虚拟 skill(McpServerEntity 无 workspace,全局共享)") void bindSkillAllowsVirtualMcpSkill() { diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java index 76f87522..6084ee65 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerListEnabledTest.java @@ -54,8 +54,8 @@ class SkillControllerListEnabledTest { acpSkillBridge); // listSkills() supplies realSkillNames() for shadow base — default // to empty so each test can override. - when(skillService.listSkills()).thenReturn(List.of()); - when(skillService.listEnabledSkills()).thenReturn(List.of()); + when(skillService.listSkills(null)).thenReturn(List.of()); + when(skillService.listEnabledSkills(null)).thenReturn(List.of()); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of()); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of()); } @@ -66,7 +66,7 @@ class SkillControllerListEnabledTest { SkillEntity mcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); - R> response = controller.listEnabled(); + R> response = controller.listEnabled(null); assertNotNull(response.getData()); assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName())), @@ -79,7 +79,7 @@ class SkillControllerListEnabledTest { SkillEntity acp = skill("claude-code", "acp"); when(acpSkillBridge.listAcpDerivedSkillEntities()).thenReturn(List.of(acp)); - R> response = controller.listEnabled(); + R> response = controller.listEnabled(null); assertTrue(response.getData().stream().anyMatch(s -> "claude-code".equals(s.getName()))); } @@ -92,13 +92,13 @@ class SkillControllerListEnabledTest { // (enabled-only) by mistake, the virtual would slip through here. SkillEntity disabledReal = skill("github", "custom"); disabledReal.setEnabled(false); - when(skillService.listSkills()).thenReturn(List.of(disabledReal)); - when(skillService.listEnabledSkills()).thenReturn(List.of()); + when(skillService.listSkills(null)).thenReturn(List.of(disabledReal)); + when(skillService.listEnabledSkills(null)).thenReturn(List.of()); SkillEntity virtualMcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(virtualMcp)); - R> response = controller.listEnabled(); + R> response = controller.listEnabled(null); // The real skill is disabled, so listEnabledSkills() returns nothing; // the virtual MCP must also be filtered to keep this endpoint in step @@ -112,11 +112,11 @@ class SkillControllerListEnabledTest { void mcpBridgeFailureSwallowed() { SkillEntity enabled = skill("web_search", "builtin"); enabled.setEnabled(true); - when(skillService.listEnabledSkills()).thenReturn(List.of(enabled)); + when(skillService.listEnabledSkills(null)).thenReturn(List.of(enabled)); when(mcpSkillBridge.listMcpDerivedSkillEntities()) .thenThrow(new RuntimeException("MCP bridge offline")); - R> response = controller.listEnabled(); + R> response = controller.listEnabled(null); assertEquals(1, response.getData().size()); assertEquals("web_search", response.getData().get(0).getName()); @@ -130,7 +130,7 @@ class SkillControllerListEnabledTest { SkillEntity mcp = skill("github", "mcp"); when(mcpSkillBridge.listMcpDerivedSkillEntities()).thenReturn(List.of(mcp)); - R> response = controller.listEnabled(); + R> response = controller.listEnabled(null); assertTrue(response.getData().stream().anyMatch(s -> "github".equals(s.getName()))); } diff --git a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java index 4366574b..435a2526 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/controller/SkillControllerVirtualGuardTest.java @@ -26,7 +26,7 @@ class SkillControllerVirtualGuardTest { void updateRejectsVirtualMcpId() { long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; MateClawException ex = assertThrows(MateClawException.class, - () -> controller.update(virtualId, new SkillEntity())); + () -> controller.update(virtualId, new SkillEntity(), null)); assertTrue(ex.getMessage().contains("MCP/ACP"), "expected redirect-to-connection-page hint, got: " + ex.getMessage()); } @@ -41,16 +41,16 @@ class SkillControllerVirtualGuardTest { assertTrue(AcpSkillBridge.isVirtualAcpSkillId(virtualAcpId), "test fixture id is not in ACP virtual range; ACP base layout changed?"); assertThrows(MateClawException.class, - () -> controller.update(virtualAcpId, new SkillEntity())); + () -> controller.update(virtualAcpId, new SkillEntity(), null)); } @Test @DisplayName("delete / toggle / rescan all reject virtual ids the same way") void mutationFamilyAllGuarded() { long virtualId = McpSkillBridge.VIRTUAL_ID_BASE + 42L; - assertThrows(MateClawException.class, () -> controller.delete(virtualId)); - assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true)); - assertThrows(MateClawException.class, () -> controller.rescan(virtualId)); + assertThrows(MateClawException.class, () -> controller.delete(virtualId, null)); + assertThrows(MateClawException.class, () -> controller.toggle(virtualId, true, null)); + assertThrows(MateClawException.class, () -> controller.rescan(virtualId, null)); } @Test @@ -69,7 +69,7 @@ class SkillControllerVirtualGuardTest { // A virtual-id call would have thrown MateClawException before // reaching the service. try { - real.update(snowflakeId, new SkillEntity()); + real.update(snowflakeId, new SkillEntity(), null); } catch (MateClawException e) { // The guard message contains "MCP/ACP"; any other MateClawException // (e.g. from the service layer) is acceptable. diff --git a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java index e0c48f11..ad89d09c 100644 --- a/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java +++ b/mateclaw-server/src/test/java/vip/mate/skill/runtime/SkillRuntimeServicePromptBudgetTest.java @@ -159,6 +159,53 @@ class SkillRuntimeServicePromptBudgetTest { + "prompt was: " + prompt); } + @Test + @DisplayName("workspace filter hides other workspaces' skills, keeps builtin global") + void workspaceFilterHidesOtherWorkspaceSkills() { + SkillService skillService = mock(SkillService.class); + SkillPackageResolver resolver = mock(SkillPackageResolver.class); + SkillLessonsService lessonsService = mock(SkillLessonsService.class); + McpSkillBridge mcpBridge = mock(McpSkillBridge.class); + AcpSkillBridge acpBridge = mock(AcpSkillBridge.class); + SkillUsageService usageService = mock(SkillUsageService.class); + + SkillEntity builtin = entity(1L, "pdf-builtin", "builtin"); + SkillEntity ownWorkspace = entity(2L, "ws1-skill", "dynamic"); + SkillEntity otherWorkspace = entity(3L, "ws2-skill", "dynamic"); + when(skillService.listEnabledSkills()).thenReturn(List.of(builtin, ownWorkspace, otherWorkspace)); + when(resolver.resolve(builtin)).thenReturn(scopedResolved(builtin, true, null)); + when(resolver.resolve(ownWorkspace)).thenReturn(scopedResolved(ownWorkspace, false, 1L)); + when(resolver.resolve(otherWorkspace)).thenReturn(scopedResolved(otherWorkspace, false, 2L)); + when(mcpBridge.listMcpDerivedResolvedSkills()).thenReturn(List.of()); + when(acpBridge.listAcpDerivedResolvedSkills()).thenReturn(List.of()); + when(usageService.recentLoadedSkillNames(null, 8)).thenReturn(Set.of()); + when(usageService.frequentlyLoadedSkillNames(8)).thenReturn(Set.of()); + + SkillRuntimeService runtime = new SkillRuntimeService( + skillService, resolver, lessonsService, mcpBridge, acpBridge, usageService); + + // Agent lives in workspace 1. + String prompt = runtime.buildSkillPromptEnhancement(null, null, 8192, null, 1L); + + assertTrue(prompt.contains("pdf-builtin"), "builtin skill must stay globally visible"); + assertTrue(prompt.contains("ws1-skill"), "the agent's own workspace skill must be visible"); + assertFalse(prompt.contains("ws2-skill"), "another workspace's skill must not leak into the prompt"); + } + + private static ResolvedSkill scopedResolved(SkillEntity entity, boolean builtin, Long workspaceId) { + return ResolvedSkill.builder() + .id(entity.getId()) + .name(entity.getName()) + .description(entity.getDescription()) + .enabled(true) + .runtimeAvailable(true) + .dependencyReady(true) + .securityBlocked(false) + .builtin(builtin) + .workspaceId(workspaceId) + .build(); + } + private static SkillEntity entity(Long id, String name, String type) { SkillEntity entity = new SkillEntity(); entity.setId(id); diff --git a/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java new file mode 100644 index 00000000..af6d7947 --- /dev/null +++ b/mateclaw-server/src/test/java/vip/mate/skill/service/SkillServiceWorkspaceScopeTest.java @@ -0,0 +1,104 @@ +package vip.mate.skill.service; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.TestPropertySource; +import vip.mate.MateClawApplication; +import vip.mate.skill.model.SkillEntity; + +import java.util.Set; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Issue #135 — verifies that skill read paths are scoped to one workspace: + * builtin skills are global (visible everywhere), every other skill is only + * visible inside its owning workspace. Without this, a workspace-B user saw + * workspace-A's skills in the marketplace but hit a 403 when binding them. + */ +@SpringBootTest( + classes = MateClawApplication.class, + webEnvironment = SpringBootTest.WebEnvironment.NONE +) +@TestPropertySource(properties = { + "spring.datasource.url=jdbc:h2:mem:skill_ws_test_${random.uuid};MODE=MySQL;DATABASE_TO_LOWER=TRUE;CASE_INSENSITIVE_IDENTIFIERS=TRUE;DB_CLOSE_DELAY=-1", + "spring.ai.dashscope.api-key=test-key", + "spring.main.web-application-type=none" +}) +class SkillServiceWorkspaceScopeTest { + + private static final AtomicLong SKILL_ID_SEQ = new AtomicLong(8_135_000L); + + @Autowired + private SkillService skillService; + + @Autowired + private JdbcTemplate jdbcTemplate; + + /** Insert a skill row directly so {@code createSkill}'s workspace/FS side effects stay out of scope. */ + private long seedSkill(String name, long workspaceId, boolean builtin) { + long id = SKILL_ID_SEQ.getAndIncrement(); + jdbcTemplate.update( + "MERGE INTO mate_skill (id, name, skill_type, version, enabled, builtin, " + + "workspace_id, create_time, update_time, deleted) " + + "KEY(id) VALUES (?, ?, ?, '1.0.0', TRUE, ?, ?, " + + "CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 0)", + id, name, builtin ? "builtin" : "dynamic", builtin, workspaceId); + return id; + } + + @Test + @DisplayName("listSkills(workspaceId) returns builtin + own-workspace skills, hides other workspaces") + void listSkillsIsWorkspaceScoped() { + String ws1Name = "ws1-skill-" + SKILL_ID_SEQ.get(); + String ws2Name = "ws2-skill-" + SKILL_ID_SEQ.get(); + String builtinName = "builtin-skill-" + SKILL_ID_SEQ.get(); + seedSkill(ws1Name, 1L, false); + seedSkill(ws2Name, 2L, false); + seedSkill(builtinName, 1L, true); + + Set ws2Names = skillService.listSkills(2L).stream() + .map(SkillEntity::getName) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(ws2Names.contains(ws2Name), "workspace 2 must see its own skill"); + assertTrue(ws2Names.contains(builtinName), "workspace 2 must see the global builtin skill"); + assertFalse(ws2Names.contains(ws1Name), "workspace 2 must not see workspace 1's skill"); + } + + @Test + @DisplayName("pageSkills(workspaceId) excludes other workspaces' skills from the marketplace listing") + void pageSkillsIsWorkspaceScoped() { + String ws1Name = "page-ws1-" + SKILL_ID_SEQ.get(); + String ws2Name = "page-ws2-" + SKILL_ID_SEQ.get(); + seedSkill(ws1Name, 1L, false); + seedSkill(ws2Name, 2L, false); + + IPage ws2Page = skillService.pageSkills( + 1, 200, null, null, null, null, null, null, null, Set.of(), 2L); + Set names = ws2Page.getRecords().stream() + .map(SkillEntity::getName) + .collect(java.util.stream.Collectors.toSet()); + + assertTrue(names.contains(ws2Name), "marketplace page for workspace 2 must list its own skill"); + assertFalse(names.contains(ws1Name), "marketplace page for workspace 2 must not list workspace 1's skill"); + } + + @Test + @DisplayName("countByType(workspaceId) counts builtin globally but other skills per workspace") + void countByTypeIsWorkspaceScoped() { + long before = skillService.countByType(2L).getOrDefault("dynamic", 0L); + seedSkill("count-ws1-" + SKILL_ID_SEQ.get(), 1L, false); + seedSkill("count-ws2-" + SKILL_ID_SEQ.get(), 2L, false); + + long after = skillService.countByType(2L).getOrDefault("dynamic", 0L); + assertTrue(after == before + 1, + "workspace 2's dynamic count should rise by exactly one (its own skill), not two"); + } +} diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts index f7b15bea..321f3361 100644 --- a/mateclaw-ui/src/i18n/locales/en-US.ts +++ b/mateclaw-ui/src/i18n/locales/en-US.ts @@ -1847,6 +1847,10 @@ export default { sortName: 'Name', sortPages: 'Pages', backToLibrary: 'Back to library', + deleteKB: 'Delete knowledge base', + deleteKBConfirm: 'Delete knowledge base "{name}"? This will also remove {raws} raw materials and {pages} wiki pages. This action cannot be undone.', + deleteKBSuccess: 'Deleted knowledge base "{name}"', + deleteKBFailed: 'Failed to delete knowledge base', }, selectPage: 'Select a page from the sidebar', pageKicker: 'Knowledge Page', @@ -2151,7 +2155,7 @@ export default { delivered: 'Delivered', not_delivered: 'Failed', }, - taskTypes: { text: 'Text Message', reminder: 'Reminder', agent: 'Agent Goal' }, + taskTypes: { text: 'Text Message', reminder: 'Reminder', agent: 'Agent Goal', wiki_process: 'Wiki Processing' }, cronTypes: { hourly: 'Hourly', daily: 'Daily', weekly: 'Weekly', custom: 'Custom' }, days: { mon: 'Mon', tue: 'Tue', wed: 'Wed', thu: 'Thu', fri: 'Fri', sat: 'Sat', sun: 'Sun' }, fields: { @@ -2166,6 +2170,11 @@ export default { reminderTextPlaceholder: 'Exact text to push when the reminder fires (no LLM rewriting)', requestBody: 'Goal', requestBodyPlaceholder: 'Describe the goal for the agent', + wikiKb: 'Knowledge Base', + wikiKbPlaceholder: 'Select a knowledge base to process', + wikiKbEmpty: 'No knowledge bases in this workspace — create one in the Wiki page first.', + wikiForce: 'Force reprocess', + wikiForceHint: 'When on, clears last_processed_hash and re-queues every raw material. When off, only pending materials are queued.', cronFrequency: 'Frequency', cronTime: 'Time', cronDays: 'Days', diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts index 9785285c..0fc4fb1e 100644 --- a/mateclaw-ui/src/i18n/locales/zh-CN.ts +++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts @@ -1859,6 +1859,10 @@ export default { sortName: '名称', sortPages: '页面数', backToLibrary: '返回库', + deleteKB: '删除知识库', + deleteKBConfirm: '确认删除知识库「{name}」?将一并清除 {raws} 个原始材料和 {pages} 个 Wiki 页面,此操作不可撤销。', + deleteKBSuccess: '已删除知识库「{name}」', + deleteKBFailed: '删除知识库失败', }, selectPage: '从左侧选择一个页面查看', pageKicker: '知识页面', @@ -2163,7 +2167,7 @@ export default { delivered: '已送达', not_delivered: '投递失败', }, - taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标' }, + taskTypes: { text: '文字消息', reminder: '提醒', agent: 'Agent 目标', wiki_process: '知识库处理' }, cronTypes: { hourly: '每小时', daily: '每天', weekly: '每周', custom: '自定义' }, days: { mon: '周一', tue: '周二', wed: '周三', thu: '周四', fri: '周五', sat: '周六', sun: '周日' }, fields: { @@ -2178,6 +2182,11 @@ export default { reminderTextPlaceholder: '输入到点要原样推送的提醒内容(不会经过 LLM 改写)', requestBody: '执行目标', requestBodyPlaceholder: '直接描述 Agent 要完成的目标', + wikiKb: '知识库', + wikiKbPlaceholder: '选择要处理的知识库', + wikiKbEmpty: '当前工作区还没有知识库,请先在 Wiki 页面创建', + wikiForce: '强制重处理', + wikiForceHint: '开启后清空所有 last_processed_hash,重新入队全部材料;关闭时只处理 pending 状态的材料', cronFrequency: '执行频率', cronTime: '执行时间', cronDays: '执行星期', diff --git a/mateclaw-ui/src/types/index.ts b/mateclaw-ui/src/types/index.ts index 63b366b1..4bf8faaa 100644 --- a/mateclaw-ui/src/types/index.ts +++ b/mateclaw-ui/src/types/index.ts @@ -923,9 +923,9 @@ export interface CronJob { name: string cronExpression: string timezone: string - agentId: string | number + agentId: string | number | null agentName?: string - taskType: 'text' | 'agent' | 'reminder' + taskType: 'text' | 'agent' | 'reminder' | 'wiki_process' triggerMessage?: string requestBody?: string enabled: boolean diff --git a/mateclaw-ui/src/views/CronJobs.vue b/mateclaw-ui/src/views/CronJobs.vue index b9b25553..c32bf32a 100644 --- a/mateclaw-ui/src/views/CronJobs.vue +++ b/mateclaw-ui/src/views/CronJobs.vue @@ -44,7 +44,7 @@

{{ job.name }}
- {{ job.agentName || 'Unknown' }} + {{ job.agentName || 'Unknown' }} {{ t('cronJobs.taskTypes.' + job.taskType) }} @@ -140,7 +140,7 @@
-
+
+
@@ -328,8 +366,14 @@ import { mcToast } from '@/composables/useMcToast' import { mcConfirm } from '@/components/common/useConfirm' import { useCronJobStore } from '@/stores/useCronJobStore' import { useAgentStore } from '@/stores/useAgentStore' +import { wikiApi } from '@/api/index' import type { CronJob } from '@/types/index' +interface WikiKbOption { + id: number | string + name: string +} + const { t } = useI18n() const store = useCronJobStore() const agentStore = useAgentStore() @@ -338,6 +382,8 @@ const agents = computed(() => agentStore.agents) const showModal = ref(false) const editing = ref(null) const detailJob = ref(null) +const wikiKbs = ref([]) +const wikiKbsLoaded = ref(false) const cronTypeOptions = ['hourly', 'daily', 'weekly', 'custom'] as const const cronType = ref('daily') @@ -352,7 +398,7 @@ const timezones = [ 'Australia/Sydney', ] -const defaultForm = (): Partial => ({ +const defaultForm = (): Partial & { wikiKbId?: number | string; wikiForce?: boolean } => ({ name: '', cronExpression: '', timezone: 'Asia/Shanghai', @@ -361,18 +407,64 @@ const defaultForm = (): Partial => ({ triggerMessage: '', requestBody: '', enabled: true, + wikiKbId: '', + wikiForce: false, }) const form = ref(defaultForm()) const canSave = computed(() => { - if (!form.value.name || !form.value.agentId) return false + if (!form.value.name) return false + // wiki_process has no agent binding; every other task type needs an agent. + if (form.value.taskType !== 'wiki_process' && !form.value.agentId) return false if (form.value.taskType === 'text' && !form.value.triggerMessage) return false if (form.value.taskType === 'reminder' && !form.value.triggerMessage) return false if (form.value.taskType === 'agent' && !form.value.requestBody) return false + if (form.value.taskType === 'wiki_process' + && (form.value.wikiKbId == null || form.value.wikiKbId === '')) return false if (cronType.value === 'custom' && !form.value.cronExpression?.trim()) return false return true }) +async function loadWikiKbs() { + if (wikiKbsLoaded.value) return + try { + const res: any = await wikiApi.listKBs() + wikiKbs.value = (res?.data || []).map((kb: any) => ({ + id: kb.id, + name: kb.name || (kb.id != null ? String(kb.id) : ''), + })) + } catch { + wikiKbs.value = [] + } finally { + wikiKbsLoaded.value = true + } +} + +// Parse a wiki_process request body back into form fields so the edit modal +// shows the bound KB and force flag instead of a raw JSON blob. +function applyWikiProcessForm(target: any, requestBody: string | null | undefined) { + if (!requestBody) { + target.wikiKbId = '' + target.wikiForce = false + return + } + try { + const payload = JSON.parse(requestBody) + // Keep kbId as string to preserve Snowflake precision. + target.wikiKbId = payload.kbId != null ? String(payload.kbId) : '' + target.wikiForce = !!payload.force + } catch { + target.wikiKbId = '' + target.wikiForce = false + } +} + +watch(() => form.value.taskType, (next) => { + if (next === 'wiki_process') { + loadWikiKbs() + } +}) + onMounted(() => { store.fetchJobs() agentStore.fetchAgents() @@ -411,7 +503,11 @@ function openCreateModal() { function openEditModal(job: CronJob) { editing.value = job - form.value = { ...job } + form.value = { ...defaultForm(), ...job } + if (job.taskType === 'wiki_process') { + applyWikiProcessForm(form.value, job.requestBody) + loadWikiKbs() + } const parsed = parseCronToForm(job.cronExpression) cronType.value = parsed.type cronTime.value = parsed.time @@ -426,19 +522,69 @@ function closeModal() { function openDetailModal(job: CronJob) { detailJob.value = job + if (job.taskType === 'wiki_process') { + loadWikiKbs() + } +} + +interface WikiProcessSummary { + kbId: string + kbLabel: string + force: boolean +} + +function wikiProcessSummary(job: CronJob): WikiProcessSummary { + if (!job?.requestBody) { + return { kbId: '', kbLabel: '-', force: false } + } + try { + const payload = JSON.parse(job.requestBody) + const kbId = payload.kbId != null ? String(payload.kbId) : '' + const match = wikiKbs.value.find((kb) => String(kb.id) === kbId) + return { + kbId, + kbLabel: match ? match.name : (kbId ? `#${kbId}` : '-'), + force: !!payload.force, + } + } catch { + return { kbId: '', kbLabel: '-', force: false } + } } function closeDetailModal() { detailJob.value = null } +function buildSavePayload() { + // Strip the form-only wiki helpers and substitute them with the canonical + // JSON request_body the backend expects for wiki_process. For every other + // task type the payload is forwarded as-is. + const { wikiKbId, wikiForce, ...rest } = form.value + if (form.value.taskType === 'wiki_process') { + return { + ...rest, + // Drop agent binding — server defaults to a 0 sentinel for system tasks. + agentId: undefined, + triggerMessage: '', + // Stringify kbId to preserve Snowflake precision over JSON.parse on the + // backend, which accepts both number and string forms. + requestBody: JSON.stringify({ + kbId: wikiKbId != null ? String(wikiKbId) : '', + force: !!wikiForce, + }), + } + } + return rest +} + async function saveJob() { try { + const payload = buildSavePayload() if (editing.value) { - await store.updateJob(editing.value.id, form.value) + await store.updateJob(editing.value.id, payload) mcToast.success(t('cronJobs.messages.updateSuccess')) } else { - await store.createJob(form.value) + await store.createJob(payload) mcToast.success(t('cronJobs.messages.createSuccess')) } closeModal() @@ -694,6 +840,7 @@ function formatTime(datetime: string | undefined): string { .type-text { background: var(--mc-primary-bg); color: var(--mc-primary); } .type-reminder { background: var(--mc-warning-bg, var(--mc-primary-bg)); color: var(--mc-warning, var(--mc-primary-hover)); } .type-agent { background: var(--mc-success-bg, var(--mc-primary-bg)); color: var(--mc-success, var(--mc-primary-hover)); } +.type-wiki_process { background: rgba(99, 102, 241, 0.12); color: rgb(99, 102, 241); } .cron-code { display: inline-flex; background: var(--mc-bg-sunken); padding: 4px 8px; border-radius: 8px; font-size: 12px; color: var(--mc-text-primary); font-family: monospace; } .cron-readable { font-size: 12px; line-height: 1.45; color: var(--mc-text-tertiary); margin-top: 6px; } .runtime-stack { display: flex; flex-direction: column; gap: 6px; } @@ -811,6 +958,7 @@ function formatTime(datetime: string | undefined): string { .form-group { display: flex; flex-direction: column; gap: 6px; } .form-label { font-size: 13px; font-weight: 500; color: var(--mc-text-secondary); } +.form-hint { font-size: 12px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.45; } .form-input { padding: 8px 12px; border: 1px solid var(--mc-border); border-radius: 8px; font-size: 14px; color: var(--mc-text-primary); outline: none; background: var(--mc-bg-sunken); width: 100%; } .form-input:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); } .form-input.mono { font-family: monospace; } diff --git a/mateclaw-ui/src/views/Wiki/components/WikiKBCard.vue b/mateclaw-ui/src/views/Wiki/components/WikiKBCard.vue index bcfb7621..02ad1619 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiKBCard.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiKBCard.vue @@ -1,10 +1,29 @@