37 KiB
DSL 多版本设计(Ingestion Pipeline / Dataflow Canvas)
状态:v5.4(在 v5.3 基础上追加:§1 C5 / §G3 空版本=红色 Missing + Save 报“版本信息缺失”,dataset/file 两层;仍为锁定设计,TDD 实现中) 约束:单 PR、严格 TDD、迁移为幂等 Go 函数、Missing 运行时校验不加字段、版本归属校验禁止悬空。
1. 目标与范围
为 ingestion pipeline(画布类别 dataflow_canvas)的 DSL 增加多版本能力:
- A1 DSL 变更在数据库增量写入,版本号即时间(
user_canvas_version已存在的基础设施)。 - A2 新增版本别名
alias(后端仅存储,前端过滤,不涉及后端逻辑)。 - B1 画布可查看多版本;B2/B2.1 删除版本需弹窗二次确认(告知可能导致 File 解析不可用);B3 手动 Save(用户明确点击保存)→ 生成新版本;自动保存(非手动)→ 仅更新
user_canvas.dsllive draft,不生成新版本(v5.2 细化,修正原「改 DSL 即生成版本」);B4 画布修改不向下传播到已绑定的 dataset/file(粘性快照)。 - C1 dataset/file 配置可选版本;C2 默认绑定“选择时刻最新具体版本”(锁定非浮动),可手动选;C3 切版本 → 参数重置为该版本 default。
- C4 版本删除 → 引用方 Missing,file 不可解析;D1/D2/D3 非最新显示
!,点击仅提示不引导升级;E1 列表新→老;F1 别名前端过滤。 - C5 版本信息为空(
pipeline_version_id为空 / 未选版本)→ UI 显示红色Missing(缺失)徽标;用户点击 Save 时校验并报错“版本信息缺失”。dataset 与 file 两层均适用(见 G3)。空版本不再自动回填为最新版本。 - G1 dataset/file 版本选择 UI:不渲染
latest伪选项,仅列真实版本,选项文案须含alias(如alias (title));已绑定即存具体版本 ID,无 latest 概念(呼应 C2)。 - G2 alias 写入/更新:后端经版本级
PATCH端点(及可选随 manual Save 携带)写入user_canvas_version.alias;前端提供编辑入口(见 §4.4、§8)。 - G3 空版本(
pipeline_version_id为空 / 未选版本)处理:UI 显示红色Missing(缺失)徽标,与「版本被删」的 Missing 同一种视觉(见 §3 扩展);用户点击 Save(创建/更新 dataset、或更新 file 绑定)时,后端拒绝并校验报错“版本信息缺失”(HTTP 4xx + 文案“版本信息缺失”),不写入、不自动回填为最新版本(呼应 C5)。dataset 与 file 两层均实现此校验(C5 双层要求)。
不在范围:agent(画布类别 agent)的发布语义、built-in parser(parse_type=1)的版本化、Python 端(按 AGENTS.md 正在移除)。
2. 当前状态核查(已核实,非臆测)
2.1 版本化基础设施已存在
- 表
user_canvas_version,实体entity.UserCanvasVersion:ID, UserCanvasID, Title, Description, Release bool, DSL, BaseModel。需加Alias *string。 internal/dao/user_canvas_version.go:SaveOrReplaceLatestTx:DSL 变更才建新版本;同 DSL 且!latest.Release || opts.Release时就地更新最新行(对 dataflow 即同 DSL 重复保存不新建版本)。ListByCanvasID:ORDER BY create_time DESC—— 满足 E1(新→老)。GetLatest:create_time DESC取最新版本。GetLatestReleased:仅release = true的版本。DeleteTx/DeleteByCanvasIDTx已存在。
2.2 发布动作的真相(关键修正)
web/.../index.tsx:350无条件挂载<PublishConfirmDialog>,但publish-confirm-dialog.tsx内部有if (isPipeline) { return null; }(isPipeline由useIsPipeline()判定 URL 分类为DataflowCanvas)。结论:发布按钮是 agent 专用,ingestion pipeline(dataflow)没有发布动作。use-save-graph.ts带release=true的saveGraph(...,true)仅来自onPublish,而该回调在 pipeline 下永不触发;dataflow 所有保存(含 20s 自动保存)release均为undefined。- 后端
SaveOrReplaceLatestTx对 dataflow 写入的版本Release恒为false。 - 因此
GetLatestReleased对 dataflow 永远返回未找到 → dataflow 无 draft/published 双态,所有版本都是草稿。 - (v5.2 勘误/延伸) 尽管 dataflow 全部保存
release均为 undefined,但SaveOrReplaceLatestTx在 DSL 发生变更时会 INSERT 新版本行——即当前自动保存(20s 去抖)也会生成新版本。§5.1 将把版本创建收敛到「仅手动 Save」。agent 与 dataflow 共用同一保存入口(web/src/pages/agent/index.tsx→UpdateAgent),故该修正作用于共享路径。
2.3 DSL 加载的两个真相源(版本化要改的核心)
- API 侧:
internal/service/pipeline_params.go的loadCanvasDSLJSON(ctx, canvasID)读user_canvas.dsl(永远最新),被LoadPipelineDSL与ResolveComponentParamsDefaults复用。 - 运行时:
internal/ingestion/task/pipeline_executor.go的loadDSLFromCanvas(ctx, canvasID)(~L1010)读user_canvas.dsl(永远最新),返回(dsl, canvasID, err)。其类型loadDSLFunc签名仅(ctx, canvasID),破坏性变更需加版本。
2.4 绑定落库路径(写入粘性快照的落点)
- dataset:
internal/service/dataset/crud.goCreateDataset、internal/service/dataset/update.goUpdateDataset。service.FromRequest(parseType, parserID, pipelineID)构建ParserSelection;sel.IsBuiltIn()时req.PipelineID = nil(清掉);Pipeline 模式下pipelineID是user_canvas.id(经normalizeDatasetPipelineID校验 32 hex)。update.go中service.LoadPipelineDSL(ctx, isPipeline, effParserID, effPipelineID)加载 DSL;pipelineChanged := pipelineID != nil && (lockedKB.PipelineID == nil || *pipelineID != *lockedKB.PipelineID)。
- document(文件级):
internal/service/document/file2document.go写入PipelineID;document.go有PipelineID *string。单个文件可单独改 pipeline(已确认)。
2.5 运行时上下文
internal/ingestion/task/task_context.go的TaskContext仅有PipelineID string;resolvePipelineID需加resolvePipelineVersionID。- ad-hoc/调试路径(
pipeline_executor.go~L885-916,input.PipelineID)也需走版本校验(见 §12 A7)。
3. 锁定决策
- 迁移 = 幂等 Go 迁移函数(
internal/dao/migration.go的RunMigrations集中调用),参考先例migrateKnowledgebaseParserConfig(FindInBatches + 版本标记)。不用独立 SQL 脚本。 - Missing = 运行时校验,不额外增加字段、不查“是否最新”。两种成因都归为 Missing:
- 版本被删 / 悬空:绑定版本
pipeline_version_id指向的user_canvas_version不存在(或被删、或跨 canvas)→ Missing。 - 空版本:
pipeline_version_id为空 / 未选版本(dataset 或 file 创建/更新时未带版本)→ 同样显示红色Missing徽标(见 G3/C5);保存时后端校验报错“版本信息缺失”,不写入、不自动回填(见 G3)。 空版本与「被删版本」是 Missing 的两种成因,前端视觉统一为红色Missing,区别仅在于成因提示文案(被删=提示重选版本;空版本=提示“版本信息缺失”)。
- 版本被删 / 悬空:绑定版本
- 版本归属校验:禁止悬空版本号。
pipeline_version_id必须属于绑定pipeline_id对应的user_canvas(且类别为dataflow_canvas);否则视为无效。
H12 修订(v4 修正)
- 因 dataflow 无发布语义,
!(非最新)基线改用GetLatest(按create_time DESC),不用GetLatestReleased。 backfillDSLVersionBindings对每个绑定了 dataflow-canvas pipeline 的 dataset/file,取GetLatest(canvasID).ID写入pipeline_version_id;GetLatest不存在(脏数据)则跳过 + 日志告警(绝不写悬空 version_id)。
4. 数据模型变更
4.1 entity.UserCanvasVersion
新增字段:
Alias *string `gorm:"column:alias" json:"alias,omitempty"`
4.2 entity.Dataset 与 entity.Document
各新增可空字段:
PipelineVersionID *string `gorm:"column:pipeline_version_id;size:32;index" json:"pipeline_version_id,omitempty"`
(built-in parser 绑定时该字段为 NULL,不启用。)
4.3 迁移函数(新增 internal/dao/migration.go)
migrateDSLVersionColumns:给knowledgebase与document表加pipeline_version_id列(用HasColumn+ ALTER,参考migrateIngestionTaskPipelineLogID的Error1060容错)。backfillDSLVersionBindings:幂等(用system_settings版本标记防重跑)。FindInBatches遍历knowledgebase/document中pipeline_id IS NOT NULL AND pipeline_id <> ''的行;仅当user_canvas存在且canvas_category = dataflow_canvas时,写pipeline_version_id = GetLatest(canvasID).ID;built-in / 类别不符 / 无版本 → 跳过。
4.4 alias 写入/更新路径(v5.2 新增)
- 现状(已核实):
entity.UserCanvasVersion.Alias已存在(internal/entity/canvas.go:67,gorm 列alias)。- DAO
SaveOrReplaceLatestTx在新建/就地更新两分支均写alias(internal/dao/user_canvas_version.go:313-315更新分支、:328新建分支);有单测TestUserCanvasVersionDAO_SaveOrReplaceLatestTxStoresAlias覆盖。 - handler
publishAgentRequest.Alias *string(internal/handler/agent.go:701)仅经POST /publish透传;但UpdateAgent路径调用saveOrReplaceVersionOptions(..., nil, nil, release)显式传nilalias,故普通保存不写 alias。 - 前端无任何 alias 输入控件:
publish-confirm-dialog.tsx对 dataflow 返回 null(无发布入口),agent 发布也不发 alias;所有前端 alias 命中均为只读过滤/显示。结论:alias 写入路径后端完备但 UI 不可达,实际恒为 NULL。
- 写入设计:
- 新增
PATCH /api/v1/agents/{canvas_id}/versions/{version_id}:body 含alias/title/description,经versionDAO.UpdateTx更新该行元数据;不触发新版本、不改 DSL;需归属校验(version.UserCanvasID == canvas_id)。 UpdateAgent保存请求可选携带alias,随 manual Save 创建/刷新版本时一并写入(兜底路径,复用SaveOrReplaceLatestTx既有 alias 分支)。
- 新增
- 前端:版本历史对话框(
web/src/pages/agent/version-dialog/index.tsx)增加 alias 编辑输入,提交至上述 PATCH;dataset/file 选择器保持只读过滤(G1/F1)。
5. 写入路径:粘性快照(首次默认最新、之后保留)
语义:绑定 pipeline 的那一刻,把“当时最新具体版本”的 ID 锁进 pipeline_version_id,后续画布再改不再影响已绑定方(B4)。
- dataset:
crud.goCreateDataset与update.goUpdateDataset中,当sel.IsPipeline()且pipelineID有效时:- 若本次是新绑定 / pipeline_id 变更(
pipelineChanged):pipeline_version_id = GetLatest(canvasID).ID(锁定当时最新)。 - 若 pipeline_id 未变(仅其它字段变更):保留原
pipeline_version_id(不随画布漂移)。 - 归属校验:确认
canvas_category = dataflow_canvas,否则报错(决策 §3-3)。
- 若本次是新绑定 / pipeline_id 变更(
- document(文件级):
file2document.go同理,绑定/变更 pipeline 时写pipeline_version_id = GetLatest(canvasID).ID;未变更则保留。 - 手动选版本(C2):新增请求字段
pipeline_version_id(可选)。传入时校验其属于该pipeline_id的 canvas,写该值(取代“默认最新”)。- handler 字段白名单(v5.3 补充):
internal/handler/dataset.go的createDatasetAllowedFields与updateDatasetAllowedFields必须放行该字段,否则请求在 handler 层即被拒(code 101Extra inputs are not permitted),service 层永远收不到。该白名单是独立于 service 校验的第二道闸,仅显式登记的字段可通过;新增pipeline_version_id须同时登记到创建与更新两个白名单(PR #21261 已落地,对应dataset_allowed_fields_test.go)。
- handler 字段白名单(v5.3 补充):
- C3 切版本重置参数:当
pipeline_version_id被显式改为另一版本时,调用ResolveComponentParamsDefaults(ctx, parserID, &canvasID, versionID)取得该版本 default,将 dataset/document 的parser_config(component params)重置为该 default(范围见 §12 A3)。 - 回落契约(v5.1 新增,必读):
pipeline_id(画布容器)与pipeline_version_id(容器内快照)是「容器⊃快照」关系,pipeline_version_id必须在pipeline_id的上下文里解析。任何解析都必须遵守下面两条回落链,缺一不可,否则会出现「钉被清空 / 误用最新 / 误用 builtin 默认」三类缺陷:- 版本回落链:
req.PipelineVersionID→lockedKB.PipelineVersionID(已钉版本,不可跳过)→GetLatest(canvas)。请求未带版本时,必须先回落到已钉版本,仅当已钉版本也为空才用 latest。绝不允许「请求没带就直接取 latest」——那会让「钉旧版本」的数据集在改配置时被悄悄升级到最新结构。 - canvas 上下文回落链:解析
pipeline_version_id所需的 canvas =req.PipelineID→lockedKB.PipelineID(已绑画布)→ nil(builtin)。请求省略pipeline_id(只发pipeline_version_id)时,必须沿用已绑PipelineID,不得因省略而让上下文变 nil;否则归属校验失效、pipeline_version_id被误清空、C3 重置误用 builtin 默认。 - parser_config 重建一律基于钉住版本的 DSL:无论因何触发(请求带
parser_config、切版本、pipeline 变更),重建所用 DSL 必须来自pipeline_version_id指向的版本;仅当pipeline_version_id为空时才回落GetLatest。「保留版本号」与「用该版本 DSL 重建」是同一语义,不可割裂——只保留列而用 latest DSL 重建属于未完全实现粘性快照(B4)。 - 单字段 PATCH 必须沿用已绑值:update 允许只发
pipeline_version_id省略pipeline_id(或反之)。被省略的字段一律沿用lockedKB已绑值,不得清空或误判。
- 版本回落链:
5.1 画布保存语义:手动 Save vs 自动保存(v5.2 新增)
背景:agent 与 dataflow 画布共用同一保存入口——web/src/pages/agent/index.tsx 的 Save 按钮(handleSave → saveGraph())与 useWatchAgentChange 的 20s 去抖自动保存,二者皆落到 service.UpdateAgent → SaveOrReplaceLatestTx。当前只要 DSL 变更即 INSERT 新版本行,故自动保存也会刷版本,违背「版本=手动保存里程碑」语义。
信号:前端自动保存请求增加 autosave: true(手动 Save 不带,或显式 autosave: false)。updateAgent 请求体(web/src/services/agent-service.ts)与 PUT /api/v1/agents/:canvas_id 透传该字段;service.UpdateAgent 据此分支。
分支规则(统一作用于 agent 与 dataflow,因共用 UpdateAgent 路径):
autosave == true:仅canvasDAO.UpdateFieldsTx更新user_canvas.dsl(live draft),不调用SaveOrReplaceLatestTx,不产生新版本。autosave == false/缺省(手动 Save):必须同时发生两件事——①canvasDAO.UpdateFieldsTx更新user_canvas.dsl(live draft);② 版本写入 INSERT 一条新的user_canvas_version记录。v5.2 修正:不再做『同 DSL 就地刷新』去重——手动 Save 即落一版,即便 DSL 与上一版本完全相同也 INSERT 新行(保证每次手动保存都产生新版本)。实现上 manual Save 应走强制 INSERT 路径(如新增SaveNewVersionTx(canvasID, dsl, alias, title, description)或给SaveOrReplaceLatestVersionOptions加ForceNew bool),绕过sameDSL就地刷新分支;自动保存则不进入该路径。
agent 发布语义不受影响:PublishAgent(release=true)路径独立,仍创建 released 版本,本次不改(与 §1「agent 发布语义不在范围」一致)。
范围裁定(A9,用户已确认 2026-10-22):因 agent/dataflow 共用 UpdateAgent,修正必须落在共享路径、对所有画布类别统一生效;手动 Save 对 agent 与 dataflow 一致:更新 user_canvas + INSERT 新 user_canvas_version;agent 的发布语义(Publish)不受影响。agent 手动 Save 仍建版本(行为不变),仅自动保存收敛,属良性改进且不触及 §1 范围。
粘性快照不受影响:新建版本只改 user_canvas_version,已绑定 dataset/file 的 pipeline_version_id 仍为粘性(B4);live draft 领先于最新版本属预期(手动 Save 后方才落成版本)。
5.2 版本上限与保留策略(v5.3 新增)
动机:用户可经手动 Save(§5.1)反复创建版本,需有一个可预期、有界的存储上限,同时绝不在写路径上静默删除用户的旧版本(旧设计曾设想「未发布 ≤ 20 自动裁剪」,经评审否决——裁剪会丢失用户可能需要的旧版本,故改为 keep-all)。
5.2.1 每画布硬上限 K=64
- 常量
MaxCanvasVersions = 64(位于internal/dao/user_canvas_version.go)。计数针对单个user_canvas_id下的全部user_canvas_version行,不区分release状态(发布的与未发布的草稿一并计入)。 - 写版本前
SaveOrReplaceLatestTx先COUNT(*)该 canvas 现有行数:- 当
existing < MaxCanvasVersions:允许写入(新增 INSERT 或就地刷新)。 - 当
existing >= MaxCanvasVersions:拒绝本次写入,返回ErrCanvasVersionLimitExceeded(文案:「版本数已达上限(最多 64 个),请删除旧的不再使用的版本」),由mapAgentError映射为common.CodeDataError透传原话给前端弹窗。
- 当
- 计数为「总量」而非「未发布」:发布版本(agent
PublishAgent的release=true行)同样占名额。达到 64 后,无论手动 Save 新草稿还是发布新版本,都会被上限拦截,必须用户主动删除腾位。 - 隔离性:上限按
user_canvas_id维度独立计算,画布 A 写满不影响画布 B(见单测TestCanvasVersionLimit_PerCanvasIsolation)。
5.2.2 不消耗名额的写入形态
以下两种写入不计入 / 不触发上限判定,是「更新既有版本」而非「新增版本」:
- 就地 DSL 刷新(
ForceNew=false且sameDSL命中):SaveOrReplaceLatestTx命中同 DSL 分支时只UPDATE最新一行(如 alias/title/description 元数据),行数不变,不消耗名额(单测TestCanvasVersionLimit_RefreshInPlaceAllowedAtCap)。 - 元数据 PATCH:服务层
PatchVersion(独立 update 路径)仅改alias/title/description等字段,不增行,不消耗名额(单测TestCanvasVersionLimit_AliasPatchUnaffected)。
上限只在「实际新增行」时起作用。只要不 INSERT 新行,即便已达到 64 也能继续刷新最新版本或改元数据。
5.2.3 keep-all:写路径不做任何自动裁剪
- 彻底删除原
deleteAllUnpublishedExcessTx及其「未发布 ≤ 20 裁剪」逻辑。写版本路径不再因总数过高而删除任何既有行。 - 版本行的唯一删除入口是用户显式操作:
- 前端版本历史对话框的删除按钮 →
DeleteVersion(带归属校验、引用扫描CountPipelineVersionReferences,防止删掉仍被 dataset/file 绑定的版本)。 - agent 的版本清理(如适用)走显式接口,非写时隐式裁剪。
- 前端版本历史对话框的删除按钮 →
- 因此「手动 Save 越多越占名额」,名额的回收只能由用户主动删除完成;K=64 是系统唯一的硬约束,不存在「写满后自动丢旧版」的隐藏行为(单测
TestCanvasVersionKeepAllRetainsUnpublished、TestCanvasVersionKeepAllRetainsBeyondOldTrim)。
与上游 PR 的关系:上游 #21343 曾引入 keep-all 但本 PR 早期版采用「未发布裁剪到 20」,现经评审统一为「keep-all + K=64 上限」——以本 PR 行为为准;上游 #21352 的 ApplyVersion 已采纳并保留。
6. 读取路径:版本透传 + Missing 校验
6.1 API 侧(pipeline_params.go)
loadCanvasDSLJSON(ctx, canvasID, versionID *string):versionID非空 → 经UserCanvasVersionDAO.GetByID取版本;归属校验:version.UserCanvasID == canvasID(否则视作 Missing)。versionID空(脏数据/未迁移)→ 回退GetLatest(canvasID);若仍无 → 返回 Missing 错误。- 读
version.DSL替代canvas.DSL。 - 仅
canvas_category = dataflow_canvas进入上述版本化路径;agent 类画布(agent/agent_canvas)按 §1 不在范围,直接读canvas.DSL(live draft),不走版本化、不触发 Missing。
LoadPipelineDSL与ResolveComponentParamsDefaults透传versionID(从 dataset/document 的pipeline_version_id取)。
6.2 运行时(pipeline_executor.go)
TaskContext增加PipelineVersionID string;resolvePipelineID增加resolvePipelineVersionID(从doc.PipelineID/doc.PipelineVersionID解析)。loadDSLFunc签名加versionID;loadDSLFromCanvas(ctx, canvasID, versionID)改为读user_canvas_version.dsl(归属校验同上)。- ad-hoc
input.PipelineID路径同样带versionID(来源见 §12 A7)。 - Missing 处理:版本缺失/不归属 → 解析任务直接 fail(C4),错误信息含“pipeline 版本不存在,请重新选择版本”,file 进入不可解析状态;不做“是否最新”判断(决策 §3-2)。
6.3 ! 非最新指示(D1/D2/D3)
- 列表/详情接口返回 dataset/document 时,附带
pipeline_version_outdated: bool=(pipeline_version_id非空 且 ≠GetLatest(canvasID).ID)。 - 前端显示
!;点击仅弹提示(D3:不引导升级、不自动改绑定)。
7. 服务/API 变更
agent.goUpdateAgent(agent 与 dataflow 共用,经canvas_category区分):patch含dsl时调SaveOrReplaceLatestTx(携release,dataflow 恒 false)——不改。- handler 字段白名单放行
pipeline_version_id(v5.3 补充):internal/handler/dataset.go的createDatasetAllowedFields/updateDatasetAllowedFields已登记pipeline_version_id。这是“绑定具体版本”得以进入 service 层的前置条件——未登记时 create/update dataset 请求携带该字段会被 handler 直接以code 101拒绝,service 的粘性快照逻辑无从触发(见 §5 C2 注释)。该改动仅“显式放行目标字段”,未知字段仍按白名单拒绝,不退化成“全放行”。 DeleteVersion:增加引用扫描 —— 查knowledgebase/document中pipeline_version_id = 待删版本ID的引用数;允许删除(B2.1 弹窗已二次确认),引用方因版本缺失自动进入 Missing(§6.2)。仅 dataflow 生效(引用扫描范围见 §12 A4)。- 版本列表接口(
ListByCanvasID,已按 create_time DESC 满足 E1):返回含alias字段(A2/F1)。 - 版本详情/下载接口已存在(
version-dialog仅查看/下载,无删除/载入编辑入口 —— 前端需补 B2 删除按钮与 C2 选版本入口)。
- 保存分支(v5.2):
UpdateAgent按autosave字段分支(见 §5.1);自动保存仅UpdateFieldsTx更新 live draft、绝不碰user_canvas_version;手动 Save 同时更新user_canvas.dsl并 INSERT 新user_canvas_version记录(保证 user_canvas 更新与版本插入两者都发生,不复用就地刷新)。 - 版本 PATCH 端点(v5.2):
PATCH /api/v1/agents/{canvas_id}/versions/{version_id},body{alias?, title?, description?},经versionDAO.UpdateTx更新元数据,不新建版本、不改 DSL;需归属校验(version.UserCanvasID == canvas_id);仅对画布本体版本生效。
8. 前端变更
web/src/pages/agent/index.tsx:PublishConfirmDialog对 pipeline 返回 null(确认无发布动作,保持现状)。web/src/components/pipeline-version-select/index.tsx:移除LATEST_VERSION_VALUE='__latest__'伪选项(G1,v5.2)——删除:97-99的<SelectItem value={LATEST_VERSION_VALUE}>与:32/:43-49的 sentinel 映射(formValueToSelectValue/selectValueToFormValue不再用__latest__);选项文案保留并强化 alias 显示(v.alias ? v.alias + ' (' + v.title + ')' : v.title)。无预绑定时默认选中最新具体版本(GetLatest),提交发送具体pipeline_version_id,空值不再表示 latest。- dataset 设置页(
web/src/pages/dataset/setting/index.tsx:294)与文件对话框(web/src/components/document-pipeline-dialog/index.tsx:193)复用上述组件,自动生效;提交体pipeline_version_id恒为具体版本 ID。 web/src/pages/agent/index.tsx+use-save-graph.ts:自动保存(20s 去抖)请求增加autosave: true;手动 Save(handleSave)不带(v5.2,见 §5.1)。web/src/pages/agent/version-dialog/index.tsx:增加 alias/title 编辑输入,提交至新PATCH端点(G2,见 §4.4);保留删除按钮(B2/B2.1)、!提示(D1/D2/D3)、alias 显示与过滤(F1)。- dataset 设置页:增加 pipeline 版本选择(C1/C2)与
!指示(D1/D2/D3)。
9. TDD 测试清单(严格红→绿)
对抗性 fixture 强制规则(v5.1 新增,见 §13): 本特性的灵魂是
pipeline_version_id(钉住)可以 ≠GetLatest(最新)。因此 §9 每一条涉及版本的测试必须使用pinned != latest的 fixture;并且必须为「耦合字段只发其一」的 PATCH 场景各写一条测试。happy-path(pinned==latest 且两字段齐发)不算覆盖,因为它永远发现不了 B1/B2 类缺陷。
后端(unit tier,默认 bash build.sh --test)
- T1
migrateDSLVersionColumns:列不存在时加列、已存在时幂等(Error1060 容错)。 - T2
backfillDSLVersionBindings:dataflow-canvas 绑定 → 写GetLatest().ID;built-in 跳过;agent-canvas 跳过;GetLatest缺失跳过+日志;重跑幂等(system_settings 标记)。 - T3
loadCanvasDSLJSON带 versionID:取版本 DSL;归属不符 → Missing;空 versionID 回退 GetLatest。 - T4
LoadPipelineDSL透传 versionID。 - T5
ResolveComponentParamsDefaults按 version 取 default。 - T6 dataset
CreateDataset:Pipeline 模式写pipeline_version_id=GetLatest;BuiltIn 模式字段为 NULL。 - T7 dataset
UpdateDataset:pipeline 未变更 → 保留原 versionID(B4);pipeline 变更 → 重写为新 GetLatest;手动 versionID 生效且做归属校验。 - T7f(B2 对应)
UpdateDataset单字段 PATCH:只发pipeline_version_id=v-new、省略pipeline_id,且数据集已钉v-old→ 断言pipeline_version_id变为v-new(未被清空),且parser_config按v-new默认整体重置(C3/A3),归属校验以已绑PipelineID为上下文。 - T7g(B1 对应)
UpdateDataset钉旧版本 + 只改 parser_config:数据集钉v-old(≠latest),PATCH 只带parser_config、不带pipeline_version_id→ 断言重建所用 DSL 来自v-old(非 latest),parser_config结构与v-old默认一致(粘性快照 B4 不被破坏)。 - T7h(v5.3 白名单)
createDatasetAllowedFields/updateDatasetAllowedFields含pipeline_version_id:断言 create/update dataset 请求携带该字段不被 handler 白名单以code 101拒绝(端到端经此闸进入 service);对抗:未知字段__bogus_field__在两白名单中仍为 false(白名单是“显式放行”而非“全放行”)。对应internal/handler/dataset_allowed_fields_test.go。 - T8 document 绑定同样写 versionID(粘性)。
- T9
DeleteVersion引用扫描:引用存在时允许删除、引用方 Missing;无引用正常删。 - T10 运行时
loadDSLFromCanvas按 versionID 取user_canvas_version.dsl;版本缺失 → 解析 fail(C4)。 - T11
TaskContext.resolvePipelineVersionID从 doc 解析。 - T12 ad-hoc 路径带 version 校验。
- T13 归属校验:悬空/跨 canvas 的
pipeline_version_id→ 拒绝/ Missing。 - T14
!指示计算:绑定≠GetLatest → outdated=true;相等 → false。 - T15 alias 存储/返回(A2)。
- T16 built-in pipeline 不受任何版本逻辑影响(验收断言)。
- T17 迁移前存量数据可被 backfill 正确锁定。
- T18
normalizeDatasetPipelineID+ 类别校验组合。
前端
- T19 版本列表新→老(E1)。
- T20
!显示与“仅提示不引导升级”(D3)。 - T21 删除版本二次确认弹窗文案含“可能导致 File 解析不可用”(B2.1)。
- T22 选版本表单可选具体版本 + alias 过滤(C2/F1)。
- T23 dataset/file 版本选择入口与提交体含
pipeline_version_id。 - T28
!点击仅提示。 - T29 Missing 状态呈现(禁用解析 + 提示重选)。
集成/边界(按需 integration tier)
- T24 删除被引用版本后,相关 file 解析失败且错误可定位。
- T25 画布改 DSL 生成新版本、已绑定方不受影响(B4 端到端)。
- T26 切版本后 component params 重置为该版本 default(C3)。
- T27 迁移在含 built-in + dataflow 混合库上的幂等重跑。
- T30 并发保存同 canvas 不产生孤儿版本。
- T31 alias 纯前端过滤不影响后端版本返回集。
- T32(v5.2 保存语义)
UpdateAgent带autosave:true+ DSL 变更 → 断言user_canvas_version行数不变、user_canvas.dsl已更新为最新 DSL(不产生新版本)。 - T33(v5.2 保存语义)
UpdateAgent手动 Save(无autosave)→ 断言新增一条user_canvas_version记录且user_canvas.dsl已更新(即便 DSL 与上一版本相同也 INSERT 新行,不复用就地刷新;验证 user_canvas 更新与版本插入两者俱发生)。 - T34(v5.2 统一路径)agent 画布自动保存同样不刷版本(与 dataflow 共用
UpdateAgent,断言行数不变)。 - T35(v5.2 alias 写入)
PATCH /versions/{id}带alias→ 断言该行alias更新、未新建版本、DSL 不变;跨 canvas 改 alias → 归属校验拒绝。 - T36(v5.2 alias 兜底)manual Save 请求携带
alias→ 断言新建/刷新版本写入该 alias。 - T37(v5.2 前端 latest 去除)dataset/file 版本选择器选项列表不含
__latest__伪值,且含 alias 文案;提交pipeline_version_id为具体 ID(前端/组件断言)。 - T38(v5.2 对抗)连续 N 次自动保存(不刷版本)+ 仅 1 次手动 Save → 版本数恰为 1(非 N),且 live draft 反映最后一次自动保存内容;手动 Save 前后
user_canvas_version记录数差恰为 1(确认 INSERT 而非 in-place 更新)。 - T39(v5.4 空版本校验)dataset 创建/更新 Save 时
pipeline_version_id为空(且为 Pipeline 模式)→ 后端拒绝并返错误文案“版本信息缺失”(HTTP 4xx),pipeline_version_id不写入、不自动回填为 latest(呼应 C5/G3);对抗:built-in 模式允许空版本(空版本校验仅作用于 Pipeline 模式)。 - T40(v5.4 空版本校验·file 层)document/file 绑定 Save 时
pipeline_version_id为空(Pipeline 模式)→ 后端同样拒绝并返“版本信息缺失”;dataset 与 file 两层校验行为一致(C5 双层要求)。
10. 漏洞审计(v4 状态)
| 编号 | 描述 | 状态 |
|---|---|---|
| V1 | 版本未线程化到运行时(loadDSL 永远读最新) | 已修复设计:§6 改读 user_canvas_version.dsl |
| V2 | 拦截点不全(ad-hoc 路径漏校验) | 设计覆盖,待 T12 落实 |
| V3 | 别名误建版本 | 设计:alias 仅字段,不改 SaveOrReplaceLatestTx 行为 |
| V4 | ! 与 Missing 不可区分 |
设计区分:!=outdated(可解析,提示);Missing=版本不存在(不可解析) |
| V5 | C3 仅在 pipeline 变更触发 | 设计:C3 显式切版本即触发重置(§5 / T26) |
| V6 | Missing 恢复 | 设计:重新选版本即恢复,无需特殊逻辑 |
| V7 | 文件级 pipeline 绑定可编辑 | 已确认可编辑,document 级同套逻辑 |
| H12 | ! “最新”口径 |
v4 修正:dataflow 无发布 → 用 GetLatest(create_time),非 GetLatestReleased |
11. 验收清单(核心断言)
- built-in parser 绑定:不受任何版本逻辑影响,
pipeline_version_id恒 NULL。 - dataflow 绑定默认锁“当时最新具体版本”,画布后续改动不传播到已绑定方(B4)。
- 删除被引用版本:引用方进入 Missing、file 不可解析;删除前有二次确认弹窗(B2/B2.1)。
- 非最新绑定显示
!,点击仅提示,不自动升级(D3)。 - 迁移幂等:存量 dataflow 绑定被锁定到
GetLatest;重跑安全;built-in/agent 跳过。 - 悬空/跨 canvas 的
pipeline_version_id被拒绝或判 Missing(归属校验)。 - 别名仅存、仅前端过滤,后端版本返回集不受其影响。
- 空版本(Pipeline 模式
pipeline_version_id为空):UI 红色Missing徽标;Save 时报错“版本信息缺失”且不写入、不自动回填;dataset 与 file 两层一致(C5/G3/T39/T40)。
12. 决策裁定(v5 已锁定,无未决项)
-
A1 类别边界(已裁定:强制校验):绑定/运行时要求
user_canvas.canvas_category == "dataflow_canvas"。依据:canvas_category是user_canvas真实字段(值dataflow_canvas/agent_canvas);作为归属校验的强化。 -
A2 文件级范围(已裁定:覆盖 document):
document表同样加pipeline_version_id并复用全套逻辑。依据:C1 原文“dataset/file”。 -
A3 C3 重置范围(已裁定:整体重置):切版本时把 dataset/document 的
parser_config(component params)整体重置为该版本 default。依据:用户裁定 + C3 字面“参数重置为该版本 default”。(§5 已按此写) -
A4 删除语义(已裁定:允许删除→Missing):弹窗二次确认后允许删除,引用方进入 Missing。依据:用户裁定 + B2.1 文案“告知风险”。(§7 已按此写)
-
A5 alias 约束(已裁定:不约束):自由文本、可空、不要求唯一。依据:A2 原文“仅存储、前端过滤”。
-
A6 Missing 呈现(已裁定:禁用解析+提示重选):前端对 Missing(版本被删)禁用解析按钮并提示重新选版本;与
!(仍可解析、仅提示)区分。 -
A7 ad-hoc/调试路径(已裁定:同正式解析带 version):
pipeline_executor.goad-hoc 路径已 reloaddoc(L888),同法透传doc.PipelineVersionID。 -
A8 脏数据回填(已裁定:跳过+日志):backfill 遇 canvas 无版本时跳过 + 日志告警,绝不写悬空 version_id。
-
A9 保存语义修正范围(已裁定:作用于共享路径、全类别统一;用户确认 2026-10-22):手动/自动保存经
autosave信号区分;版本创建仅限手动 Save,且手动 Save 同时更新user_canvas并 INSERT 新user_canvas_version记录(agent 与 dataflow 一致);agent 发布语义(Publish)不受影响。依据:agent/dataflow 共用UpdateAgent,修正必须落在共享路径;agent 手动 Save 仍建版本,仅自动保存收敛,属良性改进且不触及 §1 范围。
设计已完全锁定,进入 §9 TDD 实现阶段。
13. 对抗性测试强制规则(v5.1 新增)
TDD 保证「代码满足测试」,不保证「测试满足需求」。本特性 review 暴露的 B1/B2 缺陷,根因不是 TDD 机制失效,而是测试从需求派生的那一环失职:fixture 全用了 pinned == latest 且两字段齐发,导致「钉住≠最新」这一 feature 核心场景从未被锻炼。
强制规则(每条需求一句对抗性测试):
- 每条需求条款必须对应至少一条对抗性测试,其 fixture 必须让该条款可证伪。若「实现错了测试也绿」,说明 fixture 没打到痛处,该条款未覆盖。
- 版本相关测试强制
pinned != latest:凡涉及pipeline_version_id的测试,fixture 中钉住的版本必须明确不等于GetLatest;并额外构造「钉住版本被后续新建版本挤成过时」的时序。 - 耦合字段必须测「只发其一」的 PATCH:
pipeline_id/pipeline_version_id是耦合对,必须为「只发 version 省略 pipeline」「只发 pipeline 省略 version」「两者都省略」各写一条,断言省略字段沿用已绑值。 - 归属/权限边界必须负向测:跨 canvas 的
pipeline_version_id、悬空pipeline_version_id、agent canvas 误绑 dataflow 版本,必须各自返回 Missing/拒绝,而非静默回落。 - 回落链必须逐元素断言:§5 的版本回落链与 canvas 上下文回落链,必须分别有测试证明「跳过已钉版本直接取 latest」会被拒、「省略 pipeline_id 让上下文变 nil」会被拒。
§9 各 T 条目(T6/T7/T14/T26 等)须按上述规则补对抗性 fixture;新实现的 RED 测试若仅用 happy-path fixture,视为未达 TDD 门禁。
- 保存语义必须测
autosave正反两例:必须分别证明「autosave:true不刷版本、仅更新 live draft」与「手动 Save 刷版本」;仅测手动 Save 而漏掉自动保存不刷版本的用例,视为未覆盖(对应 T32/T33/T38)。