From 844a64ad37f487b671923b12ebeb281db8264b1c Mon Sep 17 00:00:00 2001 From: matevip Date: Fri, 8 May 2026 15:05:53 +0800 Subject: [PATCH] fix(workflow): hard-delete + blank-draft compile + canvas blank pane --- .../vip/mate/trigger/model/TriggerEntity.java | 3 +- .../mate/workflow/api/WorkflowController.java | 26 +++++++- .../mate/workflow/model/WorkflowEntity.java | 7 ++- .../workflow/model/WorkflowRunEntity.java | 3 +- .../h2/V97__workflow_purge_tombstones.sql | 16 +++++ .../mysql/V97__workflow_purge_tombstones.sql | 9 +++ .../components/workflow/WorkflowCanvas.vue | 60 +++++++++++++++++-- mateclaw-ui/src/views/Workflows.vue | 42 +++++++++++-- 8 files changed, 148 insertions(+), 18 deletions(-) create mode 100644 mateclaw-server/src/main/resources/db/migration/h2/V97__workflow_purge_tombstones.sql create mode 100644 mateclaw-server/src/main/resources/db/migration/mysql/V97__workflow_purge_tombstones.sql diff --git a/mateclaw-server/src/main/java/vip/mate/trigger/model/TriggerEntity.java b/mateclaw-server/src/main/java/vip/mate/trigger/model/TriggerEntity.java index aa295eaf..f6e7f632 100644 --- a/mateclaw-server/src/main/java/vip/mate/trigger/model/TriggerEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/trigger/model/TriggerEntity.java @@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -66,6 +65,6 @@ public class TriggerEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic + // Hard-delete only (project convention); column kept for schema compat. private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java index 8762d710..e376f172 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/api/WorkflowController.java @@ -94,12 +94,26 @@ public class WorkflowController { if (row == null) { return ResponseEntity.badRequest().body(R.fail("workflow not found: " + id)); } - if (row.getDraftJson() == null) { + // The parser throws WorkflowParseException for null/blank/whitespace + // input, which would otherwise bubble up to the global handler as a + // 500. A blank draft is a normal user state ("just created, nothing + // typed yet"), so we surface a friendly 400 here. + if (row.getDraftJson() == null || row.getDraftJson().trim().isEmpty()) { return ResponseEntity.badRequest() .body(R.fail("workflow has no draft to compile: " + id)); } - WorkflowCompiler.Result result = compiler.compile(row.getDraftJson(), - new PublishContext(0L, row.getWorkspaceId()), aclPort); + WorkflowCompiler.Result result; + try { + result = compiler.compile(row.getDraftJson(), + new PublishContext(0L, row.getWorkspaceId()), aclPort); + } catch (vip.mate.workflow.compiler.WorkflowParseException e) { + // Malformed JSON / structurally invalid graph → render as a + // single-error compile failure so the UI's existing errors + // panel handles it without a stack trace dialog. + return ResponseEntity.unprocessableEntity().body(buildCompileFailure(List.of( + new vip.mate.workflow.compiler.CompileError( + "graph.parse_failed", "/", e.getMessage())))); + } if (!result.ok()) { return ResponseEntity.unprocessableEntity() .body(buildCompileFailure(result.errors())); @@ -119,6 +133,12 @@ public class WorkflowController { return ResponseEntity.ok(R.ok(outcome)); } catch (WorkflowCompileFailedException e) { return ResponseEntity.unprocessableEntity().body(buildCompileFailure(e.errors())); + } catch (vip.mate.workflow.compiler.WorkflowParseException e) { + // Same surface as a compile error so the UI errors panel + // handles a malformed / blank draft without a 500 dialog. + return ResponseEntity.unprocessableEntity().body(buildCompileFailure(List.of( + new vip.mate.workflow.compiler.CompileError( + "graph.parse_failed", "/", e.getMessage())))); } catch (IllegalArgumentException | IllegalStateException e) { return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(R.fail(e.getMessage())); } diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowEntity.java b/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowEntity.java index 7a1ced5e..07300ec8 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowEntity.java @@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -56,6 +55,10 @@ public class WorkflowEntity { @TableField(fill = FieldFill.INSERT_UPDATE) private LocalDateTime updateTime; - @TableLogic + // The `deleted` column stays on the table for schema compatibility but + // is no longer logical-deleted — see contributing.md, the project moved + // to hard-delete project-wide. deleteById() now performs a real DELETE, + // and the unique key on (workspace_id, name, deleted) no longer collides + // when a name is recreated and re-deleted. private Integer deleted; } diff --git a/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowRunEntity.java b/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowRunEntity.java index 4f31b9f1..3b0964f7 100644 --- a/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowRunEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/workflow/model/WorkflowRunEntity.java @@ -5,7 +5,6 @@ import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; @@ -56,6 +55,6 @@ public class WorkflowRunEntity { @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; - @TableLogic + // Hard-delete only (project convention); column kept for schema compat. private Integer deleted; } diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V97__workflow_purge_tombstones.sql b/mateclaw-server/src/main/resources/db/migration/h2/V97__workflow_purge_tombstones.sql new file mode 100644 index 00000000..1202c754 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V97__workflow_purge_tombstones.sql @@ -0,0 +1,16 @@ +-- The workflow / trigger entities originally shipped with @TableLogic, which +-- caused deleteById() to soft-update `deleted=1`. The project convention is +-- hard-delete everywhere (see contributing.md), and the soft-delete path +-- collided with the (workspace_id, name, deleted) unique key whenever a name +-- was recreated and re-deleted: the second update tried to write a tombstone +-- that already existed. +-- +-- The entity annotations are removed in this same change set so deleteById() +-- now performs a real DELETE. This migration purges any tombstones that the +-- old soft-delete path may have written, because the annotation-driven query +-- filter is no longer applied — a stale `deleted=1` row would otherwise show +-- up in list endpoints. + +DELETE FROM mate_workflow WHERE deleted <> 0; +DELETE FROM mate_workflow_run WHERE deleted <> 0; +DELETE FROM mate_trigger WHERE deleted <> 0; diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V97__workflow_purge_tombstones.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V97__workflow_purge_tombstones.sql new file mode 100644 index 00000000..8b3a9125 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V97__workflow_purge_tombstones.sql @@ -0,0 +1,9 @@ +-- See the matching H2 file for context. The workflow / trigger entities +-- moved off @TableLogic to align with the project's hard-delete convention; +-- this migration drops any tombstones the old soft-delete path persisted so +-- list endpoints don't expose them after the annotation-driven filter is +-- removed. + +DELETE FROM mate_workflow WHERE deleted <> 0; +DELETE FROM mate_workflow_run WHERE deleted <> 0; +DELETE FROM mate_trigger WHERE deleted <> 0; diff --git a/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue index c3c3a019..56a6aba8 100644 --- a/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue +++ b/mateclaw-ui/src/components/workflow/WorkflowCanvas.vue @@ -27,12 +27,27 @@ -
- {{ t('workflows.canvas.empty') }} +
+
+
{{ t('workflows.canvas.parseError', { msg: graph.parseError }) }}
+
+ +
+
+ + + + + + + + +
+
{{ t('workflows.canvas.empty') }}
{ .canvas-empty { flex: 1; display: flex; + flex-direction: column; align-items: center; justify-content: center; + gap: 12px; font-size: 13px; - opacity: 0.7; - padding: 24px; + padding: 32px 24px; + color: var(--mc-text-secondary, #666); + background: var(--mc-bg-sunken, transparent); + min-height: 280px; +} +.canvas-empty-illustration { + color: var(--mc-text-tertiary, #999); + opacity: 0.85; +} +.canvas-empty-text { + text-align: center; + max-width: 320px; + line-height: 1.5; +} +.canvas-error { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 12px; + padding: 32px 24px; + background: var(--mc-danger-bg, rgba(255, 80, 80, 0.08)); + color: var(--mc-danger, #c0392b); + min-height: 280px; +} +.canvas-error-icon { + font-size: 36px; +} +.canvas-error-msg { + font-family: 'JetBrains Mono', Consolas, monospace; + font-size: 12px; + text-align: center; + max-width: 480px; + word-break: break-word; } .canvas-flow { flex: 1; diff --git a/mateclaw-ui/src/views/Workflows.vue b/mateclaw-ui/src/views/Workflows.vue index ab8917ee..590477ef 100644 --- a/mateclaw-ui/src/views/Workflows.vue +++ b/mateclaw-ui/src/views/Workflows.vue @@ -384,7 +384,30 @@ async function onCreateSubmit(payload: { name: string; description: string }) { const created = res.data as unknown as WorkflowSummary createDialogOpen.value = false await reload() - if (created?.id) await select(created.id) + if (created?.id) { + await select(created.id) + // Seed a starter step so the canvas renders something the user + // can immediately edit, instead of opening on a blank slate. + // The compile button also has something real to validate. + if (!draftJson.value || !draftJson.value.trim()) { + draftJson.value = JSON.stringify({ + steps: [ + { + name: 'first-step', + agentName: 'agent-name', + mode: { type: 'sequential' }, + promptTemplate: 'Hello {{ inputs.payload }}', + }, + ], + }, null, 2) + try { + await workflowApi.saveDraft(created.id, draftJson.value) + } catch (e) { + // Non-fatal: the user can still hit Save Draft manually. + console.warn('seed draft save failed', e) + } + } + } } catch (e) { setStatus(t('workflows.status.createFailed', { msg: (e as Error).message }), 'err') } finally { @@ -838,10 +861,21 @@ button:disabled { } .canvas-pane { flex: 1; - display: grid; - grid-template-columns: 1fr 240px; + display: flex; + flex-direction: row; gap: 12px; - min-height: 360px; + min-height: 420px; + align-items: stretch; +} +.canvas-pane > .workflow-canvas { + /* fill available width when no inspector is visible — earlier the + pane reserved 240px for an inspector via grid-template-columns + even when it was v-if hidden, leaving the canvas confined. */ + flex: 1 1 auto; + min-width: 0; +} +.canvas-pane > .canvas-inspector { + flex: 0 0 240px; } .canvas-inspector { background: var(--mc-bg-elevated, rgba(0, 0, 0, 0.02));