fix(workflow): hard-delete + blank-draft compile + canvas blank pane

This commit is contained in:
matevip 2026-05-08 15:05:53 +08:00
parent f8499eefa7
commit 844a64ad37
8 changed files with 148 additions and 18 deletions

View File

@ -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;
}

View File

@ -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()));
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;

View File

@ -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;

View File

@ -27,12 +27,27 @@
</div>
</div>
<div v-if="!graph.nodes.length && !graph.parseError" class="canvas-empty">
{{ t('workflows.canvas.empty') }}
<div v-if="graph.parseError" class="canvas-error">
<div class="canvas-error-icon"></div>
<div class="canvas-error-msg">{{ t('workflows.canvas.parseError', { msg: graph.parseError }) }}</div>
</div>
<div v-else-if="!graph.nodes.length" class="canvas-empty">
<div class="canvas-empty-illustration">
<svg width="56" height="56" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="7" height="7" rx="1.5"/>
<rect x="14" y="3" width="7" height="7" rx="1.5"/>
<rect x="3" y="14" width="7" height="7" rx="1.5"/>
<rect x="14" y="14" width="7" height="7" rx="1.5"/>
<path d="M10 6.5h4"/>
<path d="M10 17.5h4"/>
</svg>
</div>
<div class="canvas-empty-text">{{ t('workflows.canvas.empty') }}</div>
</div>
<VueFlow
v-else-if="!graph.parseError"
v-else
:id="canvasId"
class="canvas-flow"
:nodes="graph.nodes"
@ -235,11 +250,46 @@ onBeforeUnmount(() => {
.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;

View File

@ -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));