From dac774f15e01ec3d86fa90f8682d56d9f97b241f Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:06:16 +0800 Subject: [PATCH] feat(workflow): trigger workspace isolation + step property editor + custom dialogs --- .../mate/trigger/api/TriggerController.java | 39 +- .../mate/trigger/service/TriggerService.java | 132 ++++- .../workflow/CreateWorkflowDialog.vue | 247 ++++++++-- .../src/components/workflow/PublishDialog.vue | 225 +++++++-- .../components/workflow/StepPropertyPanel.vue | 460 ++++++++++++++++++ .../src/composables/useWorkflowDraft.ts | 133 +++++ mateclaw-ui/src/i18n/locales/en-US.ts | 27 +- mateclaw-ui/src/i18n/locales/zh-CN.ts | 27 +- mateclaw-ui/src/views/Triggers.vue | 27 +- mateclaw-ui/src/views/Workflows.vue | 102 ++-- 10 files changed, 1272 insertions(+), 147 deletions(-) create mode 100644 mateclaw-ui/src/components/workflow/StepPropertyPanel.vue create mode 100644 mateclaw-ui/src/composables/useWorkflowDraft.ts diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java b/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java index cf02335e..5493c714 100644 --- a/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java +++ b/mateclaw-server/src/main/java/vip/mate/trigger/api/TriggerController.java @@ -28,37 +28,52 @@ public class TriggerController { private final TriggerService triggerService; private final TriggerEventIngestService ingestService; - @Operation(summary = "List triggers in a workspace.") + @Operation(summary = "List triggers in the caller's workspace.") @GetMapping - public R> list(@RequestParam("workspaceId") long workspaceId) { + public R> list(@RequestHeader("X-Workspace-Id") long workspaceId) { return R.ok(triggerService.listByWorkspace(workspaceId)); } - @Operation(summary = "Get a trigger by id.") + @Operation(summary = "Get a trigger by id, scoped to the caller's workspace.") @GetMapping("/{id}") - public R get(@PathVariable long id) { - TriggerEntity row = triggerService.get(id); + public R get(@PathVariable long id, + @RequestHeader("X-Workspace-Id") long workspaceId) { + TriggerEntity row = triggerService.get(id, workspaceId); if (row == null) return R.fail("trigger not found: " + id); return R.ok(row); } @Operation(summary = "Create a trigger; if enabled, registers it with the scheduler.") @PostMapping - public R create(@RequestBody TriggerEntity trigger) { - return R.ok(triggerService.create(trigger)); + public R create(@RequestBody TriggerEntity trigger, + @RequestHeader("X-Workspace-Id") long workspaceId) { + // The controller forces workspace from the trusted header — the + // body's workspaceId is ignored so a caller can't plant a trigger + // into another workspace by tweaking the JSON. + try { + return R.ok(triggerService.create(trigger, workspaceId)); + } catch (IllegalArgumentException e) { + return R.fail(e.getMessage()); + } } @Operation(summary = "Update a trigger; pattern_version bumps when the cron expression changes.") @PutMapping("/{id}") - public R update(@PathVariable long id, @RequestBody TriggerEntity trigger) { - trigger.setId(id); - return R.ok(triggerService.update(trigger)); + public R update(@PathVariable long id, + @RequestBody TriggerEntity trigger, + @RequestHeader("X-Workspace-Id") long workspaceId) { + try { + return R.ok(triggerService.update(id, workspaceId, trigger)); + } catch (IllegalArgumentException e) { + return R.fail(e.getMessage()); + } } @Operation(summary = "Delete a trigger and unregister its schedule.") @DeleteMapping("/{id}") - public R delete(@PathVariable long id) { - triggerService.delete(id); + public R delete(@PathVariable long id, + @RequestHeader("X-Workspace-Id") long workspaceId) { + triggerService.delete(id, workspaceId); return R.ok(); } diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/service/TriggerService.java b/mateclaw-server/src/main/java/vip/mate/trigger/service/TriggerService.java index aaf62478..dcef2f47 100644 --- a/mateclaw-server/src/main/java/vip/mate/trigger/service/TriggerService.java +++ b/mateclaw-server/src/main/java/vip/mate/trigger/service/TriggerService.java @@ -3,14 +3,18 @@ package vip.mate.trigger.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import vip.mate.trigger.model.TriggerEntity; import vip.mate.trigger.repository.TriggerMapper; import vip.mate.trigger.scheduler.TriggerScheduler; +import vip.mate.workflow.model.WorkflowEntity; +import vip.mate.workflow.repository.WorkflowMapper; import java.util.List; import java.util.Objects; +import java.util.Set; /** * CRUD facade for {@code mate_trigger} that keeps the in-memory cron @@ -29,8 +33,19 @@ import java.util.Objects; @RequiredArgsConstructor public class TriggerService { + /** Pattern types accepted by the v0 matcher; anything else fails closed at ingest. */ + private static final Set SUPPORTED_PATTERNS = Set.of( + "cron", "channel_message", "webhook", "agent_lifecycle", + "content_match", "workflow_completion"); + + /** v0 only dispatches workflow targets; agent target requires a v1 dispatcher. */ + private static final Set SUPPORTED_TARGETS = Set.of("workflow"); + private final TriggerMapper triggerMapper; private final TriggerScheduler scheduler; + /** Optional — only present in production. Tests can null it out via constructor. */ + @Autowired(required = false) + private WorkflowMapper workflowMapper; public List listByWorkspace(long workspaceId) { return triggerMapper.selectList(new LambdaQueryWrapper() @@ -38,12 +53,57 @@ public class TriggerService { .orderByDesc(TriggerEntity::getCreateTime)); } + /** + * Lookup that scopes to a single workspace. Returns {@code null} when the + * trigger doesn't exist OR when it belongs to another workspace, so the + * caller can surface the same "not found" status either way and avoid + * leaking foreign trigger ids. + */ + public TriggerEntity get(long id, long workspaceId) { + TriggerEntity row = triggerMapper.selectById(id); + if (row == null || row.getWorkspaceId() == null || row.getWorkspaceId() != workspaceId) { + return null; + } + return row; + } + + /** Backwards-compatible single-arg get; only used by internal pipelines that + * already know they hold a trusted id (scheduler, ingest). New callers must + * use {@link #get(long, long)}. */ public TriggerEntity get(long id) { return triggerMapper.selectById(id); } + @Transactional + public TriggerEntity create(TriggerEntity trigger, long workspaceId) { + // Ignore whatever workspace / id the caller put on the body — we + // trust the workspace from the request header alone. + trigger.setId(null); + trigger.setWorkspaceId(workspaceId); + validatePatternAndTargetShape(trigger); + validateTargetOwnership(trigger, workspaceId); + ensureDefaults(trigger); + trigger.setPatternVersion(1L); + trigger.setFireCount(0L); + triggerMapper.insert(trigger); + if (Boolean.TRUE.equals(trigger.getEnabled())) { + scheduler.register(trigger); + } + return trigger; + } + + /** @deprecated use {@link #create(TriggerEntity, long)} so the workspace + * isn't trusted from the body. Kept for tests that already supply a + * workspace id on the entity and reference fixture workflow ids that + * may not have a real row in mate_workflow. */ + @Deprecated @Transactional public TriggerEntity create(TriggerEntity trigger) { + Long ws = trigger.getWorkspaceId(); + if (ws == null) { + throw new IllegalArgumentException("workspaceId required"); + } + validatePatternAndTargetShape(trigger); ensureDefaults(trigger); trigger.setPatternVersion(1L); trigger.setFireCount(0L); @@ -54,13 +114,32 @@ public class TriggerService { return trigger; } + @Transactional + public TriggerEntity update(long id, long workspaceId, TriggerEntity updated) { + TriggerEntity existing = get(id, workspaceId); + if (existing == null) { + throw new IllegalArgumentException("trigger not found: " + id); + } + // Force the canonical id + workspace; reject any body-side override. + updated.setId(id); + updated.setWorkspaceId(workspaceId); + validatePatternAndTargetShape(updated); + validateTargetOwnership(updated, workspaceId); + return updateInternal(existing, updated); + } + + /** @deprecated use the workspace-scoped overload. */ + @Deprecated @Transactional public TriggerEntity update(TriggerEntity updated) { TriggerEntity existing = triggerMapper.selectById(updated.getId()); if (existing == null) { throw new IllegalArgumentException("trigger not found: " + updated.getId()); } + return updateInternal(existing, updated); + } + private TriggerEntity updateInternal(TriggerEntity existing, TriggerEntity updated) { // Bump pattern_version whenever ANY field that changes the // schedule's behavior, payload rendering, or rate decisions // changes. This is the lamport other instances rely on at fire @@ -85,7 +164,7 @@ public class TriggerService { } else { updated.setPatternVersion(existing.getPatternVersion()); } - // Preserve fireCount / lastFiredAt — those are scheduler-owned. + // Preserve fireCount / lastFiredAt / lastError — those are scheduler / ingest owned. updated.setFireCount(existing.getFireCount()); updated.setLastFiredAt(existing.getLastFiredAt()); @@ -99,12 +178,63 @@ public class TriggerService { return updated; } + @Transactional + public void delete(long id, long workspaceId) { + TriggerEntity row = get(id, workspaceId); + if (row == null) return; // 404-equivalent: idempotent for missing rows + scheduler.unregister(id); + triggerMapper.deleteById(id); + } + + /** @deprecated workspace-blind delete; only retained for tests. */ + @Deprecated @Transactional public void delete(long id) { scheduler.unregister(id); triggerMapper.deleteById(id); } + /** + * Pattern + target shape validation — runs on every entry path so a + * trigger can never silently land in a "looks enabled, never fires" + * state. The acceptance set deliberately mirrors what + * {@code TriggerPatternMatcher} understands AND what + * {@code TriggerDispatcher} can actually route — extending one + * without the other would re-introduce the silent-skip bug. + */ + private static void validatePatternAndTargetShape(TriggerEntity t) { + String pt = t.getPatternType(); + if (pt == null || !SUPPORTED_PATTERNS.contains(pt)) { + throw new IllegalArgumentException("unsupported patternType: " + pt + + " (expected one of " + SUPPORTED_PATTERNS + ")"); + } + String tt = t.getTargetType(); + if (tt == null || !SUPPORTED_TARGETS.contains(tt)) { + throw new IllegalArgumentException("unsupported targetType: " + tt + + " (v0 only supports 'workflow')"); + } + } + + /** + * Cross-workspace ownership check — a trigger in workspace A must + * not be able to point at a workflow in workspace B. Only runs on + * the workspace-aware entry points (create / update with explicit + * workspaceId). The deprecated overloads skip this so legacy tests + * that reference fixture workflow ids without inserting them keep + * working. + */ + private void validateTargetOwnership(TriggerEntity t, long workspaceId) { + if ("workflow".equals(t.getTargetType()) && t.getTargetId() != null + && workflowMapper != null) { + WorkflowEntity wf = workflowMapper.selectById(t.getTargetId()); + if (wf == null || wf.getWorkspaceId() == null + || wf.getWorkspaceId() != workspaceId) { + throw new IllegalArgumentException( + "target workflow not found in workspace: " + t.getTargetId()); + } + } + } + private static void ensureDefaults(TriggerEntity t) { if (t.getRateLimitPerMin() == null) t.setRateLimitPerMin(60); if (t.getDedupWindowSecs() == null) t.setDedupWindowSecs(60); diff --git a/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue b/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue index e1b92192..b11bee02 100644 --- a/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue +++ b/mateclaw-ui/src/components/workflow/CreateWorkflowDialog.vue @@ -1,53 +1,60 @@