From 520e98d9d19c03e17b03937ed8295542339dd5ea Mon Sep 17 00:00:00 2001 From: k2safe Date: Fri, 18 Sep 2026 00:28:00 +0800 Subject: [PATCH] feat: publish immutable workflow definitions with typed SDK --- AGENTS.md | 4 + README.md | 3 + cmd/orbit-admin/main.go | 1 + cmd/orbit/main.go | 4 +- docs/06-workflow-spec.md | 6 +- docs/08-development-plan.md | 3 +- docs/api/README.md | 3 +- docs/api/authentication.md | 2 + docs/api/workflow-definition-format.md | 102 ++++ docs/api/workflow-definitions.md | 159 ++++++ docs/api/workflows.md | 5 +- docs/examples/README.md | 6 + docs/examples/workflow-definition.md | 54 ++ docs/reports/phase-4-definitions.md | 48 ++ docs/sdk/README.md | 4 + internal/storage/mysql/bootstrap.go | 4 + .../storage/mysql/workflow_definitions.go | 258 +++++++++ .../workflow_definitions_integration_test.go | 295 ++++++++++ internal/transport/httpserver/tasks.go | 19 +- .../httpserver/workflow_definitions.go | 51 ++ .../httpserver/workflow_definitions_test.go | 58 ++ platform/auth/token.go | 2 +- scripts/test-mysql.sh | 5 + sdk/go/examples/workflow-definition/main.go | 132 +++++ sdk/go/workflow_definitions.go | 55 ++ .../workflow_definitions_integration_test.go | 109 ++++ sdk/go/workflow_types.go | 115 ++++ sdk/go/workflow_views.go | 56 ++ weave/definition/check.go | 519 ++++++++++++++++++ weave/definition/check_test.go | 211 +++++++ weave/definition/data.go | 75 +++ weave/definition/service.go | 116 ++++ weave/definition/types.go | 130 +++++ 33 files changed, 2603 insertions(+), 11 deletions(-) create mode 100644 docs/api/workflow-definition-format.md create mode 100644 docs/api/workflow-definitions.md create mode 100644 docs/examples/workflow-definition.md create mode 100644 docs/reports/phase-4-definitions.md create mode 100644 internal/storage/mysql/workflow_definitions.go create mode 100644 internal/storage/mysql/workflow_definitions_integration_test.go create mode 100644 internal/transport/httpserver/workflow_definitions.go create mode 100644 internal/transport/httpserver/workflow_definitions_test.go create mode 100644 sdk/go/examples/workflow-definition/main.go create mode 100644 sdk/go/workflow_definitions.go create mode 100644 sdk/go/workflow_definitions_integration_test.go create mode 100644 sdk/go/workflow_types.go create mode 100644 sdk/go/workflow_views.go create mode 100644 weave/definition/check.go create mode 100644 weave/definition/check_test.go create mode 100644 weave/definition/data.go create mode 100644 weave/definition/service.go create mode 100644 weave/definition/types.go diff --git a/AGENTS.md b/AGENTS.md index d08f5dd..ccb5f15 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,3 +28,7 @@ - 业务事务Outbox示例使用独立module和业务数据库,核心SDK不得引入SQL驱动。业务记录/待发命令同事务;原请求、操作ID、目标及提交键冻结,HTTP在事务外,确认按最新租约代次与业务关联同事务。24小时/100次预算内原键核对,禁止换键、重置预算或把ACCEPTED解释成业务成功;REVIEW保留待核查数据。 - 相对延迟delay_ms只在首次受理事务按数据库时间计算;原时长参与摘要,零值omitempty保持旧摘要。重报先返回原回执,禁止重算执行时间;总时限包含等待。计划模板拒绝非零delay_ms,人工重试不自动继承首次等待。 + +- P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。 + +- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。下一步实例启动/固定版本/首批任务同事务、内部事件接续、顺序与组合推进、持久化等待;P4整体和P5–P7仍未完成。 diff --git a/README.md b/README.md index 547a3da..aa7aad7 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台, 已完成 P0–P3 平台功能阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。 +P4已实现[流程定义与不可变版本发布](docs/api/workflow-definitions.md)、[结构版本1](docs/api/workflow-definition-format.md)及[SDK发布例子](docs/examples/workflow-definition.md)。实例执行、持久化等待和恢复继续开发;发布定义不会启动任务。 + 数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 CHECK 保留;readiness 同时检查迁移及零外键要求。这不表示完整平台已具备生产服务能力。验证证据见 [P0](docs/reports/phase-0.md)、[P1](docs/reports/phase-1.md)、[P2 阶段验收](docs/reports/phase-2.md)、[安全重试](docs/reports/phase-3-safe-retry.md)、[计划调度](docs/reports/phase-3-schedules.md)、[取消与无外键回归](docs/reports/phase-3-cancellation.md) 及 [队列资源回归](docs/reports/phase-3-queue-resources.md)。 ## 目录 @@ -28,6 +30,7 @@ pulse/retry/ 冻结策略、有限退避与未知效果决策 pulse/schedule/ Cron/间隔、漏跑、版本及计划接口 internal/timezone/ 随计划冻结的内置时区数据快照 weave/model/ 定义、实例、步骤、信号和查询轮次 +weave/definition/ 有限结构/类型校验与定义发布契约 internal/config/ 配置校验 internal/storage/mysql/ 连接、事务、迁移及集成测试 internal/transport/httpserver/ HTTP 接入 diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 4a3f2cf..94445c3 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -42,6 +42,7 @@ func run() error { flags.BoolVar(&in.ScheduleAccess, "schedule-access", false, "also grant schedule read and manage actions for development") flags.BoolVar(&in.EventAccess, "event-access", false, "also grant result subscription read, manage and consume actions") flags.BoolVar(&in.RecoveryAccess, "recovery-access", false, "explicitly grant task review, manual retry and operation audit read actions") + flags.BoolVar(&in.WorkflowAccess, "workflow-access", false, "explicitly grant workflow definition read and publish actions") if err := flags.Parse(os.Args[2:]); err != nil { return err } diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index 47d666d..913e62a 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -20,6 +20,7 @@ import ( "git.yuntuo.hk/k2safe/orbit/pulse/schedule" "git.yuntuo.hk/k2safe/orbit/pulse/service" "git.yuntuo.hk/k2safe/orbit/pulse/worker" + "git.yuntuo.hk/k2safe/orbit/weave/definition" ) func main() { @@ -79,6 +80,7 @@ func run() error { return err } httpserver.RegisterSchedules(server, schedule.New(schedules)) + httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db))) scheduleCtx, stopSchedules := context.WithCancel(root) scheduleDone := make(chan struct{}) go func() { @@ -132,7 +134,7 @@ func run() error { } done := make(chan error, 1) go func() { done <- server.Serve(listener) }() - slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription and Worker APIs", "node_id", node) + slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription, workflow definition and Worker APIs", "node_id", node) select { case err := <-done: if errors.Is(err, http.ErrServerClosed) { diff --git a/docs/06-workflow-spec.md b/docs/06-workflow-spec.md index 0ac7dc5..f83ca19 100644 --- a/docs/06-workflow-spec.md +++ b/docs/06-workflow-spec.md @@ -1,6 +1,6 @@ # Weave 流程编排功能与设计 -> 版本 0.2 · 2026-09-17。首版功能范围及研发定义发布方式已确认,包含有界重复查询和生产管理能力。具体字段、状态表示、算法及参数仍待技术评审;未实现、未验收。 +> 版本 0.3 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);下述实例推进、等待、查询与管理仍待P4–P5实现验收。 ## 1. 职责与边界 @@ -30,13 +30,13 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进 首版通过 SDK / 管理 API 提供运行查看与管理能力;后台操作界面可以调用这些 API,完整图形化控制台的交付范围另定。业务人员自由拖拽编排不纳入本次确认范围。 -人工审核可以由业务系统完成,再提交经过授权的信号。Weave 记录提交者和证据,不代替业务系统判断“谁有权批准这笔账”。 +人工审核可以由业务系统完成,再提交经过授权的信号。Weave 记录提交者和证据,不代替业务系统判断“谁有权批准这次操作”。 ## 3. 定义与实例身份 研发在代码中通过 Go SDK 描述步骤与规则,定义源码跟随业务项目进行版本管理和评审,再显式发布到 Orbit。业务服务每次启动不应自动覆盖已发布定义。 -建议 SDK 将流程描述生成为可校验的 JSON 定义,表达步骤、输入映射、结果结构、条件、等待、时限和失败策略。Orbit 不执行上传的任意 Go 函数;业务计算和外部请求通过命名、版本化处理器完成。SDK 的构建 API、定义 Schema 及发布命令形式待协议评审。 +建议 SDK 将流程描述生成为可校验的 JSON 定义,表达步骤、输入映射、结果结构、条件、等待、时限和失败策略。Orbit 不执行上传的任意 Go 函数;业务计算和外部请求通过命名、版本化处理器完成。SDK采用强类型结构及显式引用,schema_version=1已按[定义格式](api/workflow-definition-format.md)实现,发布接口见[手册](api/workflow-definitions.md)。 发布前至少验证引用、可达性、顶层无环、分支穷尽性、结构化汇合、类型匹配与资源上限。有界重复查询封装为一个节点,在节点内部保存轮次和截止时间,不将次数上限展开成大量静态节点。定义发布需要独立权限和审计,版本内容不可变;重复发布同版本同内容可幂等确认,不同内容明确冲突。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 88fcc21..801fd9c 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -28,7 +28,7 @@ | P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) | | P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 | | P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 已完成平台功能阶段验收:[原范围逐项核对](reports/phase-3.md)、[本轮完整回归](reports/phase-3-delay-recovery.md)。补齐相对延迟、改参数关联示例和四种重试全停恢复场景;20个可运行示例全部通过。全平台生产门槛及SDK首版交付剩余项仍按P6–P7推进 | -| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 下一阶段:先实现定义校验、不可变版本发布及独立SDK,再接实例推进与持久化等待 | +| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);实例推进、事件接续与持久化等待仍待实现 | | P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 待开发 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 | | P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 | @@ -83,6 +83,7 @@ SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最 | 业务事务Outbox | 业务记录/请求同事务 → 原键发送 → 受理/业务关联同事务 → 故障恢复 | P3,已实现独立模块与完整示例 | | 业务操作定位 | 精确业务键 → 创建区间 → 游标分页 → 查询详情 | P3,已实现完整示例 | | 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 | +| 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) | | 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 | | 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 | | 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 | diff --git a/docs/api/README.md b/docs/api/README.md index 3bc41d3..a3c4731 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -1,6 +1,6 @@ # Orbit HTTP / WebSocket 接口手册 -更新:2026-09-17。接口实现状态随 [开发计划](../08-development-plan.md) 更新。 +更新:2026-09-18。接口实现状态随 [开发计划](../08-development-plan.md) 更新。 ## 当前接口状态 @@ -26,6 +26,7 @@ | `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) | | `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) | | `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.md) | +| `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例执行尚未实现 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) | | `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) | 未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。 diff --git a/docs/api/authentication.md b/docs/api/authentication.md index d45f6bc..72703c5 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -61,3 +61,5 @@ P3 应用额度管理新增 `application:resources:read` 和 `application:resour P3 独立任务核查、人工重试和操作审计分别增加 `tasks:review`、`tasks:retry`、`tasks:audit:read`,bootstrap显式 `--recovery-access` 才授予三项;默认凭据和 `--worker-access` 都不自动获得。核查/重试记录真实认证凭据及证据,读取任务详情仍需tasks:read,见[人工恢复](task-recovery.md)。 P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。 + +P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。 diff --git a/docs/api/workflow-definition-format.md b/docs/api/workflow-definition-format.md new file mode 100644 index 0000000..976fac4 --- /dev/null +++ b/docs/api/workflow-definition-format.md @@ -0,0 +1,102 @@ +# 流程定义结构版本1 + +本页冻结已实现的发布格式与静态校验。TASK、BRANCH、PARALLEL、SIGNAL、TIMER、POLL均可描述并发布;对应实例执行器仍按P4–P5开发,发布成功不能当作流程执行成功。API见[定义发布](workflow-definitions.md)。 + +## 1. 顶层、顺序块与类型 + +顶层`schema_version`必须为1;`input_type`声明实例输入;`timeout_ms`为1毫秒至30天的总时限;`failure_policy`必填`REVIEW`或`FAIL_AFTER_SETTLED`;`body`为顺序块。未来实例总时限包含等待和暂停,不能因恢复重新计时;未知外部效果始终需要核查,不能由失败策略覆盖。 + +一个块包含`steps`(可省略为空,不能为null)、必填`output_type`和`output`。步骤顺序固定,输出表达式在全部步骤完成后解析。空块可以直接映射输入或常量。组合节点只把该块输出暴露给外部,禁止从外部读取内部步骤。 + +类型对象使用`kind`: + +| kind | 附加字段 | 契约 | +| --- | --- | --- | +| STRING | 可选`enum: string[]` | 非空枚举最多32项,值互异且每项最多128字节;无enum表示任意有效JSON字符串 | +| NUMBER | 无 | 精确JSON数字;数字token最长256字节,指数范围等沿用平台JSON边界,不以float64比较 | +| BOOLEAN / NULL | 无 | 必须精确匹配布尔或null,不做隐式转换 | +| OBJECT | `fields: map` | 0–64个字段,所有声明字段必填,额外字段不允许;空映射可省略 | +| ARRAY | 必填`items: Type` | 所有元素同型;运行数据校验最多4096项 | +| JSON | 无 | 任意合法JSON整体值;不允许根据未知字段或下标做引用 | + +类型嵌套最多8层。字段名1–64 ASCII字符,首位字母或数字,其余可含`.`、`_`、`-`;每个path元素按完整字段名匹配,点号不自动展开。类型不支持可选字段、隐式null、联合类型或脚本。 + +输入/结果校验同时受256 KiB及平台JSON深度和数字边界限制。NUMBER和STRING不互相转换;字符串枚举来源必须是目标枚举子集。OBJECT字段集合必须相同且递归兼容。目标JSON可以接收任意合法类型,JSON来源不能窄化为未验证的具体类型。 + +## 2. 数据表达式与条件 + +| Value.kind | 配置 | 说明 | +| --- | --- | --- | +| LITERAL | 必填`literal` | 任意合法常量,显式null仅用于允许NULL/JSON的目标;保留原数字文本 | +| REFERENCE | 必填`reference:{source,path?}` | source为`input`、`workflow`或当前词法作用域已完成步骤key;path最多8个已声明OBJECT字段 | +| OBJECT | `fields: map` | 构造对象,最多64个字段;输出契约为OBJECT时必须覆盖全部字段 | +| ARRAY | `items: Value[]` | 构造数组,最多256项;更大业务数组可整体引用 | + +表达式最多8层;禁止前向、自引用、跨兄弟分支引用和越过组合节点引用内部步骤。空path引用整个值,不支持数组索引、动态路径、字符串插值、当前时间、随机数或任意代码。`workflow`内置类型为`{id: STRING, business_key: STRING}`。 + +条件为`Predicate`:比较操作`EQ/NE/LT/LE/GT/GE`必须有`left`和`right`,且类型相同;有序比较只支持STRING、NUMBER,布尔和NULL只支持相等/不等。`AND/OR`使用2–16项`arguments`,`NOT`恰好1项,不能同时携带比较操作数;条件最多8层。STRING按精确文本比较,NUMBER后续执行必须保留十进制精度。 + +不属于当前kind的非空配置会拒绝;可省略集合与空集合使用统一编码。任何未识别字段仍直接拒绝。 + +## 3. 步骤 + +每步必填`key`和`kind`。key在整个定义中唯一,1–64 ASCII字符,名称规则同字段名,保留`input`、`workflow`。必须且只能携带对应的一种配置。 + +### TASK:普通业务任务 + +`task`字段: + +| 字段 | 类型 / 规则 | +| --- | --- | +| queue_id | 必填32位ID;发布时核对当前命名空间、队列存在和权限 | +| handler / handler_version / payload_version | 必填,沿用Pulse命名与版本规则,Worker承载对应实现 | +| input_type / input | 必填,处理器参数契约及构造表达式 | +| result_type | 必填,权威成功结果契约;未来不匹配不能推进后续步骤 | +| timeout_ms | 必填正值,不超过流程总时限;执行时还受实例剩余时间约束 | +| retry | 必填显式策略:max_attempts包含首次,1–100;attempt_timeout_ms等按[Pulse规则](retries.md)校验;多次尝试须提供合法退避区间 | +| recovery_safety | 必填RECONCILE_ONLY或IDEMPOTENT,不接受隐式默认策略 | +| priority | 可省略为0,0–9 | +| business_key / concurrency_key | 可选STRING表达式;业务键只用于追踪,资源键按Pulse许可语义使用 | + +输入和结果类型不能声明为NULL,任务输入不能是字面null。JSON来源仍可能在运行时产生null,实例引擎必须再次检查实际输入/结果、键长度和Pulse限制,不能把静态通过当作运行数据有效。处理器名/版本是否有在线Worker不在发布时检查,发布不要求Worker正在运行。 + +### BRANCH:条件分支 + +`branch.cases`为1–16项`{when: Predicate, body: Block}`,按顺序取第一个匹配路径;`branch.default`必须给出Block。所有路径的output_type必须等价。对外结果是选中路径输出,不允许绕过默认分支或引用仅部分路径存在的内部节点。 + +### PARALLEL:有限并行与汇合 + +`parallel.branches`为2–8项`{name,body}`,name在本节点内唯一,规则同字段名。分支分别使用进入该节点前的作用域;全部成功后汇合。节点结果为以分支name命名的OBJECT,例如`outputs.preview`通过source=`outputs`、path=`["preview"]`读取。没有任意交叉边、动态分支或任意JOIN节点。 + +### SIGNAL:等待输入 + +`signal`必填`name`(最多128)、`payload_version`(最多32)、`correlation`(STRING表达式)、`payload_type`和`timeout_ms`。名称规则同字段名;等待时限为正值且不超过流程总时限。correlation字面值必须非空且最多512字节,动态值在激活时再检查并冻结。节点输出为经payload_type验证的信号内容。 + +信号的提前到达、重复、激活关联、授权和竞态处理属于后续实例引擎;本次只验证并保存等待契约。 + +### TIMER:等待时间 + +`timer.delay_ms`为正值且不超过流程总时限;输出类型为NULL。等待不占业务Worker并发槽,实际持久化计时和恢复随后实现。 + +### POLL:有界重复查询 + +| 字段 | 规则 | +| --- | --- | +| task | 完整TASK配置,固定查询处理器与输入;只能查询既有作业,不能重复发起原业务提交 | +| max_rounds | 1–1000,包含首次查询;每轮单独Pulse任务,单轮重试不增加查询轮次 | +| timeout_ms | 正值,不超过流程时限;task.timeout_ms不得超过此值 | +| interval_ms | 至少1000,必须小于查询总时限;从“仍在处理”结果受理后起算,不补发错过轮次 | +| status_path | 可省略为空,引用task.result_type中的STRING枚举;空表示整个结果就是枚举 | +| pending / succeeded | 各至少1个枚举值 | +| failed / review | 可省略为空,与前两组互斥;四组必须无重复、完整覆盖状态枚举 | +| final_signal | 可选SIGNAL;类型必须等价于查询结果,timeout_ms必须等于查询总时限 | + +查询成功且PENDING意味着继续等待;网络或执行错误才按该轮Pulse策略重试。两种预算独立,节点总时限不延长。最终信号不得是PENDING;轮次/时限耗尽、信号与查询同时到达的处理仍由后续运行器实现。发布格式中的配置不提供任意循环能力。 + +## 4. 整体预算与兼容 + +最多128个静态步骤(组合节点也计数),顶层块深度0,嵌套块深度不超过4;嵌套并行计算出的业务任务同时运行上界不超过32。SIGNAL/TIMER不占此业务任务并发。所有静态TASK的max_attempts加所有POLL的`max_rounds × max_attempts`不超过10000;条件各路径也全部计入保守预算,不依据当前输入缩减。 + +定义大小、JSON总深度、类型深度和块深度同时适用,任何一项超限都拒绝;不能仅满足步骤数就忽略深度。完整定义在调用方编码后才有最终字节大小。 + +结构版本1的既有定义解释必须兼容;将来若改变语义或冻结内容格式,应增加schema_version和独立校验/执行分支,不能原地修改旧版本。业务版本名由研发显式推进;运行实例固定版本及后续恢复兼容是P4–P7待验收项。 diff --git a/docs/api/workflow-definitions.md b/docs/api/workflow-definitions.md new file mode 100644 index 0000000..31e993b --- /dev/null +++ b/docs/api/workflow-definitions.md @@ -0,0 +1,159 @@ +# 流程定义与不可变版本 + +状态:P4 定义校验、持久化发布及独立 Go SDK 已实现;实例启动、推进、等待和恢复尚未实现。规则格式见[结构版本1](workflow-definition-format.md),可运行源码及命令见[研发发布场景](../examples/workflow-definition.md)。 + +## 1. 权限、传输与范围 + +使用 HTTPS、`Authorization: Bearer `、统一 `request_id` 信封。POST 必须有唯一 `Idempotency-Key` 头及 `Content-Type: application/json`。请求体最多512 KiB,完整定义编码最多256 KiB,JSON总深度最多32;拒绝未知字段、大小写别名、重复字段、非法Unicode、无效数字和不符合类型的null。 + +| 接口 | 权限 | 范围 | +| --- | --- | --- | +| `POST /v1/workflow-definitions` | `workflows:definitions:publish` | 当前凭据命名空间 | +| `GET /v1/workflow-definitions/{definition_id}` | `workflows:definitions:read` | 当前命名空间元数据,不返回规则 | +| `POST /v1/workflow-definitions/{definition_id}/versions` | `workflows:definitions:publish` | 定义及其引用的全部队列 | +| `GET /v1/workflow-definitions/{definition_id}/versions/{version}` | `workflows:definitions:read` | 定义及其引用的全部队列 | + +发布与完整版本读取均在事务中核对队列存在、命名空间归属及当前凭据队列范围。发布不要求`tasks:submit`,不创建任务;发布者可以与运行方分权。默认Bootstrap凭据没有流程权限,显式`--workflow-access`才增加上述两个动作。读取元数据只受命名空间级定义权限限制。 + +GET不接受查询参数。当前没有定义列表、修改、删除或latest接口;调用方保存创建回执中的定义ID,并显式使用版本名。 + +## 2. 创建定义 + +`POST /v1/workflow-definitions` + +| 字段 | 类型 | 规则 | +| --- | --- | --- | +| name | string | 必填,1–128 ASCII字符;首位字母或数字,其余可含`.`、`_`、`-`;同命名空间唯一且区分大小写 | +| description | string | 可省略为空,最多1024个Unicode字符,不允许NUL | + +```json +{"name":"object.pipeline","description":"通用对象作业"} +``` + +首次创建201,原键重报200。回执示意(实际ID、时间由服务端生成): + +```json +{ + "request_id":"0123456789abcdef0123456789abcdef", + "data":{ + "definition":{ + "id":"1123456789abcdef0123456789abcdef", + "namespace_id":"2123456789abcdef0123456789abcdef", + "name":"object.pipeline", + "description":"通用对象作业", + "revision":"1", + "created_at":"2026-09-18T00:00:00Z", + "updated_at":"2026-09-18T00:00:00Z" + }, + "deduplicated":false + } +} +``` + +`GET /v1/workflow-definitions/{definition_id}`的`data`直接返回上述`definition`对象。`revision`为字符串编码的uint64,初始1,每个新版本发布增加1。名称和说明在本阶段没有修改入口。原创建回执保留创建时的revision,当前值通过GET查询。 + +每个命名空间最多100个定义。不同键重用同一名称返回409;恢复创建结果必须使用原键,不能用名称猜测操作是否成功。 + +## 3. 发布版本 + +`POST /v1/workflow-definitions/{definition_id}/versions` + +| 字段 | 类型 | 规则 | +| --- | --- | --- | +| version | string | 必填,1–64 ASCII字符,名称规则同上;研发指定的业务版本名,不与schema_version混用 | +| specification | object | 必填,完整结构版本1定义;所有生效任务策略显式冻结 | +| reason | string | 必填非空白,最多1024个Unicode字符,不允许NUL,写入发布审计 | + +最小定义直接返回输入,可用于验证发布链路: + +```json +{ + "version":"v1", + "reason":"initial developer release", + "specification":{ + "schema_version":1, + "input_type":{"kind":"STRING"}, + "timeout_ms":60000, + "failure_policy":"REVIEW", + "body":{ + "output_type":{"kind":"STRING"}, + "output":{"kind":"REFERENCE","reference":{"source":"input"}} + } + } +} +``` + +首次发布201;原键重报或不同键的同版本同内容发布200。信封`data`为: + +```json +{ + "version":{ + "id":"3123456789abcdef0123456789abcdef", + "namespace_id":"2123456789abcdef0123456789abcdef", + "definition_id":"1123456789abcdef0123456789abcdef", + "version":"v1", + "specification":{ + "schema_version":1, + "input_type":{"kind":"STRING"}, + "timeout_ms":60000, + "failure_policy":"REVIEW", + "body":{ + "output_type":{"kind":"STRING"}, + "output":{"kind":"REFERENCE","reference":{"source":"input"}} + } + }, + "content_digest":"0000000000000000000000000000000000000000000000000000000000000000", + "published_by":"4123456789abcdef0123456789abcdef", + "published_at":"2026-09-18T00:00:00Z", + "analysis":{"steps":0,"max_concurrency":0,"max_task_attempts":0,"queues":[]} + }, + "deduplicated":false +} +``` + +示意中的全零摘要不是上述请求的实际摘要。实际`content_digest`为64位小写十六进制SHA-256:对已校验结构的规范JSON加域前缀`orbit.weave.spec.v1\0`后计算。对象键顺序、数字的等值十进制表示不影响内容比较;数组顺序属于内容,包括枚举/状态分组数组。数字全程保留精度,不使用float64生成内容摘要。 + +`published_by`是最初发布凭据ID,`published_at`是原始数据库UTC时间;重复发布不改写。`analysis`按全部静态路径保守计算节点、并发与任务尝试上界,队列排序去重且始终为数组。它不是运行用量或已授予配额。 + +每个定义最多100个版本。达到上限仍可原键核对和同版本同内容确认。发布v2不改写v1。数据库不可变触发器拒绝UPDATE,关联关系由事务校验,无外键、无级联删除。 + +## 4. 幂等、并发及未知结果 + +- 创建操作按命名空间和`workflow.definition.create`隔离;发布按命名空间、`workflow.version.publish`及定义ID隔离。幂等键格式和90天保留窗口沿用[公共规则](tasks.md)。 +- 原键绑定整个命令,包括`reason`。原键不同内容为`IDEMPOTENCY_CONFLICT`;换键不允许覆盖已存在版本,不同规则为`STATE_CONFLICT`。 +- 不同键、相同版本及相同规则返回第一次的版本。后续请求的原因不替换原审计,不增加父定义revision,不重复产生发布审计。 +- 并发控制顺序为认证共享锁→应用额度行→操作身份→定义行→排序后的引用队列。定义、版本、父定义revision、审计和幂等回执同事务提交,审计失败全部回滚。 +- 任何超时、连接中断或503都可能发生在提交前后。保留原ID、版本、内容、原因和键核对,禁止生成新身份或盲目修改规则重发。 +- 每次重报仍核对当前权限和引用队列;权限被收回、队列缺失时不得用旧回执绕过。版本读取还验证存储原文、结构版本和内容摘要,损坏或不兼容数据返回503,不能默默忽略后继续使用。 + +## 5. 读取与错误 + +`GET /v1/workflow-definitions/{definition_id}/versions/{version}`的`data`直接返回上述`version`对象,200。输入版本名被严格限制,不能携带路径片段。 + +| 状态 / code | 处理 | +| --- | --- | +| 400 / INVALID_ARGUMENT | 修正结构、引用、类型、预算或字段;此校验拒绝不发布版本 | +| 401 / UNAUTHENTICATED | 核对凭据与有效期 | +| 403 / PERMISSION_DENIED | 缺少定义动作权限 | +| 404 / NOT_FOUND | 定义/版本/队列不存在、不同命名空间或队列范围不允许;不据此猜测他人资源 | +| 409 / IDEMPOTENCY_CONFLICT | 原键绑定不同命令;回查原操作,不直接换键 | +| 409 / STATE_CONFLICT | 名称冲突或同版本不同规则;有意更新需显式发布新版本 | +| 409 / WORKFLOW_DEFINITION_LIMIT | 命名空间达到100个定义,原创建回执仍可恢复 | +| 409 / WORKFLOW_VERSION_LIMIT | 定义达到100个版本,旧版本确认与读取不受此上限影响 | +| 413 / BODY_TOO_LARGE | 整个HTTP请求超过512 KiB | +| 415 / UNSUPPORTED_MEDIA_TYPE | 必须使用application/json | +| 503 / UNAVAILABLE | 服务/事务/存储完整性异常,原身份核对;持续异常需要运维核查 | + +## 6. SDK + +独立module为`git.yuntuo.hk/k2safe/orbit/sdk/go`,无服务端模型、数据库或ORM依赖。 + +| 方法 | 输入 → 返回 | +| --- | --- | +| `CreateWorkflowDefinition(ctx,key,in)` | CreateWorkflowDefinitionRequest → WorkflowDefinitionResult | +| `GetWorkflowDefinition(ctx,id)` | 定义ID → WorkflowDefinition | +| `PublishWorkflowVersion(ctx,id,key,in)` | PublishWorkflowVersionRequest → WorkflowVersionResult | +| `GetWorkflowVersion(ctx,id,version)` | 定义ID及固定版本 → WorkflowVersion | +| `WorkflowRef(source,path...)` | 构造有类型校验的引用表达式;发布时校验作用域 | + +规则通过`WorkflowSpecification`、`WorkflowStep`等强类型结构构建,常量使用`json.RawMessage`,避免把大整数先转float64。SDK不复制服务端完整校验器;发布返回权威校验结果。网络错误不会自动重试发布,也不自动变更版本。完整示例见[发布场景](../examples/workflow-definition.md)。 diff --git a/docs/api/workflows.md b/docs/api/workflows.md index 17ad248..b9d48fa 100644 --- a/docs/api/workflows.md +++ b/docs/api/workflows.md @@ -1,10 +1,9 @@ -# 流程接口规划 +# 流程接口状态 -状态:计划 P4–P5,尚未实现。研发通过 Go SDK 描述流程并显式发布,服务端保存不可变定义版本。 +状态:定义格式校验、创建与不可变版本发布已实现,详见[定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md)和[可运行SDK例子](../examples/workflow-definition.md)。实例执行与管理仍按P4–P5开发。下表仅列后续实例接口。 | 操作 | 候选接口 | 完成要求 | | --- | --- | --- | -| 发布版本 | `POST /v1/workflow-definitions/{definition_id}/versions` | 图结构、类型、预算校验;同版本同内容幂等,不同内容冲突 | | 启动实例 | `POST /v1/workflows` | 固定版本并幂等启动,首批任务与实例原子创建 | | 查询实例 | `GET /v1/workflows/{workflow_id}` | 状态、暂停标记、版本、等待原因、预算摘要 | | 查询步骤 | `GET /v1/workflows/{workflow_id}/steps` | 步骤、激活、查询轮次、输入/结果引用与任务关联 | diff --git a/docs/examples/README.md b/docs/examples/README.md index 2579695..68d02ba 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -2,6 +2,12 @@ 示例只使用通用的作业、对象、文件、资源与审核信号,不包含接入方项目名称或行业规则。 +## 已可运行:研发定义与发布流程 + +- [完整命令和结果](workflow-definition.md)、[SDK源码](../../sdk/go/examples/workflow-definition/main.go)。 +- 六类节点的有限结构定义、原键及同版本确认、新版本发布、旧版本读取保持不变。 +- 当前只发布定义,实例执行仍在P4–P5开发。 + ## 已可运行:业务事务与任务提交Outbox - 独立模块:[task-outbox](../../examples/task-outbox/README.md),[完整前置、命令、Go方法及故障处理](task-outbox.md)。 diff --git a/docs/examples/workflow-definition.md b/docs/examples/workflow-definition.md new file mode 100644 index 0000000..cd7e6ef --- /dev/null +++ b/docs/examples/workflow-definition.md @@ -0,0 +1,54 @@ +# 研发定义与发布流程 + +本例运行真实HTTP发布与读取,验证六种节点的格式以及不可变版本。当前不创建流程实例、不执行外部业务。实例运行示例随P4–P5交付。 + +源码:[workflow-definition/main.go](../../sdk/go/examples/workflow-definition/main.go)。接口:[定义发布](../api/workflow-definitions.md);字段规范:[结构版本1](../api/workflow-definition-format.md)。 + +## 1. 准备与运行 + +使用专用开发环境,MySQL已完成当前迁移、服务已启动,配置方式见[SDK接入](../sdk/README.md)。从仓库根目录初始化专用发布凭据(ORBIT_MYSQL_DSN已在当前环境安全设置): + +```sh +bin/orbit-admin bootstrap --application definition-demo --namespace default --environment test --queue workflow --workflow-access --output /tmp/orbit-definition.credentials.json +``` + +首次创建文件后保留,不要覆盖或打印凭据内容。该标志只增加定义读取和发布动作;生产凭据应限制到需要的命名空间及队列。 + +```sh +cd sdk/go +ORBIT_URL=http://127.0.0.1:8080 \ +ORBIT_CREDENTIALS_FILE=/tmp/orbit-definition.credentials.json \ +ORBIT_OPERATION_ID=object-definition-demo-001 \ +go run ./examples/workflow-definition +``` + +开发回环地址可用HTTP,生产使用HTTPS。可改用`ORBIT_TOKEN`及`ORBIT_QUEUE_ID`,不把Token写入URL或提交版本库。不传`ORBIT_OPERATION_ID`时随机生成并先输出;发生未知结果时保存此操作ID,保持相同队列及源码再次运行。已成功的相同操作ID也可以重复执行,旧版本与revision不会增加。 + +## 2. 定义内容与调用链 + +输入契约为`{object_ref: STRING, operation: STRING, approved: BOOLEAN}`。示例依次定义: + +1. `prepare`:对象准备TASK,引用input.object_ref,输出STRING。 +2. `route`:根据input.approved选择路径,带显式默认分支。 +3. `outputs`:并行定义preview与manifest两个TASK,全部成功后输出命名结果。 +4. `approval`:等待与input.operation关联的布尔审核信号。 +5. `settle`:1000毫秒TIMER等待。 +6. `result`:有界POLL查询,最多5轮、每轮最多2次尝试、总时限20秒;PENDING、DONE、FAILED、REVIEW四种状态完全分组,并声明可选最终回执。 + +实际调用:CreateWorkflowDefinition → PublishWorkflowVersion(v1) → 原键确认v1 → 新键同内容确认v1 → 发布v2 → GetWorkflowVersion(v1) → GetWorkflowDefinition。v2仅把整个流程时限从60秒改到90秒;测试确认v1内容和原发布身份保持不变。 + +可直接在自己的Go代码中构建强类型`orbit.WorkflowSpecification`;`orbit.WorkflowRef("input","object_ref")`生成引用。常量使用`json.RawMessage`保留精确JSON数字。条件、步骤与处理器都在研发代码中声明,不上传任意可执行代码。 + +## 3. 预期输出与异常 + +输出包含操作ID、定义ID、v1/v2版本记录ID以及以下静态校验结果: + +```text +revision=3 +steps=8 max_concurrency=2 max_task_attempts=13 +Both definitions are stored. No workflow instance or business task has been started. +``` + +HTTP响应未知时使用原操作ID及相同源码核对,不临时更换版本或键。若有意更改v1内容则返回409,需研发评审并显式发布新业务版本。缺少定义权限为403;引用队列不存在或越权为404;类型/引用/预算错误为400。审计失败不会留下半个发布版本,持续503需核查存储及服务状态。 + +本例保留定义和发布审计,不直接删库清理;目前没有删除接口。每命名空间最多100个定义,每定义100个版本,重复运行优先复用操作ID。完整隔离回归由`scripts/test-mysql.sh`负责数据库销毁,不能对共享环境做硬删除。 diff --git a/docs/reports/phase-4-definitions.md b/docs/reports/phase-4-definitions.md new file mode 100644 index 0000000..5a107a3 --- /dev/null +++ b/docs/reports/phase-4-definitions.md @@ -0,0 +1,48 @@ +# P4 子项:定义校验与不可变发布 + +日期:2026-09-18。本子项已通过验收。范围是研发定义格式、静态校验、定义创建、版本发布/读取及独立SDK。实例启动、推进、信号/时间等待、暂停/恢复和轮询执行仍未实现;本报告不构成P4整体或生产高可用验收。 + +## 实现 + +- `weave/definition`提供schema_version=1结构及类型契约,六类节点、词法引用、条件、并行汇合和有限预算校验。不会执行上传源码。 +- 创建/发布/读取四个HTTP路由,两个显式权限与`--workflow-access`初始化标志。API严格匹配字段,映射值递归检查;仅DSL常量允许显式null,普通任务参数限制保持不变。 +- 复用现有两张定义表,未新增迁移、外键、级联或数据库依赖。发布在应用→身份→定义→队列顺序下串行化,版本/revision/审计/回执同事务。中文字段注释随代码保留。 +- 同键绑定完整命令,同版本按规则摘要比较;数字使用精确JSON规范化。读取核对原文及结构摘要,损坏或悬空关联拒绝;重复请求仍检查当前队列权限。 +- 独立SDK强类型定义、引用构建及四个客户端方法;完整六类节点发布例子和[API](../api/workflow-definitions.md)、[格式](../api/workflow-definition-format.md)、[场景](../examples/workflow-definition.md)同步。 + +## 回归证据 + +环境:macOS arm64,Go 1.27.1;Docker隔离MySQL 8.4.10,随机秘密/回环端口,由脚本创建并清理临时容器,不连接已有业务库。 + +| 检查 | 结果 | +| --- | --- | +| make fmt / test(race)/ vet / build | 通过;覆盖服务端、SDK与独立Inbox/Outbox模块 | +| DSL定向单测 | 顺序/分支/并行类型与作用域、禁用前向/跨分支引用、默认路径、策略完整性、轮次与尝试预算、状态完整分组、深度/数量上限、精确数值与非变更检查通过 | +| HTTP严格JSON | 嵌套map大小写别名/未知字段/重复键/null集合拒绝;DSL显式null常量可用,普通任务null仍拒绝 | +| 数据校验模糊测试 | 2个并行worker、10秒目标,15699次执行,通过(工具耗时11.278秒) | +| 真MySQL发布定向回归 | `TestIntegrationWorkflowDefinitions -count=3`通过,总12.756秒;包含并发、权限、无外键关联、回滚、损坏及数量上限 | +| 真HTTP提交后回执丢失 | 新SDK集成回归通过,1.314秒;服务确认201后代理断开连接,首次调用明确报错,原键重报返回同一版本,revision不重复增长 | +| 六类节点可运行发布例子 | 通过;v1/v2发布及旧版读取,revision=3、steps=8、max_concurrency=2、max_task_attempts=13;没有启动实例 | +| 独立SDK依赖 | GOWORK=off列出依赖,不包含服务端internal/model/platform/pulse/weave、MySQL或ORM | +| 完整隔离链路 | scripts/test-mysql.sh终态退出0;所有19个SDK例子及独立Inbox/Outbox两个例子通过,临时容器已清理 | + +并发用例以12个调用竞争6个幂等身份,均取得同一不可变版本,只有一次新发布;随后验证等值小数重报、相邻大整数内容冲突、v2不改变v1、审计次数与父revision,数据库触发器拒绝直接UPDATE。发布不产生Pulse任务。 + +权限用例覆盖默认凭据无定义权限、原回执队列权限收回、跨命名空间队列/定义、不存在的父定义/队列、手工删除关联后的拒绝。真实数据库断言外键为0。 + +故障用例通过隔离库审计写入失败使版本/父revision/幂等身份全部回滚,再用原键成功发布;另外新增损坏摘要记录及删除父记录,确认读取失败。100个定义/100个版本上限真实由API填满,达到上限仍恢复旧回执。 + +首次完整回归在发布人读取上发现VARCHAR文本标识被当作BINARY ID扫描;修正为文本读取后再校验ID。三次定向回归已覆盖修正。本报告保留此失败记录,未将此前未到达的SDK链路视为通过。 + + +最终完整回归中,存储集成108.239秒(定义子项2.42秒),Worker SDK91.133秒,计划5.782秒,队列4.458秒,批量1.378秒,查询1.335秒,订阅管理33.363秒,事件消费52.103秒,任务恢复3.418秒,定义SDK1.314秒;独立Inbox35.669秒、Outbox33.839秒,应用额度1.481秒。10个SDK进程故障父用例全部通过,总95.274秒,其中重试全停恢复四场景33.10秒;独立Inbox/Outbox另各有一个进程故障父用例。本次全链路零失败,已确认终态退出及容器清理,没有以日志片段或等待超时替代完成结论。 + +复现:`make fmt test vet build`、`make integration`;定向用例为`go test -race -count=3 -run '^TestIntegrationWorkflowDefinitions$' ./internal/storage/mysql`(只在隔离库设置ORBIT_TEST_MYSQL_DSN)。SDK定义用例在全量脚本中使用专门签发的流程权限运行。最后完整回归后仅补文档证据,没有修改运行代码。 + +## 后续边界 + +P4下一步是启动实例、固定版本与输入、同事务创建首批任务、内部事件接续和有界推进;继而交付顺序/条件/并行、信号与持久化定时等待。P5补齐轮询运行、门控、取消及有证据人工恢复。现在发布格式支持节点,不等于节点运行器已存在。 + +启动时必须再次核对定义版本、当前权限、队列与实际运行参数;JSON来源可能为null或超出Pulse字段限制,不能以静态校验代替运行校验。数据库列、已保存规则和SDK版本兼容都需要进入后续升级回归。 + +独立发布操作的数据库COMMIT回执丢失尚未单独注入;已有回滚及HTTP提交后回执丢失场景不能替代全部存储故障。定义列表、删除/归档、配置化额度、凭据轮换、容量、MySQL故障切换、备份和滚动升级继续按P6–P7处理;不承诺未经验证的生产吞吐和可用性。 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 8ac4029..b6622a6 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -198,3 +198,7 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) ## 订阅管理示例 [完整运行方法](../examples/subscriptions.md) 使用显式 --event-access 凭据,执行创建/同键核对/暂停/恢复/查询,再暂停本次订阅并保留审计。源码见 [examples/subscriptions](../../sdk/go/examples/subscriptions/main.go)。WSS 连接 SDK 与自动消费器见 [消费手册](event-consumer.md),可运行 [协议示例](../examples/event-protocol.md) 验证跨连接 ACK;[持久化 Inbox 示例](../examples/result-inbox.md) 已独立交付,核心SDK不新增数据库驱动。 + +## 流程定义发布 + +已提供独立WorkflowSpecification类型及CreateWorkflowDefinition、GetWorkflowDefinition、PublishWorkflowVersion、GetWorkflowVersion四个HTTP方法。业务侧研发显式发布,服务端冻结版本;当前不包含实例执行方法。见[接口与字段](../api/workflow-definitions.md)、[结构版本1](../api/workflow-definition-format.md)、[完整可运行场景](../examples/workflow-definition.md)。 diff --git a/internal/storage/mysql/bootstrap.go b/internal/storage/mysql/bootstrap.go index effc76e..39d9098 100644 --- a/internal/storage/mysql/bootstrap.go +++ b/internal/storage/mysql/bootstrap.go @@ -14,6 +14,7 @@ import ( // BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。 type BootstrapInput struct { + WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。 RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。 EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。 Application string // Application 新应用名称,与 ExistingApplicationID 二选一。 @@ -76,6 +77,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( if in.WorkerAccess { permissions.Actions = append(permissions.Actions, "workers:connect", "workers:consume") } + if in.WorkflowAccess { + permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish") + } if in.ScheduleAccess { permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage") } diff --git a/internal/storage/mysql/workflow_definitions.go b/internal/storage/mysql/workflow_definitions.go new file mode 100644 index 0000000..e10f3d7 --- /dev/null +++ b/internal/storage/mysql/workflow_definitions.go @@ -0,0 +1,258 @@ +package mysql + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/internal/jsonvalue" + "git.yuntuo.hk/k2safe/orbit/internal/jsonwire" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/weave/definition" + driver "github.com/go-sql-driver/mysql" +) + +// WorkflowDefinitionStore 只管理定义与不可变版本,关联校验、幂等和审计同事务,不创建业务任务。 +type WorkflowDefinitionStore struct{ *TaskStore } + +func NewWorkflowDefinitionStore(db *sql.DB) *WorkflowDefinitionStore { + return &WorkflowDefinitionStore{NewTaskStore(db)} +} + +var _ definition.Store = (*WorkflowDefinitionStore)(nil) + +func readWorkflowDefinition(ctx context.Context, tx *sql.Tx, ns, id core.ID, lock bool) (definition.Summary, error) { + var out definition.Summary + q := `SELECT id,namespace_id,name,description,revision,created_at,updated_at FROM weave_definitions WHERE namespace_id=? AND id=?` + if lock { + q += ` FOR UPDATE` + } + err := tx.QueryRowContext(ctx, q, ns, id).Scan(&out.ID, &out.NamespaceID, &out.Name, &out.Description, &out.Revision, &out.CreatedAt, &out.UpdatedAt) + if errors.Is(err, sql.ErrNoRows) { + err = fault.NotFound + } + return out, err +} +func workflowAnalysis(c definition.Checked) definition.Analysis { + queues := append([]core.ID{}, c.Queues...) + return definition.Analysis{Steps: c.Steps, MaxConcurrency: c.MaxConcurrency, MaxTaskAttempts: c.MaxTaskAttempts, Queues: queues} +} +func readWorkflowVersion(ctx context.Context, tx *sql.Tx, ns, id core.ID, version string) (definition.VersionView, error) { + var out definition.VersionView + var raw []byte + var schema uint32 + var actor string + var digest core.Digest + err := tx.QueryRowContext(ctx, `SELECT id,namespace_id,definition_id,version,schema_version,specification,content_digest,published_by,created_at FROM weave_definition_versions WHERE namespace_id=? AND definition_id=? AND version=?`, ns, id, version).Scan(&out.ID, &out.NamespaceID, &out.DefinitionID, &out.Version, &schema, &raw, &digest, &actor, &out.PublishedAt) + if errors.Is(err, sql.ErrNoRows) { + return out, fault.NotFound + } + if err != nil { + return out, err + } + out.PublishedBy = core.ID(actor) + if out.PublishedBy.Validate() != nil { + return out, errors.New("invalid stored workflow publisher") + } + if len(raw) > definition.MaxSpecificationBytes || json.Unmarshal(raw, &out.Specification) != nil || out.Specification.SchemaVersion != schema { + return out, errors.New("invalid stored workflow specification") + } + checked, err := definition.Check(out.Specification) + if err != nil { + return out, err + } + // 同时检查原文,不能让JSON解码忽略的未知字段或大小写别名绕过完整性验证。 + canonical, err := jsonvalue.Canonical(raw) + if err != nil { + return out, err + } + rawDigest := core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...))) + if digest != checked.Digest || digest != rawDigest { + return out, errors.New("workflow version digest mismatch") + } + out.ContentDigest = hex.EncodeToString(digest[:]) + out.Analysis = workflowAnalysis(checked) + return out, nil +} +func authorizeWorkflowQueues(ctx context.Context, tx *sql.Tx, ns core.ID, p auth.Permissions, queues []core.ID) error { + for _, id := range queues { + if !p.AllowsQueue(id) { + return fault.NotFound + } + var found core.ID + err := tx.QueryRowContext(ctx, `SELECT id FROM pulse_queues WHERE namespace_id=? AND id=? FOR SHARE`, ns, id).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return fault.NotFound + } + if err != nil { + return err + } + } + return nil +} +func (s *WorkflowDefinitionStore) Create(ctx context.Context, t auth.Token, key string, in definition.CreateInput) (definition.CreateResult, error) { + var out definition.CreateResult + err := s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error { + if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil { + return err + } + id, err := core.NewID() + if err != nil { + return err + } + identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.definition.create", "", key, "workflow_definition", id, in) + if err != nil { + return err + } + if cached != nil { + if err := json.Unmarshal(cached, &out); err != nil { + return err + } + out.Deduplicated = true + return nil + } + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definitions WHERE namespace_id=?`, t.NamespaceID).Scan(&count); err != nil { + return err + } + if count >= definition.MaxDefinitions { + return fault.New("WORKFLOW_DEFINITION_LIMIT", "namespace workflow definition limit reached") + } + now, err := dbNow(ctx, tx) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO weave_definitions(namespace_id,id,name,description,created_at,updated_at) VALUES (?,?,?,?,?,?)`, t.NamespaceID, id, in.Name, in.Description, now, now) + var duplicate *driver.MySQLError + if errors.As(err, &duplicate) && duplicate.Number == 1062 { + return fault.Conflict + } + if err != nil { + return err + } + out.Definition, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false) + if err != nil { + return err + } + if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.definition.create", "create workflow definition", nil, out.Definition); err != nil { + return err + } + return saveIdentity(ctx, tx, t.NamespaceID, identity, out) + }) + return out, err +} +func (s *WorkflowDefinitionStore) Get(ctx context.Context, t auth.Token, id core.ID) (definition.Summary, error) { + var out definition.Summary + err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, _ auth.Permissions) error { + var err error + out, err = readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false) + return err + }) + return out, err +} +func (s *WorkflowDefinitionStore) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in definition.PublishInput) (definition.PublishResult, error) { + var out definition.PublishResult + checked, err := definition.Check(in.Specification) + if err != nil { + return out, fault.Invalid(err.Error()) + } + err = s.transaction(ctx, t, definition.PublishAction, func(tx *sql.Tx, p auth.Permissions) error { + if _, err := lockApplicationResources(ctx, tx, t.NamespaceID, false); err != nil { + return err + } + // 摘要身份冻结整个命令(含原因),同版本去重只比较specification内容摘要。 + identity, cached, err := reserveIdentity(ctx, tx, t.NamespaceID, "workflow.version.publish", string(id), key, "workflow_definition", id, in) + if err != nil { + return err + } + before, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, true) + if err != nil { + return err + } + if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, checked.Queues); err != nil { + return err + } + stored, err := readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version) + if err != nil && !errors.Is(err, fault.NotFound) { + return err + } + if cached != nil { + if err != nil { + return errors.New("published workflow version missing") + } + if err := json.Unmarshal(cached, &out); err != nil { + return err + } + if out.Version.ID != stored.ID || stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) { + return errors.New("workflow publication receipt mismatch") + } + out.Version = stored + out.Deduplicated = true + return nil + } + if err == nil { + if stored.ContentDigest != hex.EncodeToString(checked.Digest[:]) { + return fault.Conflict + } + out = definition.PublishResult{Version: stored, Deduplicated: true} + return saveIdentity(ctx, tx, t.NamespaceID, identity, out) + } + var count int + if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, t.NamespaceID, id).Scan(&count); err != nil { + return err + } + if count >= definition.MaxVersions { + return fault.New("WORKFLOW_VERSION_LIMIT", "workflow definition version limit reached") + } + versionID, err := core.NewID() + if err != nil { + return err + } + raw, err := jsonwire.Marshal(in.Specification) + if err != nil { + return err + } + now, err := dbNow(ctx, tx) + if err != nil { + return err + } + _, err = tx.ExecContext(ctx, `INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?,?,?)`, t.NamespaceID, versionID, id, in.Version, in.Specification.SchemaVersion, string(raw), checked.Digest, string(t.CredentialID), now, now) + if err != nil { + return err + } + if _, err := tx.ExecContext(ctx, `UPDATE weave_definitions SET revision=revision+1,updated_at=? WHERE namespace_id=? AND id=?`, now, t.NamespaceID, id); err != nil { + return err + } + out.Version, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, in.Version) + if err != nil { + return err + } + // 审计记录固定版本、摘要和预算,不复制潜在敏感的业务常量。 + audit := map[string]any{"version_id": versionID, "version": in.Version, "content_digest": out.Version.ContentDigest, "analysis": out.Version.Analysis, "revision": before.Revision + 1} + if err := appendAudit(ctx, tx, t, identity, id, "workflow_definition", "workflow.version.publish", in.Reason, map[string]any{"revision": before.Revision}, audit); err != nil { + return err + } + return saveIdentity(ctx, tx, t.NamespaceID, identity, out) + }) + return out, err +} +func (s *WorkflowDefinitionStore) Version(ctx context.Context, t auth.Token, id core.ID, version string) (definition.VersionView, error) { + var out definition.VersionView + err := s.transaction(ctx, t, definition.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + if _, err := readWorkflowDefinition(ctx, tx, t.NamespaceID, id, false); err != nil { + return err + } + var err error + out, err = readWorkflowVersion(ctx, tx, t.NamespaceID, id, version) + if err != nil { + return err + } + return authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, out.Analysis.Queues) + }) + return out, err +} diff --git a/internal/storage/mysql/workflow_definitions_integration_test.go b/internal/storage/mysql/workflow_definitions_integration_test.go new file mode 100644 index 0000000..a438fdb --- /dev/null +++ b/internal/storage/mysql/workflow_definitions_integration_test.go @@ -0,0 +1,295 @@ +package mysql + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "os" + "reflect" + "sync" + "testing" + "time" + + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" + "git.yuntuo.hk/k2safe/orbit/weave/definition" +) + +type workflowFixture struct { + t *testing.T + ctx context.Context + db *sql.DB + resource BootstrapResult + token auth.Token + api *definition.Service +} + +func newWorkflowFixture(t *testing.T, ctx context.Context, db *sql.DB, access bool) *workflowFixture { + t.Helper() + f := &workflowFixture{t: t, ctx: ctx, db: db, api: definition.New(NewWorkflowDefinitionStore(db))} + if err := Bootstrap(ctx, db, BootstrapInput{Application: "workflow-" + string(executionID(t)), Namespace: "test", Environment: "test", Queue: "work", WorkflowAccess: access}, func(v BootstrapResult) error { f.resource = v; return nil }); err != nil { + t.Fatal(err) + } + var err error + f.token, err = auth.Parse(f.resource.Token) + if err != nil { + t.Fatal(err) + } + return f +} +func (f *workflowFixture) create(key string) definition.CreateResult { + f.t.Helper() + v, err := f.api.Create(f.ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"}) + if err != nil { + f.t.Fatal(err) + } + return v +} +func (f *workflowFixture) count(q string, args ...any) int { + f.t.Helper() + var n int + if err := f.db.QueryRowContext(f.ctx, q, args...).Scan(&n); err != nil { + f.t.Fatal(err) + } + return n +} +func (f *workflowFixture) exec(q string, args ...any) { + f.t.Helper() + if _, err := f.db.ExecContext(f.ctx, q, args...); err != nil { + f.t.Fatal(err) + } +} +func workflowTestSpec(queue core.ID) definition.Specification { + typ := definition.Type{Kind: "OBJECT", Fields: map[string]definition.Type{"n": {Kind: "NUMBER"}}} + task := definition.Task{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: definition.Value{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: pulse.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: pulse.RecoveryReconcileOnly} + return definition.Specification{SchemaVersion: 1, InputType: typ, TimeoutMS: 30000, FailurePolicy: "REVIEW", Body: definition.Block{Steps: []definition.Step{{Key: "prepare", Kind: "TASK", Task: &task}}, OutputType: typ, Output: definition.Value{Kind: "REFERENCE", Reference: &definition.Reference{Source: "prepare"}}}} +} +func TestIntegrationWorkflowDefinitions(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 24, 8) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err := Migrate(ctx, db); err != nil { + t.Fatal(err) + } + t.Run("concurrency_precision_and_immutable_versions", func(t *testing.T) { + f := newWorkflowFixture(t, ctx, db, true) + key := string(executionID(t)) + created := f.create(key) + id := created.Definition.ID + dupe, err := f.api.Create(ctx, f.token, key, definition.CreateInput{Name: "flow-" + key, Description: "通用作业处理"}) + if err != nil || !dupe.Deduplicated || dupe.Definition.ID != id { + t.Fatal("create replay", err) + } + in := definition.PublishInput{Version: "v1", Specification: workflowTestSpec(f.resource.QueueID), Reason: "release fixed specification"} + type result struct { + out definition.PublishResult + err error + } + ch := make(chan result, 12) + var wg sync.WaitGroup + // 原键竞争和不同键的同版本竞争都必须只提交一次发布。 + for i := 0; i < 12; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + k := fmt.Sprintf("release-%d", i/2) + out, err := f.api.Publish(ctx, f.token, id, k, in) + ch <- result{out, err} + }(i) + } + wg.Wait() + close(ch) + fresh := 0 + var original definition.VersionView + for r := range ch { + if r.err != nil { + t.Fatal(r.err) + } + if original.ID == "" { + original = r.out.Version + } + if !reflect.DeepEqual(original, r.out.Version) { + t.Fatal("version changed across replies") + } + if !r.out.Deduplicated { + fresh++ + } + } + if fresh != 1 { + t.Fatalf("new publications=%d", fresh) + } + in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740993.0}`) + equal, err := f.api.Publish(ctx, f.token, id, "release-0", in) + if err != nil || !equal.Deduplicated || !reflect.DeepEqual(equal.Version, original) { + t.Fatal("canonical replay", err) + } + in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`) + _, err = f.api.Publish(ctx, f.token, id, "release-0", in) + expectWorkerCode(t, err, "IDEMPOTENCY_CONFLICT") + _, err = f.api.Publish(ctx, f.token, id, "changed-content", in) + expectWorkerCode(t, err, "STATE_CONFLICT") + in.Version = "v2" + v2, err := f.api.Publish(ctx, f.token, id, "release-v2", in) + if err != nil || v2.Version.ContentDigest == original.ContentDigest { + t.Fatal("publish v2", err) + } + v1, err := f.api.Version(ctx, f.token, id, "v1") + if err != nil || !reflect.DeepEqual(v1, original) { + t.Fatal("old version changed", err) + } + metadata, err := f.api.Get(ctx, f.token, id) + if err != nil || metadata.Revision != 3 { + t.Fatal("metadata revision", err) + } + if n := f.count(`SELECT COUNT(*) FROM orbit_audit_events WHERE namespace_id=? AND resource_id=? AND action='workflow.version.publish'`, f.resource.NamespaceID, id); n != 2 { + t.Fatalf("audit=%d", n) + } + if n := f.count(`SELECT COUNT(*) FROM pulse_tasks WHERE namespace_id=?`, f.resource.NamespaceID); n != 0 { + t.Fatal("publish created tasks") + } + if _, err := db.ExecContext(ctx, `UPDATE weave_definition_versions SET version='modified' WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, original.ID); err == nil { + t.Fatal("immutable update allowed") + } + }) + t.Run("authorization_and_associations_without_foreign_keys", func(t *testing.T) { + f := newWorkflowFixture(t, ctx, db, true) + g := newWorkflowFixture(t, ctx, db, false) + id := f.create(string(executionID(t))).Definition.ID + _, err := g.api.Create(ctx, g.token, string(executionID(t)), definition.CreateInput{Name: "forbidden"}) + expectWorkerCode(t, err, "PERMISSION_DENIED") + in := definition.PublishInput{Version: "v1", Reason: "authorization regression", Specification: workflowTestSpec(f.resource.QueueID)} + accepted, err := f.api.Publish(ctx, f.token, id, "pub", in) + if err != nil { + t.Fatal(err) + } + permissions := func(actions []string, queues []string) { + raw, _ := json.Marshal(auth.Permissions{Actions: actions, Queues: queues}) + f.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), f.resource.NamespaceID, f.resource.CredentialID) + } + permissions([]string{definition.ReadAction, definition.PublishAction}, []string{string(g.resource.QueueID)}) + if _, err := f.api.Get(ctx, f.token, id); err != nil { + t.Fatal("metadata scope", err) + } + _, err = f.api.Version(ctx, f.token, id, "v1") + expectWorkerCode(t, err, "NOT_FOUND") + _, err = f.api.Publish(ctx, f.token, id, "pub", in) + expectWorkerCode(t, err, "NOT_FOUND") + in.Version = "v2" + in.Specification.Body.Steps[0].Task.QueueID = g.resource.QueueID + _, err = f.api.Publish(ctx, f.token, id, "foreign-queue", in) + expectWorkerCode(t, err, "NOT_FOUND") + permissions([]string{definition.ReadAction, definition.PublishAction}, []string{"*"}) + in.Specification.Body.Steps[0].Task.QueueID = executionID(t) + _, err = f.api.Publish(ctx, f.token, id, "absent-queue", in) + expectWorkerCode(t, err, "NOT_FOUND") + _, err = f.api.Publish(ctx, f.token, executionID(t), "absent-parent", in) + expectWorkerCode(t, err, "NOT_FOUND") + raw, _ := json.Marshal(auth.Permissions{Actions: []string{definition.ReadAction, definition.PublishAction}, Queues: []string{"*"}}) + g.exec(`UPDATE orbit_credentials SET permissions=? WHERE namespace_id=? AND id=?`, string(raw), g.resource.NamespaceID, g.resource.CredentialID) + _, err = g.api.Version(ctx, g.token, id, "v1") + expectWorkerCode(t, err, "NOT_FOUND") + // 队列被人工删除后原版本不可用,不能依赖数据库外键兜底。 + f.exec(`DELETE FROM pulse_queues WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, f.resource.QueueID) + _, err = f.api.Version(ctx, f.token, id, "v1") + expectWorkerCode(t, err, "NOT_FOUND") + if n := f.count(`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=? AND definition_id=?`, f.resource.NamespaceID, id); n != 1 { + t.Fatal("rejected publication persisted") + } + if n := f.count(`SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`); n != 0 { + t.Fatal("foreign keys exist") + } + if accepted.Version.ID == "" { + t.Fatal("missing publication") + } + }) + t.Run("audit_failure_rolls_back_entire_publication", func(t *testing.T) { + f := newWorkflowFixture(t, ctx, db, true) + id := f.create(string(executionID(t))).Definition.ID + in := definition.PublishInput{Version: "v1", Reason: "atomic publication", Specification: workflowTestSpec(f.resource.QueueID)} + f.exec(`CREATE TRIGGER orbit_test_workflow_audit BEFORE INSERT ON orbit_audit_events FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='publication audit failure'`) + _, err := f.api.Publish(ctx, f.token, id, "atomic", in) + f.exec(`DROP TRIGGER orbit_test_workflow_audit`) + if err == nil { + t.Fatal("audit failure accepted") + } + for _, q := range []string{`SELECT COUNT(*) FROM weave_definition_versions WHERE namespace_id=?`, `SELECT COUNT(*) FROM orbit_idempotency_keys WHERE namespace_id=? AND operation='workflow.version.publish'`} { + if f.count(q, f.resource.NamespaceID) != 0 { + t.Fatal("partial publication committed") + } + } + metadata, err := f.api.Get(ctx, f.token, id) + if err != nil || metadata.Revision != 1 { + t.Fatal("revision not rolled back", err) + } + out, err := f.api.Publish(ctx, f.token, id, "atomic", in) + if err != nil || out.Deduplicated { + t.Fatal("retry rolled back publication", err) + } + }) + + t.Run("damaged_or_orphaned_versions_fail_closed", func(t *testing.T) { + f := newWorkflowFixture(t, ctx, db, true) + id := f.create(string(executionID(t))).Definition.ID + in := definition.PublishInput{Version: "v1", Reason: "stored integrity", Specification: workflowTestSpec(f.resource.QueueID)} + out, err := f.api.Publish(ctx, f.token, id, "original", in) + if err != nil { + t.Fatal(err) + } + // 只在隔离库新增损坏记录,不停用不可变触发器;不依赖外键阻止这些写入。 + f.exec(`INSERT INTO weave_definition_versions(namespace_id,id,definition_id,version,schema_version,specification,content_digest,published_by) SELECT namespace_id,?,definition_id,'damaged',schema_version,specification,?,published_by FROM weave_definition_versions WHERE namespace_id=? AND id=?`, executionID(t), core.Digest{}, f.resource.NamespaceID, out.Version.ID) + _, err = f.api.Version(ctx, f.token, id, "damaged") + if err == nil { + t.Fatal("damaged digest accepted") + } + f.exec(`DELETE FROM weave_definitions WHERE namespace_id=? AND id=?`, f.resource.NamespaceID, id) + _, err = f.api.Version(ctx, f.token, id, "v1") + expectWorkerCode(t, err, "NOT_FOUND") + _, err = f.api.Publish(ctx, f.token, id, "original", in) + expectWorkerCode(t, err, "NOT_FOUND") + }) + + t.Run("bounded_metadata_and_receipts_at_capacity", func(t *testing.T) { + f := newWorkflowFixture(t, ctx, db, true) + first := f.create("first") + id := first.Definition.ID + // 使用真实API填满元数据限额;重复回执必须仍可读取。 + for i := 1; i < definition.MaxDefinitions; i++ { + f.create(fmt.Sprintf("cap-%d", i)) + } + _, err := f.api.Create(ctx, f.token, "overflow", definition.CreateInput{Name: "overflow"}) + expectWorkerCode(t, err, "WORKFLOW_DEFINITION_LIMIT") + out, err := f.api.Create(ctx, f.token, "first", definition.CreateInput{Name: "flow-first", Description: "通用作业处理"}) + if err != nil || !out.Deduplicated { + t.Fatal("create replay at limit", err) + } + in := definition.PublishInput{Reason: "bounded version history", Specification: workflowTestSpec(f.resource.QueueID)} + for i := 0; i < definition.MaxVersions; i++ { + in.Version = fmt.Sprintf("v%d", i) + if _, err := f.api.Publish(ctx, f.token, id, in.Version, in); err != nil { + t.Fatal(err) + } + } + in.Version = "overflow" + _, err = f.api.Publish(ctx, f.token, id, "overflow", in) + expectWorkerCode(t, err, "WORKFLOW_VERSION_LIMIT") + in.Version = "v0" + same, err := f.api.Publish(ctx, f.token, id, "v0", in) + if err != nil || !same.Deduplicated { + t.Fatal("original version replay at limit", err) + } + same, err = f.api.Publish(ctx, f.token, id, "new-key-same-version", in) + if err != nil || !same.Deduplicated { + t.Fatal("same version replay at limit", err) + } + }) +} diff --git a/internal/transport/httpserver/tasks.go b/internal/transport/httpserver/tasks.go index 27d5142..c8c3fbb 100644 --- a/internal/transport/httpserver/tasks.go +++ b/internal/transport/httpserver/tasks.go @@ -233,7 +233,7 @@ func writeError(w http.ResponseWriter, id string, err error) { if !errors.As(err, &known) { known = fault.Unavailable } - status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409}[known.Code] + status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409}[known.Code] if status == 0 { status = 503 } @@ -299,6 +299,18 @@ func exactFields(raw []byte, t reflect.Type) error { } return nil } + if t.Kind() == reflect.Map { + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil { + return err + } + for _, value := range values { + if err := exactFields(value, t.Elem()); err != nil { + return err + } + } + return nil + } if t.Kind() != reflect.Struct { return nil } @@ -307,11 +319,13 @@ func exactFields(raw []byte, t reflect.Type) error { return err } names := map[string]reflect.Type{} + nullable := map[string]bool{} for i := 0; i < t.NumField(); i++ { field := t.Field(i) name := strings.Split(field.Tag.Get("json"), ",")[0] if name != "" && name != "-" { names[name] = field.Type + nullable[name] = field.Tag.Get("nullable") == "true" if field.Tag.Get("required") == "true" { if _, ok := object[name]; !ok { return errors.New("missing required field") @@ -324,6 +338,9 @@ func exactFields(raw []byte, t reflect.Type) error { if !ok { return errors.New("unknown field") } + if nullable[name] && bytes.Equal(bytes.TrimSpace(value), []byte("null")) { + continue + } if err := exactFields(value, field); err != nil { return err } diff --git a/internal/transport/httpserver/workflow_definitions.go b/internal/transport/httpserver/workflow_definitions.go new file mode 100644 index 0000000..4cd40bd --- /dev/null +++ b/internal/transport/httpserver/workflow_definitions.go @@ -0,0 +1,51 @@ +package httpserver + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/weave/definition" + "net/http" +) + +// RegisterWorkflowDefinitions 提供显式研发发布入口,复用严格JSON、事务认证和幂等信封。 +func RegisterWorkflowDefinitions(server *http.Server, s *definition.Service) { + mux := server.Handler.(*http.ServeMux) + mux.HandleFunc("POST /v1/workflow-definitions", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + var in definition.CreateInput + if err := decodeBody(r, &in); err != nil { + return nil, 0, err + } + key, err := idempotencyKey(r) + if err != nil { + return nil, 0, err + } + out, err := s.Create(r.Context(), t, key, in) + return out, createdStatus(out.Deduplicated), err + })) + mux.HandleFunc("GET /v1/workflow-definitions/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := s.Get(r.Context(), t, core.ID(r.PathValue("id"))) + return out, 200, err + })) + mux.HandleFunc("POST /v1/workflow-definitions/{id}/versions", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + var in definition.PublishInput + if err := decodeBody(r, &in); err != nil { + return nil, 0, err + } + key, err := idempotencyKey(r) + if err != nil { + return nil, 0, err + } + out, err := s.Publish(r.Context(), t, core.ID(r.PathValue("id")), key, in) + return out, createdStatus(out.Deduplicated), err + })) + mux.HandleFunc("GET /v1/workflow-definitions/{id}/versions/{version}", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := s.Version(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("version")) + return out, 200, err + })) +} diff --git a/internal/transport/httpserver/workflow_definitions_test.go b/internal/transport/httpserver/workflow_definitions_test.go new file mode 100644 index 0000000..b7f9c30 --- /dev/null +++ b/internal/transport/httpserver/workflow_definitions_test.go @@ -0,0 +1,58 @@ +package httpserver + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "git.yuntuo.hk/k2safe/orbit/weave/definition" +) + +func TestWorkflowStrictJSONAndNullableLiteral(t *testing.T) { + valid := `{"version":"v1","reason":"release","specification":{"schema_version":1,"input_type":{"kind":"OBJECT","fields":{"number":{"kind":"NUMBER"}}},"timeout_ms":1000,"failure_policy":"REVIEW","body":{"output_type":{"kind":"NULL"},"output":{"kind":"LITERAL","literal":null}}}}` + for _, tc := range []struct { + name, raw string + ok bool + }{ + {"explicit null literal", valid, true}, + {"map type case alias", strings.Replace(valid, `"number":{"kind"`, `"number":{"Kind"`, 1), false}, + {"map unknown property", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":{"kind":"NUMBER","code":"script"}`, 1), false}, + {"null type map", strings.Replace(valid, `"number":{"kind":"NUMBER"}`, `"number":null`, 1), false}, + {"null collection", strings.Replace(valid, `"body":{`, `"body":{"steps":null,`, 1), false}, + {"duplicate field", strings.Replace(valid, `"schema_version":1`, `"schema_version":1,"schema_version":2`, 1), false}, + {"extra top property", strings.Replace(valid, `"reason":"release"`, `"reason":"release","Code":"arbitrary"`, 1), false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.raw)) + r.Header.Set("Content-Type", "application/json") + var in definition.PublishInput + err := decodeBody(r, &in) + if (err == nil) != tc.ok { + t.Fatalf("decode: %v", err) + } + if tc.ok { + if _, err := definition.Check(in.Specification); err != nil { + t.Fatal(err) + } + } + }) + } + // Value.fields 同样递归检查精确字段名;普通任务payload仍不得显式null。 + for _, raw := range []string{`{"kind":"OBJECT","fields":{"x":{"Kind":"LITERAL","literal":1}}}`, `{"kind":"OBJECT","fields":{"x":null}}`} { + r := httptest.NewRequest("POST", "/", strings.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + var value definition.Value + if decodeBody(r, &value) == nil { + t.Fatal("invalid value map accepted") + } + } + var ordinary struct { + Payload json.RawMessage `json:"payload"` + } + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"payload":null}`)) + r.Header.Set("Content-Type", "application/json") + if decodeBody(r, &ordinary) == nil { + t.Fatal("nullable exception leaked to task payload") + } +} diff --git a/platform/auth/token.go b/platform/auth/token.go index a799398..dc0bfde 100644 --- a/platform/auth/token.go +++ b/platform/auth/token.go @@ -58,7 +58,7 @@ type Permissions struct { Queues []string `json:"queues"` } -var supported = map[string]bool{"tasks:review": true, "tasks:retry": true, "tasks:audit:read": true, "events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true} +var supported = map[string]bool{"workflows:definitions:read": true, "workflows:definitions:publish": true, "tasks:review": true, "tasks:retry": true, "tasks:audit:read": true, "events:read": true, "events:manage": true, "events:consume": true, "application:resources:read": true, "application:resources:manage": true, "tasks:submit": true, "tasks:read": true, "tasks:cancel": true, "queues:read": true, "queues:manage": true, "workers:connect": true, "workers:consume": true, "schedules:read": true, "schedules:manage": true} func (p Permissions) Validate() error { if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 { diff --git a/scripts/test-mysql.sh b/scripts/test-mysql.sh index f9b5639..926c34e 100755 --- a/scripts/test-mysql.sh +++ b/scripts/test-mysql.sh @@ -76,6 +76,11 @@ ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap - (cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationTaskRecoverySDK$' -v .) (cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/recovery.credentials.json" "$TASK_GO" run ./examples/recovery) +# 定义发布权限独立签发;示例只发布版本,不宣称实例引擎已运行。 +ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-workflow --namespace default --environment test --queue workflow --workflow-access --output "$TASK_TEST_DIR/workflow.credentials.json" +(cd sdk/go && ORBIT_TEST_WORKER_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_TEST_WORKER_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" test -race -count=1 -run '^TestIntegrationWorkflowDefinitionSDK$' -v .) +(cd sdk/go && ORBIT_URL="http://127.0.0.1:$TASK_HTTP_PORT" ORBIT_CREDENTIALS_FILE="$TASK_TEST_DIR/workflow.credentials.json" "$TASK_GO" run ./examples/workflow-definition) + # 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心SDK。 "$TASK_DOCKER" exec "$TASK_TEST_NAME" sh -c 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" mysql -uroot --protocol=tcp -e "CREATE DATABASE orbit_receiver_test CHARACTER SET utf8mb4 COLLATE utf8mb4_bin"' export ORBIT_TEST_RECEIVER_DSN="root:$TASK_DB_PASSWORD@tcp(127.0.0.1:$TASK_DB_PORT)/orbit_receiver_test" diff --git a/sdk/go/examples/workflow-definition/main.go b/sdk/go/examples/workflow-definition/main.go new file mode 100644 index 0000000..e5681c3 --- /dev/null +++ b/sdk/go/examples/workflow-definition/main.go @@ -0,0 +1,132 @@ +// workflow-definition 演示研发定义与不可变版本发布;当前不启动流程实例。 +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + orbit "git.yuntuo.hk/k2safe/orbit/sdk/go" + "os" + "reflect" + "time" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + token, queue := os.Getenv("ORBIT_TOKEN"), os.Getenv("ORBIT_QUEUE_ID") + if path := os.Getenv("ORBIT_CREDENTIALS_FILE"); path != "" { + raw, err := os.ReadFile(path) + if err != nil { + return err + } + var credentials struct { + Token string `json:"token"` + QueueID string `json:"queue_id"` + } + if json.Unmarshal(raw, &credentials) != nil { + return errors.New("invalid private credential file") + } + if token == "" { + token = credentials.Token + } + if queue == "" { + queue = credentials.QueueID + } + } + client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: token}) + if err != nil { + return err + } + op := os.Getenv("ORBIT_OPERATION_ID") + if op == "" { + op, err = orbit.NewWorkerMessageID() + if err != nil { + return err + } + } + // 先输出稳定操作身份;响应丢失时用同一身份、同一版本和同一源码重新运行。 + fmt.Printf("operation=%s\n", op) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + created, err := client.CreateWorkflowDefinition(ctx, op+".create", orbit.CreateWorkflowDefinitionRequest{Name: "object-flow." + op, Description: "对象准备、并行生成结果与有界状态查询"}) + if err != nil { + return err + } + id := created.Definition.ID + in := orbit.PublishWorkflowVersionRequest{Version: "v1", Reason: "initial developer release", Specification: specification(queue)} + published, err := client.PublishWorkflowVersion(ctx, id, op+".v1", in) + if err != nil { + return err + } + again, err := client.PublishWorkflowVersion(ctx, id, op+".v1", in) + if err != nil || !again.Deduplicated || !reflect.DeepEqual(again.Version, published.Version) { + return fmt.Errorf("original publication replay mismatch: %v", err) + } + same, err := client.PublishWorkflowVersion(ctx, id, op+".v1.same", in) + if err != nil || !same.Deduplicated || same.Version.ID != published.Version.ID { + return fmt.Errorf("same-version replay mismatch: %v", err) + } + in.Version = "v2" + in.Reason = "extend finite workflow deadline" + in.Specification.TimeoutMS = 90000 + next, err := client.PublishWorkflowVersion(ctx, id, op+".v2", in) + if err != nil { + return err + } + original, err := client.GetWorkflowVersion(ctx, id, "v1") + if err != nil || !reflect.DeepEqual(original, published.Version) { + return fmt.Errorf("original immutable version changed: %v", err) + } + meta, err := client.GetWorkflowDefinition(ctx, id) + if err != nil { + return err + } + fmt.Printf("definition=%s revision=%d v1=%s v2=%s steps=%d max_concurrency=%d max_task_attempts=%d\n", id, meta.Revision, original.ID, next.Version.ID, original.Analysis.Steps, original.Analysis.MaxConcurrency, original.Analysis.MaxTaskAttempts) + fmt.Println("Both definitions are stored. No workflow instance or business task has been started.") + return nil +} +func specification(queue string) orbit.WorkflowSpecification { + str := orbit.WorkflowType{Kind: "STRING"} + boolean := orbit.WorkflowType{Kind: "BOOLEAN"} + input := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"object_ref": str, "operation": str, "approved": boolean}} + literal := func(raw string) orbit.WorkflowValue { + return orbit.WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(raw)} + } + block := func(value orbit.WorkflowValue) orbit.WorkflowBlock { + return orbit.WorkflowBlock{OutputType: str, Output: value} + } + task := func(handler string, value orbit.WorkflowValue) orbit.WorkflowTask { + return orbit.WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: str, Input: value, ResultType: str, TimeoutMS: 5000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: orbit.RecoveryReconcileOnly} + } + prepare := task("object.prepare", orbit.WorkflowRef("input", "object_ref")) + branchDefault := block(literal(`"standard"`)) + left, right := orbit.WorkflowRef("input", "approved"), literal(`true`) + branch := orbit.WorkflowBranch{Cases: []orbit.WorkflowBranchCase{{When: orbit.WorkflowPredicate{Op: "EQ", Left: &left, Right: &right}, Body: block(literal(`"approved"`))}}, Default: &branchDefault} + parallel := orbit.WorkflowParallel{} + for _, name := range []string{"preview", "manifest"} { + work := task("object."+name, orbit.WorkflowRef("prepare")) + body := block(orbit.WorkflowRef(name)) + body.Steps = []orbit.WorkflowStep{{Key: name, Kind: "TASK", Task: &work}} + parallel.Branches = append(parallel.Branches, orbit.WorkflowParallelBranch{Name: name, Body: body}) + } + status := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"status": {Kind: "STRING", Enum: []string{"PENDING", "DONE", "FAILED", "REVIEW"}}}} + query := task("job.query", orbit.WorkflowRef("input", "operation")) + query.ResultType = status + query.Retry = orbit.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 1000, InitialDelayMS: 100, MaxDelayMS: 200} + query.RecoverySafety = orbit.RecoveryIdempotent + poll := orbit.WorkflowPoll{Task: query, MaxRounds: 5, TimeoutMS: 20000, IntervalMS: 1000, StatusPath: []string{"status"}, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}, Failed: []string{"FAILED"}, Review: []string{"REVIEW"}, FinalSignal: &orbit.WorkflowSignal{Name: "job.final", PayloadVersion: "v1", Correlation: orbit.WorkflowRef("input", "operation"), PayloadType: status, TimeoutMS: 20000}} + return orbit.WorkflowSpecification{SchemaVersion: 1, InputType: input, TimeoutMS: 60000, FailurePolicy: "REVIEW", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{ + {Key: "prepare", Kind: "TASK", Task: &prepare}, + {Key: "route", Kind: "BRANCH", Branch: &branch}, + {Key: "outputs", Kind: "PARALLEL", Parallel: ¶llel}, + {Key: "approval", Kind: "SIGNAL", Signal: &orbit.WorkflowSignal{Name: "object.approval", PayloadVersion: "v1", Correlation: orbit.WorkflowRef("input", "operation"), PayloadType: boolean, TimeoutMS: 10000}}, + {Key: "settle", Kind: "TIMER", Timer: &orbit.WorkflowTimer{DelayMS: 1000}}, + {Key: "result", Kind: "POLL", Poll: &poll}, + }, OutputType: status, Output: orbit.WorkflowRef("result")}} +} diff --git a/sdk/go/workflow_definitions.go b/sdk/go/workflow_definitions.go new file mode 100644 index 0000000..a483a55 --- /dev/null +++ b/sdk/go/workflow_definitions.go @@ -0,0 +1,55 @@ +package orbit + +import ( + "context" + "errors" +) + +// WorkflowRef 构造输入或已完成步骤的字段引用;合法作用域及类型在发布时由服务端校验。 +func WorkflowRef(source string, path ...string) WorkflowValue { + return WorkflowValue{Kind: "REFERENCE", Reference: &WorkflowReference{Source: source, Path: append([]string(nil), path...)}} +} + +// CreateWorkflowDefinition 幂等创建稳定定义身份,不自动发布或启动流程。 +func (c *Client) CreateWorkflowDefinition(ctx context.Context, key string, in CreateWorkflowDefinitionRequest) (WorkflowDefinitionResult, error) { + var out WorkflowDefinitionResult + err := c.call(ctx, "POST", "/v1/workflow-definitions", key, in, &out) + return out, err +} +func (c *Client) GetWorkflowDefinition(ctx context.Context, id string) (WorkflowDefinition, error) { + var out WorkflowDefinition + if !validID(id) { + return out, errors.New("invalid definition id") + } + err := c.call(ctx, "GET", "/v1/workflow-definitions/"+id, "", nil, &out) + return out, err +} + +// PublishWorkflowVersion 冻结规则;网络结果不明时保留原定义ID、版本、内容、原因和幂等键重报。 +func (c *Client) PublishWorkflowVersion(ctx context.Context, id, key string, in PublishWorkflowVersionRequest) (WorkflowVersionResult, error) { + var out WorkflowVersionResult + if !validID(id) { + return out, errors.New("invalid definition id") + } + err := c.call(ctx, "POST", "/v1/workflow-definitions/"+id+"/versions", key, in, &out) + return out, err +} +func (c *Client) GetWorkflowVersion(ctx context.Context, id, version string) (WorkflowVersion, error) { + var out WorkflowVersion + if !validID(id) || !workflowVersionName(version) { + return out, errors.New("invalid definition id or version") + } + err := c.call(ctx, "GET", "/v1/workflow-definitions/"+id+"/versions/"+version, "", nil, &out) + return out, err +} +func workflowVersionName(s string) bool { + if len(s) == 0 || len(s) > 64 { + return false + } + for i, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) { + return false + } + } + return true +} diff --git a/sdk/go/workflow_definitions_integration_test.go b/sdk/go/workflow_definitions_integration_test.go new file mode 100644 index 0000000..ebb2bc4 --- /dev/null +++ b/sdk/go/workflow_definitions_integration_test.go @@ -0,0 +1,109 @@ +package orbit + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "reflect" + "strings" + "sync/atomic" + "testing" + "time" +) + +func TestIntegrationWorkflowDefinitionSDK(t *testing.T) { + api, queue, _ := integrationWorkerClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + key := workerTestID(t) + created, err := api.CreateWorkflowDefinition(ctx, key, CreateWorkflowDefinitionRequest{Name: "workflow." + key, Description: "精确数字发布回归"}) + if err != nil { + t.Fatal(err) + } + id := created.Definition.ID + typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"n": {Kind: "NUMBER"}}} + task := WorkflowTask{QueueID: queue, Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`{"n":9007199254740993}`)}, ResultType: typ, TimeoutMS: 5000, Retry: RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 3000}, RecoverySafety: RecoveryReconcileOnly} + in := PublishWorkflowVersionRequest{Version: "v1", Reason: "SDK publication", Specification: WorkflowSpecification{SchemaVersion: 1, TimeoutMS: 30000, FailurePolicy: "REVIEW", InputType: typ, Body: WorkflowBlock{Steps: []WorkflowStep{{Key: "prepare", Kind: "TASK", Task: &task}, {Key: "wait", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 1000}}}, OutputType: typ, Output: WorkflowRef("prepare")}}} + // 真服务确认发布提交后,代理关闭客户端连接;SDK不得自动换键或默默重试。 + target, _ := url.Parse(api.baseURL) + proxy := httputil.NewSingleHostReverseProxy(target) + var dropped atomic.Bool + var calls atomic.Int32 + proxy.ModifyResponse = func(r *http.Response) error { + calls.Add(1) + if dropped.CompareAndSwap(false, true) { + raw, err := io.ReadAll(r.Body) + r.Body.Close() + if err != nil { + return err + } + if r.StatusCode != 201 || !strings.Contains(string(raw), `"deduplicated":false`) { + t.Error("publication was not committed before response loss") + } + return io.ErrUnexpectedEOF + } + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) { + conn, _, e := w.(http.Hijacker).Hijack() + if e == nil { + conn.Close() + } + } + gateway := httptest.NewServer(proxy) + defer gateway.Close() + client, err := NewClient(gateway.URL, Options{Token: api.token}) + if err != nil { + t.Fatal(err) + } + if _, err := client.PublishWorkflowVersion(ctx, id, key, in); err == nil || calls.Load() != 1 || !dropped.Load() { + t.Fatal("response loss not surfaced", err) + } + published, err := client.PublishWorkflowVersion(ctx, id, key, in) + if err != nil || !published.Deduplicated { + t.Fatal("lost publication not recovered", err) + } + original := published.Version + if string(original.Specification.Body.Steps[0].Task.Input.Literal) != `{"n":9007199254740993}` || len(original.ContentDigest) != 64 || original.Analysis.Steps != 2 || original.Analysis.MaxTaskAttempts != 1 { + t.Fatal("SDK result changed precision or analysis") + } + same, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in) + if err != nil || !same.Deduplicated || !reflect.DeepEqual(same.Version, original) { + t.Fatal("same version", err) + } + in.Specification.Body.Steps[0].Task.Input.Literal = json.RawMessage(`{"n":9007199254740992}`) + _, err = api.PublishWorkflowVersion(ctx, id, workerTestID(t), in) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.Code != "STATE_CONFLICT" { + t.Fatal("overwrite accepted", err) + } + in.Version = "v2" + next, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in) + if err != nil || next.Version.ContentDigest == original.ContentDigest { + t.Fatal("v2", err) + } + stored, err := api.GetWorkflowVersion(ctx, id, "v1") + if err != nil || !reflect.DeepEqual(stored, original) { + t.Fatal("immutable v1", err) + } + metadata, err := api.GetWorkflowDefinition(ctx, id) + if err != nil || metadata.Revision != 3 { + t.Fatal("unexpected version count", err) + } + // 类型的空块输出支持显式null,不放宽普通任务的null参数约束。 + in.Version = "v3" + in.Specification.Body = WorkflowBlock{OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`null`)}} + v3, err := api.PublishWorkflowVersion(ctx, id, workerTestID(t), in) + if err != nil || v3.Version.Analysis.Queues == nil || len(v3.Version.Analysis.Queues) != 0 { + t.Fatal("null block", err) + } + if _, err := api.GetWorkflowVersion(ctx, id, "../other"); err == nil { + t.Fatal("unsafe path accepted") + } + t.Log("real HTTP publication response dropped after commit; original identity restored precise immutable version, no second revision; all SDK definition routes exercised") +} diff --git a/sdk/go/workflow_types.go b/sdk/go/workflow_types.go new file mode 100644 index 0000000..e35a872 --- /dev/null +++ b/sdk/go/workflow_types.go @@ -0,0 +1,115 @@ +package orbit + +import "encoding/json" + +// WorkflowSpecification 是结构版本1的完整定义。版本发布后禁止原地修改。 +type WorkflowSpecification struct { + SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。 + InputType WorkflowType `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。 + FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。 + Body WorkflowBlock `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。 +} + +// WorkflowType 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。 +type WorkflowType struct { + Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。 + Fields map[string]WorkflowType `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。 + Items *WorkflowType `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。 + Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。 +} + +// WorkflowValue 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。 +type WorkflowValue struct { + Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。 + Literal json.RawMessage `json:"literal,omitempty"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。 + Reference *WorkflowReference `json:"reference,omitempty"` // WorkflowReference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。 + Fields map[string]WorkflowValue `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。 + Items []WorkflowValue `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。 +} + +type WorkflowReference struct { + Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。 + Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。 +} + +// WorkflowBlock 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。 +type WorkflowBlock struct { + Steps []WorkflowStep `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。 + OutputType WorkflowType `json:"output_type"` // OutputType 本块输出契约。 + Output WorkflowValue `json:"output"` // Output 全部步骤完成后解析的输出表达式。 +} + +type WorkflowStep struct { + Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。 + Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。 + Task *WorkflowTask `json:"task,omitempty"` // WorkflowTask 普通业务工作,交由独立Worker处理。 + Branch *WorkflowBranch `json:"branch,omitempty"` // WorkflowBranch 按顺序选择第一个匹配路径,必须有默认路径。 + Parallel *WorkflowParallel `json:"parallel,omitempty"` // WorkflowParallel 静态有限分支,全部成功后汇合。 + Signal *WorkflowSignal `json:"signal,omitempty"` // WorkflowSignal 等待一次按步骤和业务操作匹配的外部输入。 + Timer *WorkflowTimer `json:"timer,omitempty"` // WorkflowTimer 持久化时间等待,不占业务Worker槽位。 + Poll *WorkflowPoll `json:"poll,omitempty"` // WorkflowPoll 有界查询,轮次预算与每轮Pulse尝试预算独立。 +} + +type WorkflowTask struct { + QueueID string `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。 + Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。 + HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。 + InputType WorkflowType `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。 + Input WorkflowValue `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。 + ResultType WorkflowType `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。 + Retry RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。 + RecoverySafety RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。 + Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。 + BusinessKey *WorkflowValue `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。 + ConcurrencyKey *WorkflowValue `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。 +} + +type WorkflowPredicate struct { + Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。 + Left *WorkflowValue `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。 + Right *WorkflowValue `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。 + Arguments []WorkflowPredicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。 +} + +type WorkflowBranch struct { + Cases []WorkflowBranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。 + Default *WorkflowBlock `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。 +} +type WorkflowBranchCase struct { + When WorkflowPredicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。 + Body WorkflowBlock `json:"body"` // Body 选中路径;所有路径输出类型必须一致。 +} +type WorkflowParallel struct { + Branches []WorkflowParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。 +} +type WorkflowParallelBranch struct { + Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。 + Body WorkflowBlock `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。 +} + +type WorkflowSignal struct { + Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。 + Correlation WorkflowValue `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。 + PayloadType WorkflowType `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。 +} +type WorkflowTimer struct { + DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。 +} + +type WorkflowPoll struct { + Task WorkflowTask `json:"task"` // WorkflowTask 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。 + MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。 + IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。 + StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。 + Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。 + Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。 + Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。 + Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。 + FinalSignal *WorkflowSignal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。 +} diff --git a/sdk/go/workflow_views.go b/sdk/go/workflow_views.go new file mode 100644 index 0000000..017be31 --- /dev/null +++ b/sdk/go/workflow_views.go @@ -0,0 +1,56 @@ +package orbit + +import "time" + +// CreateWorkflowDefinitionRequest 创建命名空间内的稳定定义身份,不发布版本或启动实例。 +type CreateWorkflowDefinitionRequest struct { + Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。 + Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。 +} + +// PublishWorkflowVersionRequest 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。 +type PublishWorkflowVersionRequest struct { + Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。 + Specification WorkflowSpecification `json:"specification"` // WorkflowSpecification 完整且显式的冻结规则。 + Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。 +} + +// WorkflowDefinition 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。 +type WorkflowDefinition struct { + ID string `json:"id"` // ID 定义稳定标识。 + NamespaceID string `json:"namespace_id"` // NamespaceID 定义隔离范围。 + Name string `json:"name"` // Name 创建时冻结的唯一名称。 + Description string `json:"description"` // Description 创建时冻结的用途说明。 + Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。 + CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。 + UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。 +} + +// WorkflowAnalysis 是静态预算上界;不表示业务任务已执行或已分配配额。 +type WorkflowAnalysis struct { + Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。 + MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。 + MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。 + Queues []string `json:"queues"` // Queues 排序去重队列,始终为数组。 +} + +// WorkflowVersion 返回原始发布内容和身份,不使用可变的latest别名。 +type WorkflowVersion struct { + ID string `json:"id"` // ID 不可变版本记录标识。 + NamespaceID string `json:"namespace_id"` // NamespaceID 隔离范围。 + DefinitionID string `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。 + Version string `json:"version"` // Version 发布版本名。 + Specification WorkflowSpecification `json:"specification"` // WorkflowSpecification 已冻结规则,数字精度不经float64转换。 + ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。 + PublishedBy string `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。 + PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。 + Analysis WorkflowAnalysis `json:"analysis"` // WorkflowAnalysis 根据结构版本1规则计算的静态检查结果。 +} +type WorkflowDefinitionResult struct { + Definition WorkflowDefinition `json:"definition"` // Definition 创建事务的原始快照。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。 +} +type WorkflowVersionResult struct { + Version WorkflowVersion `json:"version"` // Version 原版本快照,不包含当前父定义状态。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。 +} diff --git a/weave/definition/check.go b/weave/definition/check.go new file mode 100644 index 0000000..7ca751b --- /dev/null +++ b/weave/definition/check.go @@ -0,0 +1,519 @@ +package definition + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "unicode/utf8" + + "git.yuntuo.hk/k2safe/orbit/internal/jsonvalue" + "git.yuntuo.hk/k2safe/orbit/internal/jsonwire" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/pulse/service" +) + +const MaxSpecificationBytes = 256 << 10 +const maxTimeoutMS = 30 * 86400000 + +type checker struct { + result Checked + keys map[string]bool + queues map[core.ID]bool + timeout uint64 +} +type scope map[string]Type + +// Check 验证完整结构、词法数据依赖和资源边界;不修改调用方定义,也不访问数据库。 +// 队列存在性、当前权限和不可变版本身份由发布事务另行核验。 +func Check(in Specification) (Checked, error) { + if in.SchemaVersion != 1 || in.TimeoutMS == 0 || in.TimeoutMS > maxTimeoutMS || (in.FailurePolicy != "REVIEW" && in.FailurePolicy != "FAIL_AFTER_SETTLED") { + return Checked{}, invalid("specification", "schema_version=1, finite timeout and explicit failure policy required") + } + if err := checkType(in.InputType, 0); err != nil { + return Checked{}, err + } + c := checker{keys: map[string]bool{}, queues: map[core.ID]bool{}, timeout: in.TimeoutMS} + initial := scope{"input": in.InputType, "workflow": {Kind: "OBJECT", Fields: map[string]Type{"id": {Kind: "STRING"}, "business_key": {Kind: "STRING"}}}} + width, err := c.block(in.Body, initial, 0) + if err != nil { + return Checked{}, err + } + if width > 32 || c.result.MaxTaskAttempts > 10000 { + return Checked{}, invalid("specification", "parallel or total attempt budget exceeded") + } + raw, err := jsonwire.Marshal(in) + if err != nil || len(raw) > MaxSpecificationBytes { + return Checked{}, invalid("specification", "encoded definition exceeds 256 KiB or is invalid") + } + canonical, err := jsonvalue.Canonical(raw) + if err != nil { + return Checked{}, invalid("specification", "definition JSON exceeds supported depth or value limits") + } + c.result.Digest = core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...))) + c.result.MaxConcurrency = width + for q := range c.queues { + c.result.Queues = append(c.result.Queues, q) + } + sort.Slice(c.result.Queues, func(i, j int) bool { return c.result.Queues[i] < c.result.Queues[j] }) + return c.result, nil +} + +func invalid(where, message string) error { return fmt.Errorf("%s: %s", where, message) } +func name(s string, limit int) bool { + if len(s) == 0 || len(s) > limit { + return false + } + for i, r := range s { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) { + return false + } + } + return true +} +func cloneScope(in scope) scope { + out := make(scope, len(in)+8) + for key, value := range in { + out[key] = value + } + return out +} +func (c *checker) block(b Block, parent scope, depth int) (uint32, error) { + if depth > 4 || len(b.Steps) > 128 { + return 0, invalid("body", "block depth or step limit exceeded") + } + if err := checkType(b.OutputType, 0); err != nil { + return 0, err + } + env := cloneScope(parent) + var width uint32 + for _, step := range b.Steps { + if !name(step.Key, 64) || step.Key == "input" || step.Key == "workflow" || c.keys[step.Key] { + return 0, invalid("step", "key must be unique and nonreserved") + } + c.keys[step.Key] = true + c.result.Steps++ + if c.result.Steps > 128 { + return 0, invalid("body", "at most 128 static steps allowed") + } + result, concurrency, err := c.step(step, env, depth) + if err != nil { + return 0, invalid(step.Key, err.Error()) + } + env[step.Key] = result + if concurrency > width { + width = concurrency + } + } + if err := checkValue(b.Output, b.OutputType, env, 0); err != nil { + return 0, invalid("output", err.Error()) + } + return width, nil +} +func (c *checker) step(s Step, env scope, depth int) (Type, uint32, error) { + n := 0 + for _, present := range []bool{s.Task != nil, s.Branch != nil, s.Parallel != nil, s.Signal != nil, s.Timer != nil, s.Poll != nil} { + if present { + n++ + } + } + if n != 1 { + return Type{}, 0, invalid("step", "exactly one kind configuration required") + } + switch s.Kind { + case "TASK": + if s.Task != nil { + if err := c.task(*s.Task, env); err != nil { + return Type{}, 0, err + } + c.result.MaxTaskAttempts += s.Task.Retry.MaxAttempts + return s.Task.ResultType, 1, nil + } + case "SIGNAL": + if s.Signal != nil { + return s.Signal.PayloadType, 0, c.signal(*s.Signal, env) + } + case "TIMER": + if s.Timer != nil && s.Timer.DelayMS > 0 && s.Timer.DelayMS <= c.timeout { + return Type{Kind: "NULL"}, 0, nil + } + case "BRANCH": + if s.Branch != nil { + b := s.Branch + if b.Default == nil || len(b.Cases) < 1 || len(b.Cases) > 16 { + return Type{}, 0, invalid("branch", "1 to 16 cases and explicit default required") + } + width, err := c.block(*b.Default, env, depth+1) + if err != nil { + return Type{}, 0, err + } + for _, path := range b.Cases { + if err := checkPredicate(path.When, env, 0); err != nil { + return Type{}, 0, err + } + w, err := c.block(path.Body, env, depth+1) + if err != nil { + return Type{}, 0, err + } + if !sameType(path.Body.OutputType, b.Default.OutputType) { + return Type{}, 0, invalid("branch", "all path output types must match") + } + if w > width { + width = w + } + } + return b.Default.OutputType, width, nil + } + case "PARALLEL": + if s.Parallel != nil { + if len(s.Parallel.Branches) < 2 || len(s.Parallel.Branches) > 8 { + return Type{}, 0, invalid("parallel", "2 to 8 named branches required") + } + result := Type{Kind: "OBJECT", Fields: map[string]Type{}} + var width uint32 + for _, branch := range s.Parallel.Branches { + if !name(branch.Name, 64) { + return Type{}, 0, invalid("parallel", "invalid branch name") + } + if _, exists := result.Fields[branch.Name]; exists { + return Type{}, 0, invalid("parallel", "duplicate branch name") + } + w, err := c.block(branch.Body, env, depth+1) + if err != nil { + return Type{}, 0, err + } + width += w + result.Fields[branch.Name] = branch.Body.OutputType + } + if width > 32 { + return Type{}, 0, invalid("parallel", "at most 32 concurrent business tasks allowed") + } + return result, width, nil + } + case "POLL": + if s.Poll != nil { + p := s.Poll + if p.MaxRounds == 0 || p.MaxRounds > 1000 || p.TimeoutMS == 0 || p.TimeoutMS > c.timeout || p.IntervalMS < 1000 || p.IntervalMS >= p.TimeoutMS || p.Task.TimeoutMS > p.TimeoutMS { + return Type{}, 0, invalid("poll", "finite rounds, interval and compatible time budgets required") + } + if err := c.task(p.Task, env); err != nil { + return Type{}, 0, err + } + status, err := pathType(p.Task.ResultType, p.StatusPath) + if err != nil || status.Kind != "STRING" || len(status.Enum) == 0 || len(p.Pending) == 0 || len(p.Succeeded) == 0 { + return Type{}, 0, invalid("poll", "status must be a declared string enum with pending and success cases") + } + seen := map[string]bool{} + for _, group := range [][]string{p.Pending, p.Succeeded, p.Failed, p.Review} { + for _, v := range group { + if !contains(status.Enum, v) || seen[v] { + return Type{}, 0, invalid("poll", "status groups must form an exact disjoint partition") + } + seen[v] = true + } + } + if len(seen) != len(status.Enum) { + return Type{}, 0, invalid("poll", "unmapped status enum value") + } + if p.FinalSignal != nil { + if err := c.signal(*p.FinalSignal, env); err != nil { + return Type{}, 0, err + } + if p.FinalSignal.TimeoutMS != p.TimeoutMS || !sameType(p.FinalSignal.PayloadType, p.Task.ResultType) { + return Type{}, 0, invalid("poll", "final signal must share result type and poll deadline") + } + } + c.result.MaxTaskAttempts += p.MaxRounds * p.Task.Retry.MaxAttempts + return p.Task.ResultType, 1, nil + } + } + return Type{}, 0, invalid("step", "unknown kind, mismatched configuration or invalid timer") +} + +func (c *checker) task(t Task, env scope) error { + if t.TimeoutMS == 0 || t.TimeoutMS > c.timeout || t.InputType.Kind == "NULL" || t.ResultType.Kind == "NULL" { + return invalid("task", "finite timeout and non-null payload/result types required") + } + // 复用Pulse的处理器/策略边界,但定义必须显式提供生效值,不能在后续发布时解释不同默认值。 + v, err := service.NormalizeSubmit(service.SubmitInput{QueueID: t.QueueID, Handler: t.Handler, HandlerVersion: t.HandlerVersion, PayloadVersion: t.PayloadVersion, Payload: json.RawMessage(`{}`), TaskTimeoutMS: t.TimeoutMS, RecoverySafety: t.RecoverySafety, Retry: t.Retry, Priority: t.Priority}) + if err != nil || v.Retry != t.Retry || v.RecoverySafety != t.RecoverySafety { + return invalid("task", "invalid or incomplete frozen Pulse policy") + } + if err := checkType(t.InputType, 0); err != nil { + return err + } + if err := checkType(t.ResultType, 0); err != nil { + return err + } + if err := checkValue(t.Input, t.InputType, env, 0); err != nil { + return err + } + if t.Input.Kind == "LITERAL" && bytes.Equal(bytes.TrimSpace(t.Input.Literal), []byte("null")) { + return invalid("task", "null task input is not accepted by Pulse") + } + for _, key := range []*Value{t.BusinessKey, t.ConcurrencyKey} { + if key != nil { + if err := checkValue(*key, Type{Kind: "STRING"}, env, 0); err != nil { + return err + } + } + } + c.queues[t.QueueID] = true + return nil +} +func (c *checker) signal(s Signal, env scope) error { + if !name(s.Name, 128) || !name(s.PayloadVersion, 32) || s.TimeoutMS == 0 || s.TimeoutMS > c.timeout { + return invalid("signal", "name, payload version and finite timeout required") + } + if err := checkType(s.PayloadType, 0); err != nil { + return err + } + if err := checkValue(s.Correlation, Type{Kind: "STRING"}, env, 0); err != nil { + return err + } + if s.Correlation.Kind == "LITERAL" { + var value string + if json.Unmarshal(s.Correlation.Literal, &value) != nil || len(value) == 0 || len(value) > 512 { + return invalid("signal", "literal correlation must be a nonempty string up to 512 bytes") + } + } + return nil +} + +func checkType(t Type, depth int) error { + if depth > 8 || len(t.Fields) > 64 || len(t.Enum) > 32 { + return invalid("type", "schema depth or width limit exceeded") + } + if t.Kind != "OBJECT" && len(t.Fields) != 0 || t.Kind != "ARRAY" && t.Items != nil || t.Kind != "STRING" && len(t.Enum) != 0 { + return invalid("type", "fields, items or enum do not match kind") + } + switch t.Kind { + case "JSON", "NUMBER", "BOOLEAN", "NULL": + case "STRING": + seen := map[string]bool{} + for _, value := range t.Enum { + if !utf8.ValidString(value) || len(value) > 128 || seen[value] { + return invalid("type", "invalid or duplicate string enum value") + } + seen[value] = true + } + case "OBJECT": + for key, field := range t.Fields { + if !name(key, 64) { + return invalid("type", "invalid object field name") + } + if err := checkType(field, depth+1); err != nil { + return err + } + } + case "ARRAY": + if t.Items == nil { + return invalid("type", "array items type required") + } + return checkType(*t.Items, depth+1) + default: + return invalid("type", "unknown kind") + } + return nil +} +func sameType(a, b Type) bool { return assignable(a, b) && assignable(b, a) } +func assignable(actual, expected Type) bool { + if expected.Kind == "JSON" { + return true + } + if actual.Kind != expected.Kind { + return false + } + switch expected.Kind { + case "OBJECT": + if len(actual.Fields) != len(expected.Fields) { + return false + } + for key, t := range expected.Fields { + v, ok := actual.Fields[key] + if !ok || !assignable(v, t) { + return false + } + } + case "ARRAY": + return actual.Items != nil && expected.Items != nil && assignable(*actual.Items, *expected.Items) + case "STRING": + if len(expected.Enum) == 0 { + return true + } + if len(actual.Enum) == 0 { + return false + } + for _, v := range actual.Enum { + if !contains(expected.Enum, v) { + return false + } + } + } + return true +} +func contains(values []string, value string) bool { + for _, v := range values { + if v == value { + return true + } + } + return false +} +func pathType(t Type, path []string) (Type, error) { + if len(path) > 8 { + return Type{}, invalid("reference", "path exceeds 8 fields") + } + for _, key := range path { + if t.Kind != "OBJECT" { + return Type{}, invalid("reference", "path crosses a non-object or untyped JSON value") + } + var ok bool + t, ok = t.Fields[key] + if !ok { + return Type{}, invalid("reference", "field not declared") + } + } + return t, nil +} +func referenceType(r Reference, env scope) (Type, error) { + t, ok := env[r.Source] + if !ok { + return Type{}, invalid("reference", "source is missing, forward, conditional or outside this block") + } + return pathType(t, r.Path) +} + +func checkValue(v Value, expected Type, env scope, depth int) error { + if depth > 8 { + return invalid("value", "expression depth exceeds 8") + } + if v.Kind != "LITERAL" && len(v.Literal) != 0 || v.Kind != "REFERENCE" && v.Reference != nil || v.Kind != "OBJECT" && len(v.Fields) != 0 || v.Kind != "ARRAY" && len(v.Items) != 0 { + return invalid("value", "configuration does not match kind") + } + switch v.Kind { + case "LITERAL": + return ValidateData(expected, v.Literal) + case "REFERENCE": + if v.Reference == nil { + return invalid("reference", "source required") + } + actual, err := referenceType(*v.Reference, env) + if err != nil { + return err + } + if !assignable(actual, expected) { + return invalid("reference", "source and target types are incompatible") + } + case "OBJECT": + if expected.Kind != "OBJECT" && expected.Kind != "JSON" || len(v.Fields) > 64 { + return invalid("value", "object expression has incompatible type or too many fields") + } + if expected.Kind == "OBJECT" && len(v.Fields) != len(expected.Fields) { + return invalid("value", "object expression must define every field exactly") + } + for key, child := range v.Fields { + if !name(key, 64) { + return invalid("value", "invalid object field") + } + t := Type{Kind: "JSON"} + if expected.Kind == "OBJECT" { + var ok bool + t, ok = expected.Fields[key] + if !ok { + return invalid("value", "extra object field") + } + } + if err := checkValue(child, t, env, depth+1); err != nil { + return err + } + } + case "ARRAY": + if expected.Kind != "ARRAY" && expected.Kind != "JSON" || len(v.Items) > 256 { + return invalid("value", "array expression has incompatible type or too many elements") + } + t := Type{Kind: "JSON"} + if expected.Kind == "ARRAY" { + t = *expected.Items + } + for _, child := range v.Items { + if err := checkValue(child, t, env, depth+1); err != nil { + return err + } + } + default: + return invalid("value", "unknown kind") + } + return nil +} + +func scalarType(v Value, env scope) (string, error) { + if err := checkValue(v, Type{Kind: "JSON"}, env, 0); err != nil { + return "", err + } + var kind string + switch v.Kind { + case "REFERENCE": + t, err := referenceType(*v.Reference, env) + if err != nil { + return "", err + } + kind = t.Kind + case "LITERAL": + var raw any + d := json.NewDecoder(bytes.NewReader(v.Literal)) + d.UseNumber() + if err := d.Decode(&raw); err != nil { + return "", err + } + switch raw.(type) { + case nil: + kind = "NULL" + case string: + kind = "STRING" + case bool: + kind = "BOOLEAN" + case json.Number: + kind = "NUMBER" + } + } + if kind != "STRING" && kind != "NUMBER" && kind != "BOOLEAN" && kind != "NULL" { + return "", invalid("predicate", "only typed scalar comparisons allowed") + } + return kind, nil +} +func checkPredicate(p Predicate, env scope, depth int) error { + if depth > 8 { + return invalid("predicate", "expression depth exceeds 8") + } + switch p.Op { + case "AND", "OR", "NOT": + if p.Left != nil || p.Right != nil || len(p.Arguments) > 16 || (p.Op == "NOT" && len(p.Arguments) != 1) || (p.Op != "NOT" && len(p.Arguments) < 2) { + return invalid("predicate", "invalid logical arguments") + } + for _, child := range p.Arguments { + if err := checkPredicate(child, env, depth+1); err != nil { + return err + } + } + case "EQ", "NE", "LT", "LE", "GT", "GE": + if p.Left == nil || p.Right == nil || len(p.Arguments) != 0 { + return invalid("predicate", "exactly two comparison operands required") + } + left, err := scalarType(*p.Left, env) + if err != nil { + return err + } + right, err := scalarType(*p.Right, env) + if err != nil { + return err + } + if left != right || (p.Op != "EQ" && p.Op != "NE" && left != "STRING" && left != "NUMBER") { + return invalid("predicate", "incompatible comparison operands") + } + default: + return invalid("predicate", "unknown operator") + } + return nil +} diff --git a/weave/definition/check_test.go b/weave/definition/check_test.go new file mode 100644 index 0000000..5ab5119 --- /dev/null +++ b/weave/definition/check_test.go @@ -0,0 +1,211 @@ +package definition + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" +) + +func literal(raw string) Value { return Value{Kind: "LITERAL", Literal: json.RawMessage(raw)} } +func ref(source string, path ...string) Value { + return Value{Kind: "REFERENCE", Reference: &Reference{Source: source, Path: path}} +} +func scalar(kind string) Type { return Type{Kind: kind} } +func block(result Type, value Value, steps ...Step) Block { + return Block{Steps: steps, OutputType: result, Output: value} +} +func testTask() Task { + return Task{QueueID: "11111111111111111111111111111111", Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: scalar("STRING"), Input: ref("input", "operation"), ResultType: scalar("STRING"), TimeoutMS: 10000, Retry: pulse.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 3000, InitialDelayMS: 1000, MaxDelayMS: 2000}, RecoverySafety: pulse.RecoveryReconcileOnly} +} +func testSpecification() Specification { + t := testTask() + return Specification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: Type{Kind: "OBJECT", Fields: map[string]Type{"operation": scalar("STRING"), "enabled": scalar("BOOLEAN")}}, Body: block(scalar("STRING"), ref("prepare"), Step{Key: "prepare", Kind: "TASK", Task: &t})} +} +func TestStructuredDefinitionScopesAndBudgets(t *testing.T) { + base := testSpecification() + left, right := ref("input", "enabled"), literal("true") + task := testTask() + branch := Branch{Cases: []BranchCase{{When: Predicate{Op: "EQ", Left: &left, Right: &right}, Body: block(scalar("STRING"), ref("chosen"), Step{Key: "chosen", Kind: "TASK", Task: &task})}}, Default: ptrBlock(block(scalar("STRING"), literal(`"skipped"`)))} + parallel := Parallel{Branches: []ParallelBranch{{Name: "first", Body: block(scalar("STRING"), ref("choice"))}, {Name: "second", Body: block(scalar("NULL"), ref("timer"), Step{Key: "timer", Kind: "TIMER", Timer: &Timer{DelayMS: 1}})}}} + base.Body = block(Type{Kind: "OBJECT", Fields: map[string]Type{"first": scalar("STRING"), "second": scalar("NULL")}}, ref("joined"), Step{Key: "choice", Kind: "BRANCH", Branch: &branch}, Step{Key: "joined", Kind: "PARALLEL", Parallel: ¶llel}) + got, err := Check(base) + if err != nil || got.Steps != 4 || got.MaxConcurrency != 1 || got.MaxTaskAttempts != 2 || len(got.Queues) != 1 { + t.Fatal("structured definition", got, err) + } + for _, test := range []struct { + name string + change func(*Specification) + }{ + {"forward_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("later") }}, + {"self_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("prepare") }}, + {"missing_property", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "missing") }}, + {"wrong_type", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "enabled") }}, + {"default_policy_not_frozen", func(s *Specification) { s.Body.Steps[0].Task.Retry.MaxAttempts = 0 }}, + {"unknown_kind", func(s *Specification) { s.Body.Steps[0].Kind = "SCRIPT" }}, + {"extra_kind_body", func(s *Specification) { s.Body.Steps[0].Timer = &Timer{DelayMS: 1} }}, + {"reserved_step_name", func(s *Specification) { s.Body.Steps[0].Key = "input" }}, + {"type_cycle", func(s *Specification) { x := scalar("ARRAY"); x.Items = &x; s.InputType = x }}, + {"invalid_unicode", func(s *Specification) { s.Body.Steps[0].Task.Input = literal(`"\ud800"`) }}, + {"duplicate_literal_key", func(s *Specification) { + s.Body.Steps[0].Task.InputType = scalar("JSON") + s.Body.Steps[0].Task.Input = literal(`{"a":1,"a":2}`) + }}, + {"null_task_literal", func(s *Specification) { + s.Body.Steps[0].Task.InputType = scalar("JSON") + s.Body.Steps[0].Task.Input = literal(" null ") + }}, + {"empty_task_deadline", func(s *Specification) { s.Body.Steps[0].Task.TimeoutMS = 0 }}, + } { + t.Run(test.name, func(t *testing.T) { + s := testSpecification() + test.change(&s) + if _, err := Check(s); err == nil { + t.Fatal("invalid definition accepted") + } + }) + } + branch.Cases[0].Body.Output = ref("joined", "first") + if _, err := Check(base); err == nil { + t.Fatal("future join result was visible inside a branch") + } + branch.Cases[0].Body.Output = ref("chosen") + base.Body.Output = ref("chosen") + if _, err := Check(base); err == nil { + t.Fatal("conditional child leaked outside composite") + } + base.Body.Output = ref("joined") + parallel.Branches[1].Body.Output = ref("chosen") + if _, err := Check(base); err == nil { + t.Fatal("sibling/unselected result leaked") + } + parallel.Branches[1].Body.Output = ref("timer") + branch.Default = nil + if _, err := Check(base); err == nil { + t.Fatal("nonexhaustive branch accepted") + } +} +func ptrBlock(b Block) *Block { return &b } + +func TestDefinitionParallelBudgetAndDuplicateIdentity(t *testing.T) { + makeParallel := func(prefix string) Step { + p := Parallel{} + for _, suffix := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} { + task := testTask() + key := prefix + suffix + p.Branches = append(p.Branches, ParallelBranch{Name: suffix, Body: block(scalar("STRING"), ref(key), Step{Key: key, Kind: "TASK", Task: &task})}) + } + return Step{Key: prefix, Kind: "PARALLEL", Parallel: &p} + } + s := testSpecification() + p := makeParallel("wide") + s.Body = block(scalar("JSON"), ref("wide"), p) + got, err := Check(s) + if err != nil || got.MaxConcurrency != 8 || got.Steps != 9 || got.MaxTaskAttempts != 16 { + t.Fatal(got, err) + } + p.Parallel.Branches[1].Body.Steps[0].Key = "widea" + if _, err := Check(s); err == nil { + t.Fatal("duplicate step identities accepted") + } + outer := Parallel{} + for _, prefix := range []string{"a", "b", "c", "d", "e"} { + child := makeParallel(prefix) + outer.Branches = append(outer.Branches, ParallelBranch{Name: prefix, Body: block(scalar("JSON"), ref(prefix), child)}) + } + s.Body = block(scalar("JSON"), ref("outer"), Step{Key: "outer", Kind: "PARALLEL", Parallel: &outer}) + if _, err := Check(s); err == nil { + t.Fatal("nested concurrency over 32 accepted") + } + s = testSpecification() + for i := 0; i < 128; i++ { + task := testTask() + s.Body.Steps = append(s.Body.Steps, Step{Key: "extra" + strings.Repeat("x", i+1), Kind: "TASK", Task: &task}) + } + if _, err := Check(s); err == nil { + t.Fatal("overlong block accepted") + } +} + +func TestPollPartitionsAndIndependentBudgets(t *testing.T) { + s := testSpecification() + task := testTask() + task.ResultType = Type{Kind: "OBJECT", Fields: map[string]Type{"state": {Kind: "STRING", Enum: []string{"processing", "done", "failed", "review"}}, "reference": scalar("STRING")}} + p := Poll{Task: task, MaxRounds: 3, TimeoutMS: 30000, IntervalMS: 1000, StatusPath: []string{"state"}, Pending: []string{"processing"}, Succeeded: []string{"done"}, Failed: []string{"failed"}, Review: []string{"review"}, FinalSignal: &Signal{Name: "completion", PayloadVersion: "v1", Correlation: ref("input", "operation"), PayloadType: task.ResultType, TimeoutMS: 30000}} + s.Body = block(task.ResultType, ref("poll"), Step{Key: "poll", Kind: "POLL", Poll: &p}) + got, err := Check(s) + if err != nil || got.MaxTaskAttempts != 6 || got.Steps != 1 { + t.Fatal(got, err) + } + for _, change := range []func(*Poll){ + func(p *Poll) { p.MaxRounds = 0 }, func(p *Poll) { p.MaxRounds = 1001 }, func(p *Poll) { p.IntervalMS = 0 }, func(p *Poll) { p.IntervalMS = p.TimeoutMS }, + func(p *Poll) { p.Review = nil }, func(p *Poll) { p.Failed = []string{"processing"} }, func(p *Poll) { p.StatusPath = []string{"reference"} }, + func(p *Poll) { p.MaxRounds = 1000; p.Task.Retry.MaxAttempts = 100 }, func(p *Poll) { p.Task.TimeoutMS = p.TimeoutMS + 1 }, + func(p *Poll) { v := *p.FinalSignal; v.TimeoutMS++; p.FinalSignal = &v }, + } { + copy := p + change(©) + s.Body.Steps[0].Poll = © + if _, err := Check(s); err == nil { + t.Fatal("unbounded or ambiguous poll accepted") + } + } +} + +func TestDefinitionDigestAndPreciseData(t *testing.T) { + s := testSpecification() + s.Body.Steps[0].Task.InputType = scalar("JSON") + s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740993,"decimal":12345678901234567890.1234567890123456789}`) + a, err := Check(s) + if err != nil { + t.Fatal(err) + } + before, _ := json.Marshal(s) + s.Body.Steps[0].Task.Input = literal(`{"decimal":12345678901234567890.123456789012345678900,"n":9007199254740993.0}`) + b, err := Check(s) + if err != nil || a.Digest != b.Digest { + t.Fatal("semantic numeric digest changed", err) + } + s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740992,"decimal":12345678901234567890.1234567890123456789}`) + c, err := Check(s) + if err != nil || a.Digest == c.Digest { + t.Fatal("large integer rounded", err) + } + var old Specification + if json.Unmarshal(before, &old) != nil { + t.Fatal("snapshot") + } + if _, err := Check(old); err != nil { + t.Fatal(err) + } + after, _ := json.Marshal(old) + if !bytes.Equal(before, after) { + t.Fatal("check mutated definition") + } + for _, raw := range []string{`"bad"`, `null`, `{"extra":1}`, `{"operation":"x","enabled":true,"extra":1}`, `{"operation":"x","enabled":1}`, `{"operation":"x","operation":"y","enabled":true}`} { + if ValidateData(old.InputType, json.RawMessage(raw)) == nil { + t.Fatal("bad instance input accepted") + } + } + if err := ValidateData(old.InputType, json.RawMessage(`{"operation":"x","enabled":true}`)); err != nil { + t.Fatal(err) + } + s = Specification{SchemaVersion: 1, InputType: scalar("NULL"), TimeoutMS: 1, FailurePolicy: "REVIEW", Body: block(scalar("NULL"), literal("null"))} + if _, err := Check(s); err != nil { + t.Fatal("empty typed block must be valid", err) + } +} + +func FuzzDefinitionDataNeverPanics(f *testing.F) { + for _, raw := range []string{`{"operation":"x","enabled":true}`, `null`, `{"n":9007199254740993}`, `{"n":1,"n":2}`} { + f.Add([]byte(raw)) + } + f.Fuzz(func(t *testing.T, raw []byte) { + if len(raw) > 4096 { + return + } + _ = ValidateData(testSpecification().InputType, raw) + }) +} diff --git a/weave/definition/data.go b/weave/definition/data.go new file mode 100644 index 0000000..b150c72 --- /dev/null +++ b/weave/definition/data.go @@ -0,0 +1,75 @@ +package definition + +import ( + "bytes" + "encoding/json" + + "git.yuntuo.hk/k2safe/orbit/internal/jsonvalue" +) + +// ValidateData 在实例输入、任务成功结果、信号和块输出边界复用同一类型契约。 +// 使用精确JSON数值文本;大小、重复字段、Unicode和嵌套限制先于类型检查。 +func ValidateData(schema Type, raw json.RawMessage) error { + if err := checkType(schema, 0); err != nil { + return err + } + if len(raw) == 0 || len(raw) > 256<<10 { + return invalid("data", "JSON value required, maximum 256 KiB") + } + if _, err := jsonvalue.Canonical(raw); err != nil { + return invalid("data", "invalid JSON, duplicate fields or unsupported value limits") + } + var value any + d := json.NewDecoder(bytes.NewReader(raw)) + d.UseNumber() + if err := d.Decode(&value); err != nil { + return invalid("data", "invalid JSON value") + } + return matchData(schema, value) +} +func matchData(schema Type, value any) error { + valid := false + switch schema.Kind { + case "JSON": + return nil + case "NULL": + valid = value == nil + case "STRING": + v, ok := value.(string) + valid = ok && (len(schema.Enum) == 0 || contains(schema.Enum, v)) + case "NUMBER": + _, valid = value.(json.Number) + case "BOOLEAN": + _, valid = value.(bool) + case "OBJECT": + v, ok := value.(map[string]any) + if !ok || len(v) != len(schema.Fields) { + return invalid("data", "object fields do not match declared type") + } + for key, t := range schema.Fields { + child, ok := v[key] + if !ok { + return invalid("data", "required object field missing") + } + if err := matchData(t, child); err != nil { + return err + } + } + return nil + case "ARRAY": + v, ok := value.([]any) + if !ok || len(v) > 4096 { + return invalid("data", "array required, maximum 4096 elements") + } + for _, child := range v { + if err := matchData(*schema.Items, child); err != nil { + return err + } + } + return nil + } + if !valid { + return invalid("data", "value does not match declared type") + } + return nil +} diff --git a/weave/definition/service.go b/weave/definition/service.go new file mode 100644 index 0000000..61fd6bb --- /dev/null +++ b/weave/definition/service.go @@ -0,0 +1,116 @@ +package definition + +import ( + "context" + "strings" + "time" + "unicode/utf8" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/pulse/service" +) + +const ReadAction = "workflows:definitions:read" +const PublishAction = "workflows:definitions:publish" +const MaxDefinitions = 100 +const MaxVersions = 100 + +// CreateInput 创建命名空间内的稳定定义身份,不发布版本或启动实例。 +type CreateInput struct { + Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。 + Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。 +} + +// PublishInput 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。 +type PublishInput struct { + Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。 + Specification Specification `json:"specification"` // Specification 完整且显式的冻结规则。 + Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。 +} + +// Summary 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。 +type Summary struct { + ID core.ID `json:"id"` // ID 定义稳定标识。 + NamespaceID core.ID `json:"namespace_id"` // NamespaceID 定义隔离范围。 + Name string `json:"name"` // Name 创建时冻结的唯一名称。 + Description string `json:"description"` // Description 创建时冻结的用途说明。 + Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。 + CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。 + UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。 +} + +// Analysis 是静态预算上界;不表示业务任务已执行或已分配配额。 +type Analysis struct { + Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。 + MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。 + MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。 + Queues []core.ID `json:"queues"` // Queues 排序去重队列,始终为数组。 +} + +// VersionView 返回原始发布内容和身份,不使用可变的latest别名。 +type VersionView struct { + ID core.ID `json:"id"` // ID 不可变版本记录标识。 + NamespaceID core.ID `json:"namespace_id"` // NamespaceID 隔离范围。 + DefinitionID core.ID `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。 + Version string `json:"version"` // Version 发布版本名。 + Specification Specification `json:"specification"` // Specification 已冻结规则,数字精度不经float64转换。 + ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。 + PublishedBy core.ID `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。 + PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。 + Analysis Analysis `json:"analysis"` // Analysis 根据结构版本1规则计算的静态检查结果。 +} +type CreateResult struct { + Definition Summary `json:"definition"` // Definition 创建事务的原始快照。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。 +} +type PublishResult struct { + Version VersionView `json:"version"` // Version 原版本快照,不包含当前父定义状态。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。 +} +type Store interface { + Create(context.Context, auth.Token, string, CreateInput) (CreateResult, error) + Get(context.Context, auth.Token, core.ID) (Summary, error) + Publish(context.Context, auth.Token, core.ID, string, PublishInput) (PublishResult, error) + Version(context.Context, auth.Token, core.ID, string) (VersionView, error) +} +type Service struct{ store Store } + +func New(store Store) *Service { return &Service{store: store} } +func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (CreateResult, error) { + if err := service.ValidateKey(key); err != nil { + return CreateResult{}, err + } + if !name(in.Name, 128) || !textValid(in.Description, false) { + return CreateResult{}, fault.Invalid("invalid workflow definition name or description") + } + return s.store.Create(ctx, t, key, in) +} +func textValid(s string, required bool) bool { + return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 1024 && !strings.ContainsRune(s, 0) && (!required || strings.TrimSpace(s) != "") +} +func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (Summary, error) { + if id.Validate() != nil { + return Summary{}, fault.Invalid("invalid definition id") + } + return s.store.Get(ctx, t, id) +} +func (s *Service) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in PublishInput) (PublishResult, error) { + if err := service.ValidateKey(key); err != nil { + return PublishResult{}, err + } + if id.Validate() != nil || !name(in.Version, 64) || !textValid(in.Reason, true) { + return PublishResult{}, fault.Invalid("valid definition id, version and reason required") + } + if _, err := Check(in.Specification); err != nil { + return PublishResult{}, fault.Invalid(err.Error()) + } + return s.store.Publish(ctx, t, id, key, in) +} +func (s *Service) Version(ctx context.Context, t auth.Token, id core.ID, version string) (VersionView, error) { + if id.Validate() != nil || !name(version, 64) { + return VersionView{}, fault.Invalid("invalid definition id or version") + } + return s.store.Version(ctx, t, id, version) +} diff --git a/weave/definition/types.go b/weave/definition/types.go new file mode 100644 index 0000000..4158c1c --- /dev/null +++ b/weave/definition/types.go @@ -0,0 +1,130 @@ +// Package definition 描述可发布的有限流程;只解释类型和数据引用,不执行上传的业务代码。 +package definition + +import ( + "encoding/json" + + core "git.yuntuo.hk/k2safe/orbit/model" + pulse "git.yuntuo.hk/k2safe/orbit/pulse/model" +) + +// Specification 是结构版本1的完整定义。版本发布后禁止原地修改。 +type Specification struct { + SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。 + InputType Type `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。 + FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。 + Body Block `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。 +} + +// Type 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。 +type Type struct { + Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。 + Fields map[string]Type `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。 + Items *Type `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。 + Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。 +} + +// Value 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。 +type Value struct { + Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。 + Literal json.RawMessage `json:"literal,omitempty" nullable:"true"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。 + Reference *Reference `json:"reference,omitempty"` // Reference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。 + Fields map[string]Value `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。 + Items []Value `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。 +} + +type Reference struct { + Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。 + Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。 +} + +// Block 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。 +type Block struct { + Steps []Step `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。 + OutputType Type `json:"output_type"` // OutputType 本块输出契约。 + Output Value `json:"output"` // Output 全部步骤完成后解析的输出表达式。 +} + +type Step struct { + Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。 + Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。 + Task *Task `json:"task,omitempty"` // Task 普通业务工作,交由独立Worker处理。 + Branch *Branch `json:"branch,omitempty"` // Branch 按顺序选择第一个匹配路径,必须有默认路径。 + Parallel *Parallel `json:"parallel,omitempty"` // Parallel 静态有限分支,全部成功后汇合。 + Signal *Signal `json:"signal,omitempty"` // Signal 等待一次按步骤和业务操作匹配的外部输入。 + Timer *Timer `json:"timer,omitempty"` // Timer 持久化时间等待,不占业务Worker槽位。 + Poll *Poll `json:"poll,omitempty"` // Poll 有界查询,轮次预算与每轮Pulse尝试预算独立。 +} + +type Task struct { + QueueID core.ID `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。 + Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。 + HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。 + InputType Type `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。 + Input Value `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。 + ResultType Type `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。 + Retry pulse.RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。 + RecoverySafety pulse.RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。 + Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。 + BusinessKey *Value `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。 + ConcurrencyKey *Value `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。 +} + +type Predicate struct { + Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。 + Left *Value `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。 + Right *Value `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。 + Arguments []Predicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。 +} + +type Branch struct { + Cases []BranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。 + Default *Block `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。 +} +type BranchCase struct { + When Predicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。 + Body Block `json:"body"` // Body 选中路径;所有路径输出类型必须一致。 +} +type Parallel struct { + Branches []ParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。 +} +type ParallelBranch struct { + Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。 + Body Block `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。 +} + +type Signal struct { + Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。 + Correlation Value `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。 + PayloadType Type `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。 +} +type Timer struct { + DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。 +} + +type Poll struct { + Task Task `json:"task"` // Task 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。 + MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。 + TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。 + IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。 + StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。 + Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。 + Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。 + Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。 + Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。 + FinalSignal *Signal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。 +} + +// Checked 是发布验证的计算结果;资源上限按所有静态路径保守计数,不代表运行已发生。 +type Checked struct { + Steps uint32 // Steps 静态节点总数,不按轮次展开,最多128。 + MaxConcurrency uint32 // MaxConcurrency 结构可产生的并行工作上界,最多32。 + MaxTaskAttempts uint32 // MaxTaskAttempts 各静态任务尝试数与查询轮次乘积总和,最多10000。 + Queues []core.ID // Queues 排序且去重的引用队列,发布/启动必须检查权限和归属。 + Digest core.Digest // Digest 规范化JSON的SHA-256,用于不可变版本内容比较。 +}