From b9e4191c6bb06d770c2bd3929032d0def3dab39d Mon Sep 17 00:00:00 2001 From: k2safe Date: Fri, 18 Sep 2026 06:51:08 +0800 Subject: [PATCH] feat: expose workflow runtime through HTTP and Go SDK --- AGENTS.md | 15 +- README.md | 4 +- cmd/orbit-admin/main.go | 5 + cmd/orbit/main.go | 7 +- docs/03-http-websocket-protocol.md | 12 +- docs/06-workflow-spec.md | 4 +- docs/08-development-plan.md | 23 +- docs/10-workflow-engine-core.md | 2 +- docs/11-workflow-admission.md | 2 +- docs/12-workflow-runtime.md | 4 +- docs/13-workflow-clock.md | 2 +- docs/14-workflow-signals.md | 4 +- docs/15-workflow-controls.md | 4 +- docs/16-workflow-recovery.md | 4 +- docs/17-workflow-poll-recovery.md | 4 +- docs/18-workflow-wait-recovery.md | 2 +- docs/api/README.md | 5 +- docs/api/authentication.md | 2 + docs/api/workflow-definitions.md | 2 +- docs/api/workflow-recovery.md | 146 +++++++ docs/api/workflows.md | 178 +++++++- docs/examples/README.md | 8 +- docs/examples/workflow-definition.md | 2 +- docs/examples/workflow.md | 57 +++ docs/reports/phase-5-public-api.md | 42 ++ docs/sdk/README.md | 4 +- internal/storage/mysql/bootstrap.go | 48 ++- internal/storage/mysql/workflow_apply.go | 1 + .../workflow_bootstrap_integration_test.go | 68 +++ internal/storage/mysql/workflow_details.go | 79 ++++ .../workflow_details_integration_test.go | 102 +++++ internal/storage/mysql/workflow_instances.go | 120 +++--- internal/storage/mysql/workflow_operations.go | 80 ++++ internal/storage/mysql/workflow_poll_retry.go | 1 + .../storage/mysql/workflow_runtime_graph.go | 6 +- internal/storage/mysql/workflow_step_retry.go | 1 + internal/storage/mysql/workflow_wait_rearm.go | 1 + internal/transport/httpserver/tasks.go | 28 +- internal/transport/httpserver/workflows.go | 102 +++++ .../transport/httpserver/workflows_test.go | 69 +++ platform/auth/token.go | 2 +- scripts/test-mysql.sh | 7 +- sdk/go/examples/workflow/main.go | 174 ++++++++ sdk/go/workflow_instance_types.go | 234 ++++++++++ sdk/go/workflow_instances.go | 144 +++++++ sdk/go/workflow_instances_integration_test.go | 403 ++++++++++++++++++ weave/instance/details.go | 55 +++ weave/instance/operations.go | 63 +++ weave/instance/service.go | 18 +- weave/instance/service_test.go | 14 + 50 files changed, 2221 insertions(+), 143 deletions(-) create mode 100644 docs/api/workflow-recovery.md create mode 100644 docs/examples/workflow.md create mode 100644 docs/reports/phase-5-public-api.md create mode 100644 internal/storage/mysql/workflow_bootstrap_integration_test.go create mode 100644 internal/storage/mysql/workflow_details.go create mode 100644 internal/storage/mysql/workflow_details_integration_test.go create mode 100644 internal/storage/mysql/workflow_operations.go create mode 100644 internal/transport/httpserver/workflows.go create mode 100644 internal/transport/httpserver/workflows_test.go create mode 100644 sdk/go/examples/workflow/main.go create mode 100644 sdk/go/workflow_instance_types.go create mode 100644 sdk/go/workflow_instances.go create mode 100644 sdk/go/workflow_instances_integration_test.go create mode 100644 weave/instance/details.go create mode 100644 weave/instance/operations.go diff --git a/AGENTS.md b/AGENTS.md index f015622..725926b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,17 +31,17 @@ - P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。 -- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,继续实例公共SDK;P4整体和P5–P7仍未完成。 +- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,继续完整历史与来源;P4整体和P5–P7仍未完成。 - 内部结果交付使用workflow_activation所有者并固定激活ID,TASK经activation.task_id、POLL经轮次校验归属;weave_inbox来源及独立快照与Pulse结果同事务,先于外部结果流行锁,不在结果路径反锁实例/步骤/订阅。迁移000017隔离缺快照历史,禁止用当前任务重建旧事件。 -- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK继续开发。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。 +- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,完整历史及来源继续开发。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。 -- 实例内部受理已实现,信号内部运行器已接入,公共实例/信号HTTP及独立SDK尚未开放;结果消费和根待续扫描已接入后台。Start按认证→应用额度→原身份→排序队列建立门控/固定版本/全部步骤/首批工作,所有失败同事务回滚。首批推进递增revision至2,原键返回冻结回执且仍核验当前权限,不重算截止。 +- 实例内部受理已实现,信号内部运行器已接入,公共实例/信号HTTP及独立SDK已接入;结果消费和根待续扫描已接入后台。Start按认证→应用额度→原身份→排序队列建立门控/固定版本/全部步骤/首批工作,所有失败同事务回滚。首批推进递增revision至2,原键返回冻结回执且仍核验当前权限,不重算截止。 - 实例根步骤`$root`/JOIN不属于用户定义,Steps查询排除它;More对应根步骤版本化持久检查点。TASK直接关联激活task_id,POLL通过轮次关联,实际任务截止必须受实例/查询绝对截止限制。Wait名称及契约版本冻结,旧NULL等待须核实,不能盲目解释;新关联512字节、存储兼容历史764字节。 - 结果消费按身份→应用额度→排序队列→实例→门控→步骤/Inbox持锁,禁止先锁Inbox再锁父资源。来源使用独立冻结快照,旧激活/低版本留痕;数据库故障不归类为业务失败。消费确认、步骤、后继或根待续、事件及审计同事务。 - 后继满额用SAVEPOINT回滚整轮新工作,保留已接受结果和待续;恢复必须重读持久图。多次根推进使用独立系统审计操作身份;迟到证据也递增实例历史版本,禁止复用已有workflow.advanced唯一身份。 -- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK仍须完成,不得按部分自动运行宣称P4/P5完成。 +- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK已接入,完整历史与来源仍须完成,不得按部分自动运行宣称P4/P5完成。 - TIMER/POLL复用weave_pending_advances及到期索引,时钟固定激活ID;迁移000019只回填原等待/查询时钟,不改截止和预算。后续POLL只由已确认POLL_PENDING开启,首轮精确输入/资源键冻结,每轮Pulse尝试独立计数;满额/暂停不消耗轮数。截止遇到已落库结果先等待Inbox,仍在途则关闭门控但保留ACTIVE和当前任务,许可由Pulse结清;不得由时钟假定外部请求已结束。 @@ -52,11 +52,14 @@ - StopKind/StopRequestedAt冻结首次CANCEL或FAILURE目标,后来的用户取消不覆盖原失败目标。结清复用ROOT,每事务最多一个Pulse取消、32项步骤收尾;结果流锁取得后不能再锁另一任务。终态前校验全部历史任务的双向归属、未结束尝试和未释放许可,UNKNOWN不得终结。迁移000021恢复旧停止意图,不添加外键。 - 人工核查使用显式workflows:recover、当前实例revision、步骤/任务版本及激活身份,必须有原因和证据;执行结论、步骤结论、解除核查分别确认。新决定拒绝PENDING/QUARANTINED Inbox,原键回执仍核验权限。核查回执/额度/双审计/结果流同事务,结果流锁最后取得。 -- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK也未开放,不能据此宣称P5完成。 +- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK已接入,完整历史和来源仍需补齐,不能据此宣称P5完成。 - POLL人工恢复以新激活重查同一业务轮,FirstRound/FirstRoundAttemptsUsed保留原轮次和已用尝试;实际尝试行、retry_of链和冻结参数必须相符。同轮任务数也受原max_attempts限制,PENDING不得人工重查绕过轮次上限。查询截止按首次激活、请求截止按该轮首任务,不允许重计时;下一业务轮才获得完整单轮策略。 -- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK与生产验收继续推进。 +- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK已接入,完整历史与生产验收继续推进。 - 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;ContinuePoll显式恢复原等待,必须核验当前SUCCEEDED/PENDING任务、已处理Inbox及不可变来源。新审计固定来源事件/版本、原间隔与截止,时钟/最终信号核验证明后才推进;不改写旧Inbox处置,ReviewTask不自动解除核查。 - RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。 + +- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。完整历史、实例列表、信号审计列表与Pulse流程来源仍未交付。 +- 恢复请求的匿名嵌入结构按扁平精确JSON字段解析,继承字段也拒绝别名/重复/未知字段,保留显式null例外与必填确认。bootstrap先验证权限集合;工作流执行/控制/信号/恢复/审计分别显式授予,旧workflow-access仅定义发布。 diff --git a/README.md b/README.md index 1d72f26..7d16963 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ 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)。[有限步骤决策器与内部结果交付](docs/10-workflow-engine-core.md)已实现;已补[实例受理及首批工作存储](docs/11-workflow-admission.md)。[结果消费、顺序/分支/并行推进及根检查点接续](docs/12-workflow-runtime.md)已接入后台运行器。[定时等待、后续查询轮次及原预算恢复](docs/13-workflow-clock.md)已接入;[外部信号和查询最终信号竞争](docs/14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与失败结清](docs/15-workflow-controls.md)已接入内部运行器;[人工核查、解除核查与TASK新激活](docs/16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](docs/17-workflow-poll-recovery.md)已接入;[PENDING接续和独立等待恢复](docs/18-workflow-wait-recovery.md)已接入内部服务;实例公共API/SDK继续开发,发布定义不会启动任务。 +Weave已提供[定义发布](docs/api/workflow-definitions.md)、[实例启动、步骤详情、暂停/继续/取消、信号和操作审计](docs/api/workflows.md),以及[六类人工核查恢复](docs/api/workflow-recovery.md)的HTTP与独立Go SDK。持久运行器支持顺序、分支、有限并行、TIMER、SIGNAL及有界POLL,恢复沿用原预算与截止。[并行作业与回执示例](docs/examples/workflow.md)提供完整调用链;实例筛选分页、历史激活/轮次/信号列表、流程任务来源及更多独立恢复示例继续交付,P4/P5与生产验收尚未整体完成。 数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 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)。 @@ -32,7 +32,7 @@ internal/timezone/ 随计划冻结的内置时区数据快照 weave/model/ 定义、实例、步骤、信号和查询轮次 weave/definition/ 有限结构/类型校验与定义发布契约 weave/engine/ 不可变规则的有限步骤决策与精确结果映射 -weave/instance/ 内部实例受理/查询契约,公共运行API继续开发 +weave/instance/ 实例受理、当前详情及操作审计契约 weave/signal/ 内部可靠信号受理/查询契约及显式激活身份 weave/control/ 内部版本化暂停/继续/取消契约 internal/config/ 配置校验 diff --git a/cmd/orbit-admin/main.go b/cmd/orbit-admin/main.go index 94445c3..c119620 100644 --- a/cmd/orbit-admin/main.go +++ b/cmd/orbit-admin/main.go @@ -43,6 +43,11 @@ func run() error { 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") + flags.BoolVar(&in.WorkflowRunAccess, "workflow-run-access", false, "explicitly grant workflow start and read actions") + flags.BoolVar(&in.WorkflowControlAccess, "workflow-control-access", false, "explicitly grant workflow control and read actions") + flags.BoolVar(&in.WorkflowSignalAccess, "workflow-signal-access", false, "explicitly grant workflow signal and read actions") + flags.BoolVar(&in.WorkflowRecoveryAccess, "workflow-recovery-access", false, "explicitly grant workflow recovery and read actions") + flags.BoolVar(&in.WorkflowAuditAccess, "workflow-audit-access", false, "explicitly grant workflow operation audit read action") if err := flags.Parse(os.Args[2:]); err != nil { return err } diff --git a/cmd/orbit/main.go b/cmd/orbit/main.go index d502da6..366d4b7 100644 --- a/cmd/orbit/main.go +++ b/cmd/orbit/main.go @@ -20,7 +20,11 @@ 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/control" "git.yuntuo.hk/k2safe/orbit/weave/definition" + "git.yuntuo.hk/k2safe/orbit/weave/instance" + "git.yuntuo.hk/k2safe/orbit/weave/recovery" + weavesignal "git.yuntuo.hk/k2safe/orbit/weave/signal" ) func main() { @@ -81,6 +85,7 @@ func run() error { } httpserver.RegisterSchedules(server, schedule.New(schedules)) httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db))) + httpserver.RegisterWorkflows(server, instance.New(mysql.NewWorkflowInstanceStore(db)), control.New(mysql.NewWorkflowControlStore(db)), weavesignal.New(mysql.NewWorkflowSignalStore(db)), recovery.New(mysql.NewWorkflowRecoveryStore(db))) workflows, err := mysql.NewWorkflowRuntime(db, node) if err != nil { return err @@ -159,7 +164,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, workflow definition and Worker APIs", "node_id", node) + slog.Info("Orbit HTTP server starting", "address", listener.Addr().String(), "scope", "health, task, queue, schedule, subscription, workflow and Worker APIs", "node_id", node) select { case err := <-done: if errors.Is(err, http.ErrServerClosed) { diff --git a/docs/03-http-websocket-protocol.md b/docs/03-http-websocket-protocol.md index d3dd858..d4dccf6 100644 --- a/docs/03-http-websocket-protocol.md +++ b/docs/03-http-websocket-protocol.md @@ -33,21 +33,21 @@ 应用、凭据、队列、配额和审计管理接口沿用同一鉴权、版本与错误规范,其完整路径清单在接口评审时补齐。就绪检查必须反映关键依赖,数据库不可用时不能继续宣称可安全接单。 -Weave 的补充接口建议如下,功能范围已确认,具体技术细则以 [流程设计](06-workflow-spec.md) 为准: +Weave以下接口已接入公共HTTP与SDK,具体字段和权限以[实例接口](api/workflows.md)、[恢复接口](api/workflow-recovery.md)为准: | 方法与路径 | 行为 | | --- | --- | | `POST /v1/workflow-definitions/{definition_id}/versions` | 研发通过 SDK 发布不可变定义;同版本同内容幂等,不同内容冲突 | | `POST /v1/workflows` | 幂等启动流程,固定定义版本 | | `GET /v1/workflows/{workflow_id}` | 查询实例、定义版本、执行状态、暂停标记与等待摘要 | -| `GET /v1/workflows/{workflow_id}/steps` | 分页查询步骤、输入/结果引用、激活、查询轮次、已用预算与关联任务 | +| `GET /v1/workflows/{workflow_id}/steps` | 按键返回最多128项步骤元数据;单个步骤详情另用`/steps/{key}`读取输入/输出、等待和查询预算 | | `POST /v1/workflows/{workflow_id}/signals` | 幂等受理信号,不等于信号已经被消费 | | `GET /v1/workflows/{workflow_id}/signals/{signal_id}` | 查询信号受理、匹配与消费结果 | -| `POST /v1/workflows/{workflow_id}:pause` | 持久化暂停门控与审计;不代表在途业务已停止 | -| `POST /v1/workflows/{workflow_id}:resume` | 解除暂停并安排受控推进;不重置预算、核查状态或原版本 | -| `POST /v1/workflows/{workflow_id}:cancel` | 记录取消意图,结清关联工作 | +| `POST /v1/workflows/{workflow_id}/control` | PAUSE/RESUME/CANCEL;按control_revision保存门控与意图 | +| `POST /v1/workflows/{workflow_id}/recovery/*` | 六类有证据恢复,按当前实例/步骤/任务版本核对 | +| `GET /v1/workflows/{workflow_id}/operations` | 有界分页读取流程操作审计摘要,正文另用单条查询 | -流程列表、事件查询与人工核查/恢复接口待细化;恢复必须带预期状态版本、稳定操作身份、原因与证据,不提供任意修改状态的通用入口。 +流程列表和完整历史查询仍待交付;恢复必须带预期状态版本、稳定操作身份、原因与证据,不提供任意修改状态的通用入口。 暂停和继续使用独立管理权限、预期控制版本、幂等操作身份与原因;同一操作重报返回原确认,过期控制版本不能覆盖后来的操作。建议 `200` 确认门控已提交,后续执行进度通过查询观察;门控失效条件和终态操作错误在协议评审时固定。 diff --git a/docs/06-workflow-spec.md b/docs/06-workflow-spec.md index f7dad3f..d0e1d30 100644 --- a/docs/06-workflow-spec.md +++ b/docs/06-workflow-spec.md @@ -1,6 +1,6 @@ # Weave 流程编排功能与设计 -> 版本 0.9 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);[实例受理](11-workflow-admission.md)、[结果消费与根推进](12-workflow-runtime.md)、[TIMER与POLL时钟](13-workflow-clock.md)已接入。[信号等待及最终查询竞争](14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与停止结清](15-workflow-controls.md)已实现内部链路;[人工核查与TASK恢复](16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](17-workflow-poll-recovery.md)和[等待恢复](18-workflow-wait-recovery.md)已接入;实例公共API/SDK仍待P4–P5实现验收。 +> 版本 0.10 · 2026-09-18。首版功能范围已确认。定义结构、发布校验与不可变版本已实现,见[定义格式](api/workflow-definition-format.md)和[发布API](api/workflow-definitions.md);[实例受理](11-workflow-admission.md)、[结果消费与根推进](12-workflow-runtime.md)、[TIMER与POLL时钟](13-workflow-clock.md)已接入。[信号等待及最终查询竞争](14-workflow-signals.md)已接入内部运行器;[暂停/继续/取消与停止结清](15-workflow-controls.md)已实现内部链路;[人工核查与TASK恢复](16-workflow-recovery.md)已接入内部服务;[POLL跨激活恢复](17-workflow-poll-recovery.md)和[等待恢复](18-workflow-wait-recovery.md)已接入;[实例公共API/SDK](api/workflows.md)已接入;完整历史、来源及生产验收仍需交付。 ## 1. 职责与边界 @@ -174,7 +174,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进 3. 在一个事务内记录管理操作,创建新的步骤激活及关联 Pulse 任务,并固定关联关系。 4. 保留旧激活与旧任务结果,沿用恰当的业务幂等身份。 -执行核查、步骤结论、解除核查及TASK新激活已按[人工恢复约定](16-workflow-recovery.md)实现内部服务,原预算和绝对截止不重置;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入,等待恢复已接入内部服务,公共API另行交付。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。 +执行核查、步骤结论、解除核查及TASK新激活已按[人工恢复约定](16-workflow-recovery.md)实现内部服务,原预算和绝对截止不重置;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入,等待恢复已接入内部服务,公共API已接入,见[恢复接口](api/workflow-recovery.md)。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。 取消和补偿是不同业务操作。若未来增加补偿,补偿本身也必须是有权限、可审计、可重试或可核查的业务步骤;不能宣称撤销流程就能撤回已经生效的外部业务操作。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index 23c6ab3..9be3318 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -28,24 +28,24 @@ | 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及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK继续开发 | -| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK继续开发 | +| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;完整历史、来源与更多场景继续开发 | +| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;完整历史、来源与更多场景继续开发 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 | | P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 | SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 -P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。实例公共入口继续与控制能力同步交付,不能将内部存储链路作为完整SDK验收。 +P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。完整历史和来源仍须完成。 ### P4/P5 下一步实施顺序 1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。 2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。 3. 已实现内部执行核查、步骤结论、解除核查及TASK新激活和旧激活隔离,见[核查报告](reports/phase-5-recovery.md)。POLL跨激活恢复已实现(见[查询恢复](17-workflow-poll-recovery.md));[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)也已接入内部服务,保留原预算、轮次、结果和证据。 -4. 同步开放实例/步骤/信号/来源/审计HTTP、独立SDK和端到端场景,覆盖原键恢复、权限、版本冲突、竞争与进程全停;公共步骤/激活查询须返回目标激活代次,调用方据此发送恢复后的信号,不能要求调用方自行猜测sequence。公共能力逐项记录实际验收结果。 +4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。继续补齐实例筛选分页、完整激活/轮次/信号历史、信号资源审计与Pulse任务流程来源,并提供独立轮询及恢复示例。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。 5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。 -公共接入下一批按以下调用链交付,逐项补齐服务端、独立SDK、Markdown手册和真实HTTP回归: +公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。以下历史、来源、分页和更多独立场景继续交付: | 调用链 | 需要补齐的内容 | 验收重点 | | --- | --- | --- | @@ -105,10 +105,10 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告]( | 业务操作定位 | 精确业务键 → 创建区间 → 游标分页 → 查询详情 | P3,已实现完整示例 | | 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 | | 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) | -| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 | -| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 | -| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 | -| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5 | +| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4,[已实现示例](examples/workflow.md) | +| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4,[已实现模拟外部回执示例](examples/workflow.md);实际外部系统由接入方对接 | +| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5,HTTP/SDK恢复场景已回归,独立可运行示例待补 | +| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5,HTTP/SDK及审计场景已回归,独立可运行示例待补 | | 故障恢复 | 节点/连接中断 → 相同身份核对 → 恢复或人工核查 | P2–P6 | | 执行历史核对 | 读取尝试 → 查看冲突/迟到证据 → 对照权威结果 | P2,已实现完整示例 | @@ -130,6 +130,11 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告]( ## 7. 文档入口 +- [流程实例公共API](api/workflows.md) +- [流程恢复API及SDK](api/workflow-recovery.md) +- [并行作业与回执示例](examples/workflow.md) +- [公共接入验证记录](reports/phase-5-public-api.md) + - [等待恢复](18-workflow-wait-recovery.md) - [等待恢复验证记录](reports/phase-5-wait-recovery.md) - [有界查询恢复](17-workflow-poll-recovery.md) diff --git a/docs/10-workflow-engine-core.md b/docs/10-workflow-engine-core.md index 75dc2c5..bf532cc 100644 --- a/docs/10-workflow-engine-core.md +++ b/docs/10-workflow-engine-core.md @@ -74,7 +74,7 @@ v16及以前的未处理记录缺少冻结来源,升级后统一标为QUARANTI ## 4. 后续存储适配必须满足的条件 -以下保留存储运行器的完整要求。第1项已按[受理报告](reports/phase-4-admission.md)验证;第2–4项结果消费和根接续已接入[实际运行器](12-workflow-runtime.md),第5项的TIMER/POLL已接入[时钟运行器](13-workflow-clock.md),第5项的信号等待已接入[信号运行器](14-workflow-signals.md),内部控制与结清已接入[控制运行器](15-workflow-controls.md),[核查与TASK新激活](16-workflow-recovery.md)已接入;接口和SDK仍继续实现: +以下保留存储运行器的完整要求。第1项已按[受理报告](reports/phase-4-admission.md)验证;第2–4项结果消费和根接续已接入[实际运行器](12-workflow-runtime.md),第5项的TIMER/POLL已接入[时钟运行器](13-workflow-clock.md),第5项的信号等待已接入[信号运行器](14-workflow-signals.md),内部控制与结清已接入[控制运行器](15-workflow-controls.md),[核查与TASK新激活](16-workflow-recovery.md)已接入;公共接口和SDK已接入,完整历史及来源继续实现: 1. 启动时认证、固定不可变版本/输入和策略、校验全部队列、计算实例截止,并在同事务创建实例、门控、首批激活/任务及审计回执。相同身份返回原实例。 2. 消费者领取内部事件后核对命名空间、来源任务版本、激活/轮次及当前步骤版本;消费确认、步骤变化、后续任务或持久化待续记录同事务提交。 diff --git a/docs/11-workflow-admission.md b/docs/11-workflow-admission.md index 9072489..993257e 100644 --- a/docs/11-workflow-admission.md +++ b/docs/11-workflow-admission.md @@ -1,6 +1,6 @@ # Weave 实例受理与首批工作 -状态:内部Go服务与MySQL存储适配已实现实例Start/Get/Steps、固定版本、首批决策落库和可靠结果交付。[后台Inbox消费和根待续扫描](12-workflow-runtime.md)已接通。**尚未开放实例HTTP路由、启动SDK或命令行入口;[内部控制和结清](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共API/SDK继续开发;[信号运行器](14-workflow-signals.md)已接入。** Weave相关公开SDK目前仅支持定义发布。[TIMER和后续POLL轮次](13-workflow-clock.md)已接入,TASK及组合节点已可自动推进,不能将此解释成全部等待/控制能力已实现。 +状态:实例Start/Get/Steps、单步详情、固定版本、首批事务及后台推进已实现,并已接入[公共HTTP与独立SDK](api/workflows.md)。控制、信号、人工恢复见[恢复API](api/workflow-recovery.md)。当前视图与操作审计可读,完整历史、实例列表和生产验收继续交付。 ## 1. 受理事务 diff --git a/docs/12-workflow-runtime.md b/docs/12-workflow-runtime.md index 171efe7..af2ac44 100644 --- a/docs/12-workflow-runtime.md +++ b/docs/12-workflow-runtime.md @@ -1,6 +1,6 @@ # Weave 结果消费与自动推进 -状态:内部实例的TASK结果消费、顺序、分支、有限并行汇合和根检查点接续已接入Orbit后台运行器。[定时等待和后续查询轮次](13-workflow-clock.md)也已接入。[信号等待与最终查询竞争](14-workflow-signals.md)已接入。实例公共HTTP/SDK仍未开放,[失败后的分支取消结清与内部控制](15-workflow-controls.md)已接入,[内部核查及TASK恢复](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共API/SDK继续开发,不能将本阶段等同于完整Weave交付。 +状态:TASK结果消费、顺序、分支、有限并行汇合、根检查点、TIMER/POLL及信号等待已接入Orbit后台。实例、控制、信号与恢复已经提供[公共API及独立SDK](api/workflows.md)。完整历史查询和生产验收继续按计划推进。 ## 1. 后台运行和事务边界 @@ -64,4 +64,4 @@ flowchart LR 进程回归由`make integration`构建二进制后显式执行。缺少一次性数据库或`ORBIT_TEST_SERVER_BINARY`时测试会跳过;跳过不构成进程恢复证据。验证记录见[本阶段报告](reports/phase-4-runtime.md)。 -持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。下一步仍包括实例/来源公共API、独立SDK和通用端到端场景。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。 +持久化信号等待及查询最终信号竞争已按[信号运行约定](14-workflow-signals.md)实现。内部控制及停止结清也已接入。实例公共API与独立SDK已接入,下一步包括完整历史、来源公共API及更多通用场景。P4整体及P5–P7继续按[完整计划](08-development-plan.md)执行。 diff --git a/docs/13-workflow-clock.md b/docs/13-workflow-clock.md index 19f4943..1b5a089 100644 --- a/docs/13-workflow-clock.md +++ b/docs/13-workflow-clock.md @@ -50,7 +50,7 @@ TIMER由待续表直接唤醒,不创建Pulse业务任务,也不占Worker执 当前任务仍在途且查询总截止已到时,实例转NEEDS_REVIEW并关闭新执行门控;步骤保持ACTIVE、轮次保持当前任务引用,继续收集Pulse结果。时钟不释放执行许可,也不假定外部请求已经停止。Pulse恢复将失联执行记录为UNKNOWN后,内部消费再结清当前查询记录,保留核查状态。 -最终信号与在途查询的竞争已按[信号运行约定](14-workflow-signals.md)接入:保存赢家后等待Pulse查询结清,不能提前释放许可或覆盖未知效果。实例及信号公共HTTP/SDK尚未开放。 +最终信号与在途查询的竞争已按[信号运行约定](14-workflow-signals.md)接入:保存赢家后等待Pulse查询结清,不能提前释放许可或覆盖未知效果。实例及信号公共HTTP/SDK已接入,见[实例接口](api/workflows.md)。 ## 5. 升级和验证 diff --git a/docs/14-workflow-signals.md b/docs/14-workflow-signals.md index fcf92ee..8ed22e9 100644 --- a/docs/14-workflow-signals.md +++ b/docs/14-workflow-signals.md @@ -1,6 +1,6 @@ # Weave 外部信号与最终查询结果 -状态:内部 Go 信号受理/查询、SIGNAL 等待、早到保存、截止处理及 POLL 最终信号竞争已接入 MySQL 和后台运行器。**实例与信号的公共 HTTP/独立 SDK 尚未开放**;内部控制和失败分支结清已按[控制约定](15-workflow-controls.md)接入;人工恢复继续按[持续计划](08-development-plan.md)实现。定义发布 SDK 不等于实例运行 SDK。 +状态:信号受理/查询、SIGNAL等待、早到保存、截止处理及POLL最终信号竞争已接入MySQL和后台运行器,并已提供[公共HTTP与独立SDK](api/workflows.md#5-有身份的外部信号)。控制和人工恢复也已接入;完整历史与更多场景继续按[计划](08-development-plan.md)推进。 ## 1. 受理身份与权限 @@ -65,4 +65,4 @@ [受理回归](../internal/storage/mysql/workflow_signals_integration_test.go)、[消费回归](../internal/storage/mysql/workflow_signal_runtime_integration_test.go)、[真实v19升级](../internal/storage/mysql/workflow_signal_upgrade_integration_test.go)和[实际进程恢复](../internal/storage/mysql/workflow_runtime_process_integration_test.go)对应受理/并发/故障/升级边界,实际运行结果记录在[阶段报告](reports/phase-4-signals.md)。 -内部失败分支取消结清、实例暂停/继续/取消已接入。人工核查及[POLL新激活的最终信号换代](17-workflow-poll-recovery.md)已接入;[PENDING接续与独立等待重建](18-workflow-wait-recovery.md)已接入内部服务,下一步交付实例与信号HTTP、独立SDK及完整场景。当前内部存储测试不替代尚未开放的公共接口验收,也不替代生产MySQL切换与容量测试。 +内部失败分支取消结清、实例暂停/继续/取消已接入。人工核查及[POLL新激活的最终信号换代](17-workflow-poll-recovery.md)已接入;[PENDING接续与独立等待重建](18-workflow-wait-recovery.md)已接入内部服务,实例与信号HTTP、独立SDK和基础场景已接入,完整历史及更多场景继续交付。公共接入验收另见[报告](reports/phase-5-public-api.md),也不替代生产MySQL切换与容量测试。 diff --git a/docs/15-workflow-controls.md b/docs/15-workflow-controls.md index d100ee7..8d61a7e 100644 --- a/docs/15-workflow-controls.md +++ b/docs/15-workflow-controls.md @@ -1,6 +1,6 @@ # Weave 控制与停止结清 -状态:内部Go暂停、继续、取消服务及后台失败/取消结清运行器已接入。**公共实例/控制HTTP与独立SDK尚未开放,[内部核查及TASK新激活](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共入口仍在后续交付范围**。本文说明已实现的持久化规则,不把内部存储回归当作全部Weave或生产验收。 +状态:暂停、继续、取消与后台失败/取消结清已实现,并通过[公共控制API及SDK](api/workflows.md#4-暂停继续与取消)调用。人工恢复另见[恢复API](api/workflow-recovery.md)。当前功能回归不等同于生产验收。 ## 1. 控制操作 @@ -60,4 +60,4 @@ CANCELLED仅表示本次流程停止已结清,不撤销先前成功的业务 [控制及结清回归](../internal/storage/mysql/workflow_controls_integration_test.go)覆盖控制版本、权限重报、开始竞争、失败/未知、审计回滚、信号与查询及超过32步骤接续;[真实v20升级](../internal/storage/mysql/workflow_stop_upgrade_integration_test.go)验证兼容回填;[实际Orbit进程回归](../internal/storage/mysql/workflow_runtime_process_integration_test.go)覆盖停止意图和结果在全停期间保存后由两个新进程接续。实际结果见[阶段报告](reports/phase-5-controls.md)。 -下一步仍需完成公共实例/控制/信号/审计接口、独立SDK及可运行场景,再完成P6–P7生产故障、性能、运维和发布验收。当前测试不替代生产MySQL复制、故障切换、备份恢复或RPO/RTO验证。 +公共实例/控制/信号/审计接口、独立SDK及基础可运行场景已接入,下一步补齐完整历史、来源与更多场景,再完成P6–P7生产故障、性能、运维和发布验收。当前测试不替代生产MySQL复制、故障切换、备份恢复或RPO/RTO验证。 diff --git a/docs/16-workflow-recovery.md b/docs/16-workflow-recovery.md index f639582..1088727 100644 --- a/docs/16-workflow-recovery.md +++ b/docs/16-workflow-recovery.md @@ -1,6 +1,6 @@ # Weave 人工核查与接续 -状态:内部Go服务已提供执行核查、步骤结论确认、解除核查,以及TASK/POLL新激活重试。POLL跨激活预算与附属信号换代见[查询恢复](17-workflow-poll-recovery.md)。公共HTTP、独立SDK和通用接入示例尚未交付;[PENDING接续及独立等待重建](18-workflow-wait-recovery.md)已接入内部服务。完整交付继续按[开发计划](08-development-plan.md)推进。 +状态:执行核查、步骤结论、解除核查、TASK/POLL新激活、PENDING等待接续和SIGNAL/TIMER换代已实现,并接入[六类公共恢复API及SDK](api/workflow-recovery.md)。当前详情与管理审计可读,完整历史和生产验收继续按[计划](08-development-plan.md)推进。 ## 1. 六类操作 @@ -74,6 +74,6 @@ POLL跨激活恢复已实现,保留原业务轮、同轮剩余尝试、查询 | POLL次数耗尽,人工取得最终结果 | 核实历史请求均已结束 → ResolveStep(符合固定成功映射的结果) → Continue;原used_rounds不改变 | | 取消后出现UNKNOWN | ReviewTask → 等待消费 → ResolveStep → Continue → 原取消结清;不创建后继工作 | -这些是内部服务流程说明,公共接入方目前不能调用未开放的HTTP路由或SDK。后续接口文档和可运行场景须覆盖相同证据与版本要求。 +以上内部操作已通过[公共恢复API及SDK](api/workflow-recovery.md)提供,六类真实服务场景保留相同证据与版本要求。 [存储专项](../internal/storage/mysql/workflow_recovery_integration_test.go)、[实际进程测试](../internal/storage/mysql/workflow_runtime_process_integration_test.go)和[阶段报告](reports/phase-5-recovery.md)记录验证范围。本阶段不替代生产数据库高可用、灾难恢复和容量验收。 diff --git a/docs/17-workflow-poll-recovery.md b/docs/17-workflow-poll-recovery.md index 5fbdd7e..3ee4c4d 100644 --- a/docs/17-workflow-poll-recovery.md +++ b/docs/17-workflow-poll-recovery.md @@ -1,6 +1,6 @@ # Weave 有界查询的人工恢复 -状态:内部 `RetryStep` 已支持 POLL 新激活,保留跨激活的业务轮次、同轮剩余尝试和绝对截止;附属最终信号等待随新激活换代。[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)已接入内部服务,公共实例/恢复 HTTP、独立 SDK 和可运行接入示例继续按[开发计划](08-development-plan.md)交付。验证范围见[阶段报告](reports/phase-5-poll-recovery.md)。 +状态:POLL新激活保留跨激活的轮次、同轮尝试和原绝对截止,附属信号随新激活换代;已通过[公共恢复API](api/workflow-recovery.md)提供独立SDK。PENDING等待接续见[等待恢复](18-workflow-wait-recovery.md),历史实现验证见[报告](reports/phase-5-poll-recovery.md)。完整历史、通用接入场景及生产验证继续交付。 ## 1. 业务轮次与请求尝试 @@ -50,7 +50,7 @@ POLL 带最终信号时,恢复创建同契约、同精确关联键、同原截 当前代次仍有 PENDING 信号时,先消费这些事实,恢复拒绝直接丢弃。已有赢家时核对其受理证明与关联后保留历史。最终信号原截止已过时不重新开始等待。 -这些规则覆盖 POLL 附属等待;独立 SIGNAL/TIMER 使用[RearmWait](18-workflow-wait-recovery.md),公共接口仍待交付。 +这些规则覆盖 POLL 附属等待;独立 SIGNAL/TIMER 使用[RearmWait](18-workflow-wait-recovery.md),公共接口已接入。 ## 5. 内部调用场景 diff --git a/docs/18-workflow-wait-recovery.md b/docs/18-workflow-wait-recovery.md index c8b7a89..abc107d 100644 --- a/docs/18-workflow-wait-recovery.md +++ b/docs/18-workflow-wait-recovery.md @@ -1,6 +1,6 @@ # Weave 等待核查与恢复 -状态:内部服务已实现 `ContinuePoll` 和 `RearmWait`,均使用[人工恢复的权限、版本、证据及原键回执规则](16-workflow-recovery.md)。公共流程 HTTP、独立 SDK、通用可运行场景与生产验收继续按[开发计划](08-development-plan.md)交付;本页不是公共 SDK 调用示例。 +状态:ContinuePoll和RearmWait已实现,并已接入[公共恢复HTTP及独立SDK](api/workflow-recovery.md)。均遵守[人工恢复权限、版本、证据及原键规则](16-workflow-recovery.md)。本页说明内部持久化语义,完整历史和生产验收继续按[计划](08-development-plan.md)推进。 ## 1. 两类操作 diff --git a/docs/api/README.md b/docs/api/README.md index a3c4731..c28852f 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -26,8 +26,9 @@ | `/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) | +| `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例另用启动接口 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) | +| `/v1/workflows`、步骤详情、控制、信号及操作审计 | 已接入HTTP与独立SDK | [实例接口](workflows.md) | +| `/v1/workflows/{id}/recovery/*` 六类人工恢复 | 已接入有证据恢复及独立SDK | [恢复接口](workflow-recovery.md) | 未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。 diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 72703c5..00bf43c 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -63,3 +63,5 @@ P3 独立任务核查、人工重试和操作审计分别增加 `tasks:review` 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)。 + +P4/P5实例公共接入增加`workflows:start/read/control/signal/recover`及`workflows:audit:read`。五个独立CLI开关`--workflow-run-access`、`--workflow-control-access`、`--workflow-signal-access`、`--workflow-recovery-access`、`--workflow-audit-access`分别明确授予。前四个同时包含实例读取;audit只授予流程操作审计读取。原`--workflow-access`仍仅授予定义读/发布,任务`--recovery-access`不授予流程恢复。全部固定队列仍按当前凭据校验,详见[实例权限表](workflows.md#1-路由权限与-sdk)。初始化权限集合在交付凭据前校验。 diff --git a/docs/api/workflow-definitions.md b/docs/api/workflow-definitions.md index 31e993b..b8fee87 100644 --- a/docs/api/workflow-definitions.md +++ b/docs/api/workflow-definitions.md @@ -1,6 +1,6 @@ # 流程定义与不可变版本 -状态:P4 定义校验、持久化发布及独立 Go SDK 已实现;实例启动、推进、等待和恢复尚未实现。规则格式见[结构版本1](workflow-definition-format.md),可运行源码及命令见[研发发布场景](../examples/workflow-definition.md)。 +状态:P4 定义校验、持久化发布及独立 Go SDK 已实现;实例启动、控制、等待和恢复已接入[实例API](workflows.md)及[恢复API](workflow-recovery.md)。规则格式见[结构版本1](workflow-definition-format.md),可运行源码及命令见[研发发布场景](../examples/workflow-definition.md)。 ## 1. 权限、传输与范围 diff --git a/docs/api/workflow-recovery.md b/docs/api/workflow-recovery.md new file mode 100644 index 0000000..ee8c41a --- /dev/null +++ b/docs/api/workflow-recovery.md @@ -0,0 +1,146 @@ +# 流程人工核查与恢复 API / SDK + +状态:六类内部恢复已接入公共HTTP和独立SDK。所有路径以 `/v1/workflows/{id}/recovery/` 开头,均为POST,要求 `workflows:recover`、全部固定队列范围、Bearer凭据、稳定Idempotency-Key及application/json。成功首次与重复响应均为200。身份、读查询、错误码见[实例接口](workflows.md)。 + +恢复只针对NEEDS_REVIEW实例。SDK不会自动重试、补齐声明、刷新版本或生成新键。核查前应读取当前实例、当前步骤、相关任务及外部证据;每次新决定使用当前版本,响应未知则用原决定原键核对。输入和结果最多256 KiB,请求体总计最多512 KiB。 + +## 1. 公共字段 + +所有请求包含WorkflowRecoveryDecision的**扁平字段**: + +| 字段 | 必填与语义 | +| --- | --- | +| expected_revision | 必填,当前实例的非零revision十进制字符串,不能用control_revision替代 | +| reason | 必填,1–1024个Unicode字符;不允许全空白或NUL | +| evidence_ref | 必填,1–512个Unicode字符,不含空白或控制字符;平台不访问引用、不存放秘密 | + +步骤类请求还包含WorkflowStepDecision的扁平字段:step_key(固定定义步骤名,最多64个ASCII字符)、activation_id(当前32位激活ID)、expected_step_revision(当前非零步骤版本字符串)。实例版本、步骤版本及激活身份一起检查,旧激活不能操作新工作。 + +服务端检查待消费/隔离Inbox、历史任务归属、仍在执行的尝试及未释放许可。声明为true是操作者确认,不替代服务端验证。恢复不会重置原绝对截止、次数或业务关联;已有停止目标不会被撤销。 + +## 2. 六类命令 + +| 路径后缀 | SDK方法 / 请求类型 | 额外必填字段与结果 | +| --- | --- | --- | +| task-review | ReviewWorkflowTask / ReviewWorkflowTaskRequest | task_id、expected_task_revision字符串、execution_settled=true、outcome=SUCCEEDED或FAILED、非null result;核实本实例任一历史激活的待核查执行 | +| step-resolve | ResolveWorkflowStep / ResolveWorkflowStepRequest | 步骤公共字段、execution_settled=true、outcome=SUCCEEDED或FAILED、result;确认当前叶子步骤的逻辑结论 | +| continue | ContinueWorkflow / ContinueWorkflowRequest | review_resolved=true;确认全部核查事项已处理后解除核查 | +| step-retry | RetryWorkflowStep / RetryWorkflowStepRequest | 步骤公共字段、safe_to_retry=true;为TASK/POLL创建新激活,保留原预算和截止 | +| poll-continue | ContinueWorkflowPoll / ContinueWorkflowPollRequest | 步骤公共字段、pending_confirmed=true;权威结果已确认为PENDING时按原间隔恢复等待,不重查同轮 | +| wait-rearm | RearmWorkflowWait / RearmWorkflowWaitRequest | 步骤公共字段、wait_verified=true;独立SIGNAL/TIMER在原截止内更换等待代次,含首次最多32代 | + +所有确认布尔值必须在HTTP中显式提供且为true。拒绝嵌套`Decision`、伪造actor_id、大小写别名和未声明字段。 + +执行核查请求示例: + +```json +{ + "expected_revision":"8", + "reason":"已核实请求完成且外部作业仍在处理", + "evidence_ref":"evidence:query-001", + "task_id":"88888888888888888888888888888888", + "expected_task_revision":"4", + "execution_settled":true, + "outcome":"SUCCEEDED", + "result":"PENDING" +} +``` + +ReviewTask只改变执行结论,旧UNKNOWN尝试和证据保持原样。它产生新的内部结果,必须等待后台消费,当前步骤及实例不会自动解除核查。POLL请求成功不等于流程业务成功;ResolveStep对POLL的成功结果必须映射到固定的业务成功值,不能把PENDING写成步骤成功。TIMER成功只能在首次等待时长满足后确认,result必须为显式null。 + +PENDING接续请求示例: + +```json +{ + "expected_revision":"11", + "reason":"沿原间隔继续查询外部作业", + "evidence_ref":"evidence:query-001", + "step_key":"query", + "activation_id":"66666666666666666666666666666666", + "expected_step_revision":"3", + "pending_confirmed":true +} +``` + +相同字段布局适用于step-resolve、step-retry和wait-rearm,额外字段按上表替换。Continue只有实例公共字段及review_resolved,不要求步骤字段。 + +## 3. 回执与当前视图 + +六个方法统一返回WorkflowRecoveryResult;HTTP的data字段为该对象: + +| 字段 | 含义 | +| --- | --- | +| instance | 首次管理事务完成时的完整实例快照,字段同[启动回执](workflows.md#2-固定版本启动) | +| audit_id | 本次流程管理审计ID,可用GetWorkflowOperation读取原因、证据及前后摘要 | +| step | 步骤类操作返回当前步骤快照,包含激活代次;不是后续实时状态 | +| task | task-review或step-retry返回执行任务快照;字段同GetTask | +| task_audit_id | task-review对应的独立任务审计ID,与流程审计同事务保存 | +| deduplicated | 是否恢复原回执;恢复原键仍检查当前权限 | + +步骤恢复回执示意,省略字段按协议确实不存在,并非JSON占位: + +```json +{ + "request_id":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data":{ + "instance":{ + "id":"22222222222222222222222222222222", + "namespace_id":"33333333333333333333333333333333", + "definition_id":"11111111111111111111111111111111", + "definition_version_id":"44444444444444444444444444444444", + "version":"v1","state":"NEEDS_REVIEW","revision":"13", + "paused":false,"control_revision":"3", + "input":{"n":9007199254740993},"business_key":"operation-001 ", + "deadline_at":"2026-09-18T08:01:00Z", + "created_at":"2026-09-18T08:00:00Z","updated_at":"2026-09-18T08:00:10Z" + }, + "audit_id":"99999999999999999999999999999999", + "step":{ + "id":"55555555555555555555555555555555","key":"query","kind":"POLL", + "state":"WAITING","revision":"4", + "activation_id":"66666666666666666666666666666666","activation_sequence":1 + }, + "deduplicated":false + } +} +``` + +ContinuePoll保留当前激活与信号代次;RetryStep及RearmWait创建新激活。恢复等待或创建新任务后仍须显式Continue解除核查;paused仍为true时还需ControlWorkflow(RESUME)。任何恢复操作都不盲目撤回已发生的外部业务效果。 + +## 4. SDK接入顺序 + +以下为嵌入接入方函数的调用片段,api是已认证Client,workflowID为目标实例,operationKey与本次完整命令需要由调用方持久保存: + +```go +current, err := api.GetWorkflowStep(ctx, workflowID, "query") +if err != nil { return err } +if current.Step.ActivationID == nil { return errors.New("query is not activated") } +command := orbit.ContinueWorkflowPollRequest{ + WorkflowStepDecision: orbit.WorkflowStepDecision{ + WorkflowRecoveryDecision: orbit.WorkflowRecoveryDecision{ + ExpectedRevision: current.Revision, + Reason: "已核实权威PENDING结果,沿原预算接续", + EvidenceRef: "evidence:query-001", + }, + StepKey: current.Step.Key, + ActivationID: *current.Step.ActivationID, + ExpectedStepRevision: current.Step.Revision, + }, + PendingConfirmed: true, +} +receipt, err := api.ContinueWorkflowPoll(ctx, workflowID, operationKey, command) +if err != nil { return err } // 不生成新键绕过冲突;结果不明时原命令原键核对。 +audit, err := api.GetWorkflowOperation(ctx, workflowID, receipt.AuditID) +if err != nil { return err } +fmt.Println(audit.Action, receipt.Instance.State) +``` + +完整、可执行的六类SDK场景位于[真实服务集成测试](../../sdk/go/workflow_instances_integration_test.go):UNKNOWN→执行核查→逻辑结论→解除核查;已知失败→剩余预算重试,同时重建信号等待;UNKNOWN查询→核查为PENDING→原间隔接续。运行方式为仓库根目录`make integration`,由脚本创建隔离MySQL、实际服务及凭据。 + +一般步骤是:查询当前事实 → 提交一个有证据的决定 → 等待该决定产生的内部结果消费 → 重新读取当前版本 → 再提交下一决定。不要自动将任意STATE_CONFLICT重试为“最新版本”;冲突可能意味着新的在途结果、权限变化、原截止已到或预算耗尽,应重新核查。相同命令的响应未知则仍使用原版本和原键。 + +## 5. 兼容与剩余范围 + +新路由仅在本开发版本及之后可用;旧服务返回404时不能降级为普通任务重试来绕过Weave所有权。SDK类型独立、版本字段保留uint64精度。规则及数据模型未增加表、字段、外键或级联。 + +本批尚不提供完整历史激活/轮次遍历、实例筛选列表或信号资源审计列表;当前输入结果、当前查询预算、单条信号和流程管理审计可读。生产数据库切换、容量及恢复目标继续按P6验证,见[阶段报告](../reports/phase-5-public-api.md)。 diff --git a/docs/api/workflows.md b/docs/api/workflows.md index b9d48fa..4f4fb28 100644 --- a/docs/api/workflows.md +++ b/docs/api/workflows.md @@ -1,15 +1,171 @@ -# 流程接口状态 +# 流程实例、控制、信号与操作审计 -状态:定义格式校验、创建与不可变版本发布已实现,详见[定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md)和[可运行SDK例子](../examples/workflow-definition.md)。实例执行与管理仍按P4–P5开发。下表仅列后续实例接口。 +状态:下表公共 HTTP 与独立 Go SDK 已接入。定义发布见[定义接口](workflow-definitions.md),人工恢复见[恢复接口](workflow-recovery.md),完整运行例子见[并行作业与回执](../examples/workflow.md)。SDK 尚未发布正式版本,服务端与 SDK 应使用同一开发版本。 -| 操作 | 候选接口 | 完成要求 | +实例筛选分页、历史激活/轮次/信号列表及 Pulse 任务的流程来源查询继续开发。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。 + +## 1. 路由、权限与 SDK + +下文 `{id}` 为32位小写十六进制流程实例ID,不是定义ID;身份和命名空间来自凭据。所有读取均检查固定定义引用的全部队列。变更还需要 `Content-Type: application/json`、`Authorization: Bearer ` 和一个稳定的 `Idempotency-Key`;键最长128字节,字符规则沿用[任务协议](tasks.md)。请求体最大512 KiB,拒绝重复字段、大小写别名、未声明字段和多余查询参数。 + +| 方法与路径 | 权限 | Go SDK 方法 | | --- | --- | --- | -| 启动实例 | `POST /v1/workflows` | 固定版本并幂等启动,首批任务与实例原子创建 | -| 查询实例 | `GET /v1/workflows/{workflow_id}` | 状态、暂停标记、版本、等待原因、预算摘要 | -| 查询步骤 | `GET /v1/workflows/{workflow_id}/steps` | 步骤、激活、查询轮次、输入/结果引用与任务关联 | -| 提交信号 | `POST /v1/workflows/{workflow_id}/signals` | 幂等、授权、业务操作匹配;受理与消费分别可查 | -| 暂停/继续 | `POST /v1/workflows/{workflow_id}:pause`、`:resume` | 控制版本与门控原子变化,保留进度、预算与审计 | -| 取消 | `POST /v1/workflows/{workflow_id}:cancel` | 停止后续工作,收集在途结果,不伪装业务撤销 | -| 核查恢复 | 具体接口待实现阶段冻结 | 权限、证据、预期版本与稳定操作身份;保留旧激活历史 | +| `POST /v1/workflows` | `workflows:start`;有任务队列时还需 `tasks:submit` | StartWorkflow | +| `GET /v1/workflows/{id}` | `workflows:read` | GetWorkflow | +| `GET /v1/workflows/{id}/steps` | `workflows:read` | GetWorkflowSteps | +| `GET /v1/workflows/{id}/steps/{key}` | `workflows:read` | GetWorkflowStep | +| `POST /v1/workflows/{id}/control` | `workflows:control` | ControlWorkflow | +| `POST /v1/workflows/{id}/signals` | `workflows:signal` | SendWorkflowSignal | +| `GET /v1/workflows/{id}/signals/{signal}` | `workflows:read` | GetWorkflowSignal | +| `GET /v1/workflows/{id}/operations` | `workflows:audit:read` | ListWorkflowOperations | +| `GET /v1/workflows/{id}/operations/{operation}` | `workflows:audit:read` | GetWorkflowOperation | -配套场景:并行生成结果、等待审核信号、外部作业有界查询、暂停与核查恢复。正式示例将在对应 API 和 SDK 通过端到端验证后提供。节点与状态设计见 [Weave 功能与设计](../06-workflow-spec.md)。 +GET成功为200;启动及信号首次受理为201,原键回执为200;控制成功为200。控制响应表示意图已保存,取消结清由后台完成。SDK签名、独立类型及各字段中文说明见[调用方法](../../sdk/go/workflow_instances.go)和[协议类型](../../sdk/go/workflow_instance_types.go)。 + +CLI显式开关分别为:`--workflow-access`(定义读/发布)、`--workflow-run-access`(实例读/启动)、`--workflow-control-access`(实例读/控制)、`--workflow-signal-access`(实例读/信号)、`--workflow-recovery-access`(实例读/恢复)、`--workflow-audit-access`(流程操作审计读)。普通 Worker、定义发布及任务核查权限均不会自动获得流程管理权限。初始化会先验证权限集合再交付凭据,具体命令见[示例](../examples/workflow.md)。 + +## 2. 固定版本启动 + +`StartWorkflow(ctx, key, StartWorkflowRequest)` 的请求: + +```json +{ + "definition_id": "11111111111111111111111111111111", + "version": "v1", + "input": {"n": 9007199254740993}, + "business_key": "operation-001 " +} +``` + +| 字段 | 类型与约束 | +| --- | --- | +| definition_id | 必填,当前命名空间的定义ID | +| version | 必填,1–64个ASCII版本字符,首字符字母或数字,之后还可用 `_ . -`;不解析latest | +| input | 必填,最多256 KiB JSON,必须符合固定版本输入契约;显式null与缺失不同,数字不经过float64 | +| business_key | 可选,默认空字符串,最多191个Unicode字符;尾部空格保留,不能替代幂等键 | + +示意完整回执: + +```json +{ + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data": { + "instance": { + "id": "22222222222222222222222222222222", + "namespace_id": "33333333333333333333333333333333", + "definition_id": "11111111111111111111111111111111", + "definition_version_id": "44444444444444444444444444444444", + "version": "v1", + "state": "RUNNING", + "revision": "2", + "paused": false, + "control_revision": "1", + "input": {"n": 9007199254740993}, + "business_key": "operation-001 ", + "deadline_at": "2026-09-18T08:01:00Z", + "created_at": "2026-09-18T08:00:00Z", + "updated_at": "2026-09-18T08:00:00Z" + }, + "deduplicated": false + } +} +``` + +`GetWorkflow`的data直接是instance对象。可能状态为RUNNING、WAITING、CANCELLING、NEEDS_REVIEW、SUCCEEDED、FAILED、CANCELLED;paused是独立标记。`output`在存在结果时返回,成功输出可以为显式null。`cancel_requested_at`、`stop_kind`(CANCEL/FAILURE)、`stop_requested_at`、`completed_at`仅有值时返回。所有时间为UTC RFC3339,revision及control_revision为十进制字符串,SDK使用uint64。 + +启动固定定义版本和总截止,并在同事务创建门控、步骤和首批任务。原键同内容返回**首次受理快照**,不重算时间,不代表当前状态;同键不同内容返回IDEMPOTENCY_CONFLICT。网络响应丢失时保持原键和原请求核对,再用GetWorkflow读取当前状态。每命名空间最多10000个未终结实例,已保存的原回执在满额时仍可恢复。 + +## 3. 步骤及当前激活 + +GetWorkflowSteps返回 `{instance_id, revision, steps}`,steps按key排序、最多128项,不含内部根步骤。每项含 `id/key/kind/state/revision`;已激活时还含 `activation_id/activation_sequence`,TASK还含直接 `task_id`。未激活步骤没有激活ID和代次。步骤状态包括BLOCKED、ACTIVE、WAITING、SUCCEEDED、FAILED、CANCELLED、NEEDS_REVIEW、SKIPPED。 + +GetWorkflowStep返回单个步骤详情,实例revision与当前步骤同事务读取: + +```json +{ + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data": { + "instance_id": "22222222222222222222222222222222", + "revision": "8", + "step": { + "id": "55555555555555555555555555555555", + "key": "receipt", + "kind": "SIGNAL", + "state": "WAITING", + "revision": "2", + "activation_id": "66666666666666666666666666666666", + "activation_sequence": 1 + }, + "input": {"name": "done", "payload_version": "v1", "correlation": "operation-001 "}, + "activated_at": "2026-09-18T08:00:05Z", + "wait": { + "id": "77777777777777777777777777777777", + "kind": "SIGNAL", + "state": "WAITING", + "name": "done", + "payload_version": "v1", + "correlation_key": "operation-001 ", + "deadline_at": "2026-09-18T08:00:35Z" + } + } +} +``` + +- `input/output`属于当前激活,每次只读取一份;未激活或尚无输出时省略,显式null保留。`activated_at/completed_at`属于当前激活,不能据新激活时间重算原预算。 +- POLL的 `poll` 包含 used_rounds、first_round、first_round_attempts_used、max_rounds、max_attempts_per_round、interval_ms、deadline_at、last_task_id;current_task_id、next_poll_at、resolved_at在有值时返回。last_task_id指当前激活最后业务轮任务,可再通过GetTask读取其状态/版本。 +- SIGNAL、TIMER和有最终信号的POLL返回 `wait`,包含当前等待身份、类型、状态、原截止和关联;name/payload_version不适用于TIMER,旧未核验等待也可能缺失。winner_signal_id和resolved_at在有值时返回。已选中赢家不代表在途任务已结清。 +- 时间预算不会随人工换代增加。信号发送者应读取activation_sequence,不能把激活ID、步骤revision或任务尝试epoch当作等待代次。 + +## 4. 暂停、继续与取消 + +`ControlWorkflow(ctx, id, key, WorkflowControlRequest)`: + +```json +{"operation":"PAUSE","expected_control_revision":"1","reason":"维护期间停止推进新工作"} +``` + +operation必填,为PAUSE/RESUME/CANCEL;expected_control_revision为读取到的非零control_revision字符串,reason为1–1024个Unicode字符,不允许全空白或NUL。成功data为 `{instance, deduplicated}`,instance字段同启动回执。新决定使用旧控制版本返回STATE_CONFLICT;原键恢复仍返回第一次的冻结决定。 + +PAUSE关闭新执行授权,在途结果继续收集;RESUME不清除NEEDS_REVIEW,不撤销首次停止目标,不延长原截止。CANCEL保存取消意图并停止后继,保留在途许可直到真实执行结束;未知效果仍进入核查。后台任务结果改变通用revision,不要求操作者追逐每个结果去更新control_revision。详细结清规则见[控制设计](../15-workflow-controls.md)。 + +## 5. 有身份的外部信号 + +`SendWorkflowSignal(ctx, id, key, SendWorkflowSignalRequest)`: + +```json +{ + "name":"done", + "payload_version":"v1", + "target_step_key":"receipt", + "target_activation_sequence":1, + "correlation_key":"operation-001 ", + "payload":{"n":9007199254740993}, + "reason":"已核验外部回执", + "evidence_ref":"evidence:receipt-001" +} +``` + +name/payload_version/target_step_key必填,分别最多128/32/64个ASCII名称字符;代次为非零uint32数字。correlation_key为1–512字节UTF8、精确匹配并保留尾部空格。payload必填、最多256 KiB,符合固定契约时可显式null。reason规则同控制原因;evidence_ref可选、最多512个Unicode字符,不含空白或控制字符,平台不会访问这个引用。 + +成功data为 `{signal, deduplicated}`。signal字段:id、namespace_id、workflow_id、revision字符串、name、payload_version、target_step_key、target_activation_sequence、correlation_key、payload、actor_id、state、accepted_at、expires_at;evidence_ref、disposition、consumed_wait_id在有值时返回。actor_id从认证获得。GetWorkflowSignal直接返回当前signal对象。 + +受理与消费分离:PENDING可以稍后变为CONSUMED、REJECTED或EXPIRED。每实例全部历史受理记录最多1024条,原键回执先于上限检查。早到信号可以指定首次代次1;当前旧代次信号保存为REJECTED/STALE_ACTIVATION,未来代次冲突。恢复为新代次后,原信号原键仍返回旧回执,不能用重报改变目标。最终查询与信号按原事件时间竞争,PENDING查询不争夺业务完成权,见[信号设计](../14-workflow-signals.md)。 + +## 6. 操作审计与错误处理 + +ListWorkflowOperations接受HistoryPageRequest:limit默认20、范围1–100;cursor最大1024字节,绑定命名空间、流程ID和查询种类,不可跨资源复用。data为 `{items, next_cursor?}`;每项含 id、workflow_id、operation_id、actor_id、action、created_at,按时间及ID倒序。GetWorkflowOperation除这些字段外还返回reason、evidence_ref(可空省略)、before、after JSON摘要。单独读取正文,避免列表聚合大结果。 + +该接口当前查询resource_kind=workflow的启动、推进、控制和恢复审计;独立任务核查审计通过恢复回执的task_audit_id关联。信号自身的受理状态通过GetWorkflowSignal读取,信号资源审计列表及完整激活历史继续开发。 + +| HTTP/错误码 | 处理方式 | +| --- | --- | +| 400 INVALID_ARGUMENT | 修正字段、固定契约或查询游标;不要把遗漏声明默认成true | +| 401 UNAUTHENTICATED / 403 PERMISSION_DENIED | 核对凭据和显式动作权限,重报不会绕过撤权 | +| 404 NOT_FOUND | 资源不存在、跨命名空间或队列不在授权范围 | +| 409 STATE_CONFLICT / IDEMPOTENCY_CONFLICT | 当前状态/版本不符或原键请求改变;先读取权威状态 | +| 409 WORKFLOW_INSTANCE_LIMIT / WORKFLOW_SIGNAL_LIMIT | 固定资源数量上限,不能通过新键盲目补发 | +| 429 QUEUE_FULL / APPLICATION_FULL | 新工作配额不足;保持原请求身份核对,服务端给Retry-After | +| 413 BODY_TOO_LARGE / 415 UNSUPPORTED_MEDIA_TYPE | 缩小请求或使用application/json | +| 503 UNAVAILABLE / 网络异常 | 提交结果可能不明,保持原键和原命令核对;SDK不自动换键 | + +已实现路由之外的路径为404。回归证据和剩余交付范围见[公共接入报告](../reports/phase-5-public-api.md)。 diff --git a/docs/examples/README.md b/docs/examples/README.md index 68d02ba..036f720 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -6,7 +6,13 @@ - [完整命令和结果](workflow-definition.md)、[SDK源码](../../sdk/go/examples/workflow-definition/main.go)。 - 六类节点的有限结构定义、原键及同版本确认、新版本发布、旧版本读取保持不变。 -- 当前只发布定义,实例执行仍在P4–P5开发。 +- 本例只发布定义;实际运行见下方流程示例。 + +## 已可运行:并行作业、定时等待与外部回执 + +- [完整命令、输入输出与失败处理](workflow.md)、[独立SDK源码](../../sdk/go/examples/workflow/main.go)。 +- 两个任务并行处理并汇合,计时等待后接收显式目标代次的信号,再执行后续任务;最终三个任务完成并有界排空。 +- 启动及信号原键重复不会建立新的实例或信号。 ## 已可运行:业务事务与任务提交Outbox diff --git a/docs/examples/workflow-definition.md b/docs/examples/workflow-definition.md index cd7e6ef..98c6c46 100644 --- a/docs/examples/workflow-definition.md +++ b/docs/examples/workflow-definition.md @@ -1,6 +1,6 @@ # 研发定义与发布流程 -本例运行真实HTTP发布与读取,验证六种节点的格式以及不可变版本。当前不创建流程实例、不执行外部业务。实例运行示例随P4–P5交付。 +本例运行真实HTTP发布与读取,验证六种节点的格式以及不可变版本。当前不创建流程实例、不执行外部业务。实际运行另见[并行作业与回执示例](workflow.md)。 源码:[workflow-definition/main.go](../../sdk/go/examples/workflow-definition/main.go)。接口:[定义发布](../api/workflow-definitions.md);字段规范:[结构版本1](../api/workflow-definition-format.md)。 diff --git a/docs/examples/workflow.md b/docs/examples/workflow.md new file mode 100644 index 0000000..6029516 --- /dev/null +++ b/docs/examples/workflow.md @@ -0,0 +1,57 @@ +# 并行生成结果 → 等待外部回执 → 后续任务 + +[完整源码](../../sdk/go/examples/workflow/main.go)通过独立Go SDK发布固定版本、启动实例,并使用真实自动Worker处理任务。流程是两个文本规范化任务并行完成并汇合,等待500毫秒,等待模拟外部确认信号,最后用左侧输出执行发布步骤。输入为 `{"text":" hello orbit "}`,输出为 `{"text":"HELLO ORBIT"}`。 + +## 1. 启动 + +环境:Go 1.27.1、MySQL 8.4+,已设置专用ORBIT_MYSQL_DSN。仓库根目录执行: + +```sh +go run ./cmd/orbit-migrate up +mkdir -p .local +chmod 700 .local +go run ./cmd/orbit-admin bootstrap \ + --application workflow-example --namespace default --environment development \ + --queue workflows --workflow-access --workflow-run-access \ + --workflow-signal-access --worker-access \ + --output .local/workflow.credentials.json +go run ./cmd/orbit +``` + +初始化仅执行一次,文件存在时不会覆盖。重用该凭据运行例子无需重复bootstrap。另开终端,从仓库根目录执行: + +```sh +cd sdk/go +ORBIT_URL=http://127.0.0.1:8080 \ +ORBIT_CREDENTIALS_FILE=../../.local/workflow.credentials.json \ +go run ./examples/workflow +``` + +生产使用HTTPS/WSS,并为定义发布者、启动者、信号发送者及Worker分别签发所需权限。本示例使用一个专用开发凭据,未申请人工恢复或控制权限。 + +## 2. 调用链与输出 + +CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → 原键再次StartWorkflow → 自动Worker并行执行 → GetWorkflowStep读取当前信号契约与代次 → SendWorkflowSignal → 原键再次发送 → 后继任务完成 → GetWorkflow → Worker有界排空。 + +预期输出中的ID每次运行不同: + +```text +workflow=<32位实例ID> state=SUCCEEDED result={"text":"HELLO ORBIT"} start_deduplicated=true signal_deduplicated=true +worker_drained=true started=3 confirmed=3 +``` + +示例会确认三个任务实际开始、三个结果确认且没有运行中的处理器。信号的target_activation_sequence读取自当前步骤,correlation_key使用等待原关联,不猜测恢复代次;重复键不改变目标。示例模拟已核验外部回执,不调用真实第三方业务系统。实际使用中由独立外部调用者保存证据后发送。 + +## 3. 失败与恢复 + +示例最长运行45秒,Worker排空最长5秒。NEEDS_REVIEW、FAILED、CANCELLED或Worker提前退出都会报错,不把受理回执当成业务成功。命令重新运行会生成新的独立示例流程,不属于故障时的原身份核对;生产接入方应在自身事务中保存operationKey及完整命令。 + +出现网络结果不明时,使用原definition/version/input/business_key和原幂等键重报。暂停不延长原截止;信号受理不表示消费。人工恢复使用[六类恢复命令](../api/workflow-recovery.md),不能用普通任务RetryTask绕过流程门控和预算。 + +示例成功后保留定义、实例、任务、信号和审计,不硬删除历史。每次运行使用唯一处理器名,避免其他Worker误消费该例子。完整命令、HTTP字段和权限见[实例API](../api/workflows.md)。 + +## 4. 其他可执行恢复场景 + +[公共SDK集成测试](../../sdk/go/workflow_instances_integration_test.go)还实际运行:启动响应丢失的原键恢复;暂停/继续、定时等待、信号消费和取消;UNKNOWN执行核查、步骤结论及解除核查;剩余尝试内TASK重试与SIGNAL换代;确认PENDING后沿原查询预算继续。使用`make integration`在一次性环境复现。 + +历史激活列表、流程来源查询、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。 diff --git a/docs/reports/phase-5-public-api.md b/docs/reports/phase-5-public-api.md new file mode 100644 index 0000000..2c97645 --- /dev/null +++ b/docs/reports/phase-5-public-api.md @@ -0,0 +1,42 @@ +# P4/P5 流程公共 HTTP 与独立 SDK 接入回归 + +日期:2026-09-18。范围为15条实例/控制/信号/恢复/操作审计路由、当前步骤详情、独立Go SDK和一个实际并行执行场景。本报告不表示P4/P5全部范围或生产平台已完成。 + +## 实现 + +- StartWorkflow固定版本和输入;GetWorkflow读取当前实例,GetWorkflowSteps返回最多128项元数据,GetWorkflowStep只读取单个当前激活的输入/输出、查询预算及等待。当前代次进入新步骤视图和恢复回执;旧冻结回执不改写。 +- ControlWorkflow使用control_revision;信号固定步骤和激活代次。六类人工恢复复用现有事务服务,版本、证据、明确声明、原预算和停止意图全部保留。 +- 流程资源操作审计支持独立权限、摘要分页和单条正文,游标绑定命名空间、实例及查询种类。信号资源审计及完整激活历史尚待补齐。 +- 严格JSON支持匿名公共字段的扁平组合,仍拒绝大小写别名、未知/重复字段和缺少必填确认,保留显式null和精确整数。 +- 初始化分别授予执行、控制、信号、恢复和审计权限,原workflow-access仍只定义读/发布。联调发现新审计动作漏登记导致凭据被拒绝,已补齐认证白名单,并在凭据交付前验证权限集合。 +- SDK保持独立module,协议类型有中文注释,不导入服务端模型或存储。新增运行例子为并行处理→汇合→定时等待→模拟外部回执→后继任务,验证恰好三个任务和有界排空。 + +本批不新增迁移、数据表或持久化字段;仍为35个实体、305个直接持久化字段声明,无数据库外键和级联。 + +## 回归证据 + +环境为本机darwin/arm64、Go 1.27.1、Docker MySQL 8.4.10;专项及完整脚本使用一次性隔离数据库和真实Orbit二进制。 + +| 检查 | 结果 | +| --- | --- | +| 既有执行/恢复与元数据读专项 | `-race`通过,39.627秒,覆盖实例受理、TASK/POLL/等待恢复与新增当前详情读取 | +| 初始化权限与详情专项 | `-race`通过,5.892秒;六种独立流程授权及无外键归属拒绝 | +| 独立SDK专项 | 定义SDK与四组实例场景通过,包21.154秒;实例场景19.76秒 | +| 可运行示例 | 原定义发布例子与新workflow例子通过;新例子SUCCEEDED、start_deduplicated=true、signal_deduplicated=true、started=3、confirmed=3 | +| 基础回归 | 最终make fmt/test/vet/build全部通过,包含主模块、独立SDK和两个业务示例模块的竞态检查、静态检查及构建 | +| 完整隔离回归 | `scripts/test-mysql.sh`退出0;存储219.229秒、实际流程进程恢复48.336秒、流程SDK21.342秒(四组实例20.01秒)、最终Worker/订阅进程故障组95.200秒;20个SDK示例及2个独立业务模块全部通过,一次性环境由脚本清理 | +| 无外键升级 | 历史v7至当前版本回归通过,4.58秒;70个外键全部移除,已有数据、未替换索引及唯一约束保留,运行时拒绝意外新增外键 | +| 模型、SQL及文档 | Go AST核对305个直接持久化字段,缺中文注释0;实际MySQL已应用至v22,外键0、业务表字段缺COMMENT为0;991个本地文档链接及61个JSON示例检查通过 | + +四组真实HTTP/SDK场景: + +1. 服务端实际提交启动事务后代理丢弃响应,原键找回同一实例和原截止;暂停/原键恢复/旧控制版本拒绝/继续,Worker完成首任务后经过TIMER与SIGNAL,再执行后继;信号旧回执与当前消费结果分别查询。审计分页及跨实例游标拒绝、取消等待实例也一并覆盖。 +2. Worker报告UNKNOWN → ReviewWorkflowTask确认成功 → 后台消费新结果 → ResolveWorkflowStep确认逻辑成功 → ContinueWorkflow;旧UNKNOWN尝试保留,管理原因和证据可读取。 +3. 已知失败步骤与并行信号等待 → RearmWorkflowWait第二代,原截止不变 → 旧代次信号被拒绝 → RetryWorkflowStep只保留一次剩余尝试 → 解除核查 → 新任务和新代次信号完成流程。 +4. 查询UNKNOWN → 核查为SUCCEEDED/PENDING → 原结果消费 → ContinueWorkflowPoll → ContinueWorkflow → 只创建第二业务轮并最终DONE;查询总截止不变。 + +实现和证据:[公共路由](../../internal/transport/httpserver/workflows.go)、[严格JSON测试](../../internal/transport/httpserver/workflows_test.go)、[初始化范围测试](../../internal/storage/mysql/workflow_bootstrap_integration_test.go)、[当前详情及审计范围测试](../../internal/storage/mysql/workflow_details_integration_test.go)、[独立SDK场景](../../sdk/go/workflow_instances_integration_test.go)、[示例源码](../../sdk/go/examples/workflow/main.go)。完整复现入口是[隔离MySQL脚本](../../scripts/test-mysql.sh),跳过的环境依赖测试不算通过。 + +## 剩余交付 + +继续提供实例筛选分页、完整激活/轮次/信号历史、信号资源审计、Pulse任务流程来源和更多独立恢复示例,再逐项核对P4/P5原功能范围。P6/P7仍包括生产监控、凭据轮换、故障及容量工具、备份恢复/切换与发布验收。本机多进程和单数据库回归不能替代生产复制、跨故障域部署或RPO/RTO验证。 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index b6622a6..fbc6796 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -1,6 +1,6 @@ # Go SDK 接入说明 -已实现健康查询、任务单项与[批量提交](../api/batch-tasks.md)/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[人工核查与重试](../api/task-recovery.md) 已提供 ReviewTask、RetryTask 与任务操作审计查询。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。[订阅管理](../api/subscriptions.md) 已支持创建、查询、暂停恢复、交付历史与人工重试;[事件消费 SDK](event-consumer.md) 已支持连接、自动心跳、重连与确认。流程构建等后续接口按 [开发计划](../08-development-plan.md) 继续实现。 +已实现健康查询、任务单项与[批量提交](../api/batch-tasks.md)/查询/分页/待执行取消、队列创建/查询、[自动 Worker](worker-runtime.md)、[Worker 底层协议 SDK](worker.md) 和 [执行历史查询](../api/execution-history.md)。[人工核查与重试](../api/task-recovery.md) 已提供 ReviewTask、RetryTask 与任务操作审计查询。[安全重试](../api/retries.md) 已提供有限策略、WorkerFailure.Retryable、历史查询及完整例子。[计划调度](../api/schedules.md) 已支持创建、查询、版本发布、暂停/恢复、触发历史与完整示例。[订阅管理](../api/subscriptions.md) 已支持创建、查询、暂停恢复、交付历史与人工重试;[事件消费 SDK](event-consumer.md) 已支持连接、自动心跳、重连与确认。[流程定义发布](../api/workflow-definitions.md)、[实例/控制/信号/操作审计](../api/workflows.md)及[六类人工恢复](../api/workflow-recovery.md)已提供独立SDK;完整历史与更多场景按[开发计划](../08-development-plan.md)继续实现。 module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 Go 标准库与 `github.com/coder/websocket v1.8.15`,不引用服务端模型或数据库实现。 @@ -201,4 +201,4 @@ P2 已提供底层协议、[自动 Worker 运行器及示例](worker-runtime.md) ## 流程定义发布 -已提供独立WorkflowSpecification类型及CreateWorkflowDefinition、GetWorkflowDefinition、PublishWorkflowVersion、GetWorkflowVersion四个HTTP方法。业务侧研发显式发布,服务端冻结版本;当前不包含实例执行方法。见[接口与字段](../api/workflow-definitions.md)、[结构版本1](../api/workflow-definition-format.md)、[完整可运行场景](../examples/workflow-definition.md)。 +已提供独立WorkflowSpecification类型及CreateWorkflowDefinition、GetWorkflowDefinition、PublishWorkflowVersion、GetWorkflowVersion四个HTTP方法。业务侧研发显式发布,服务端冻结版本;实例执行使用[StartWorkflow等方法](../api/workflows.md),完整运行见[示例](../examples/workflow.md)。见[接口与字段](../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 39d9098..544deaa 100644 --- a/internal/storage/mysql/bootstrap.go +++ b/internal/storage/mysql/bootstrap.go @@ -14,17 +14,22 @@ import ( // BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。 type BootstrapInput struct { - WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。 - RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。 - EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。 - Application string // Application 新应用名称,与 ExistingApplicationID 二选一。 - Namespace string // Namespace 命名空间名称。 - Environment string // Environment 环境名称,必须显式指定。 - Queue string // Queue 初始队列名称。 - WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。 - ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。 - ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。 - ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。 + WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。 + WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。 + WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。 + WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。 + WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。 + WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。 + RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。 + EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。 + Application string // Application 新应用名称,与 ExistingApplicationID 二选一。 + Namespace string // Namespace 命名空间名称。 + Environment string // Environment 环境名称,必须显式指定。 + Queue string // Queue 初始队列名称。 + WorkerAccess bool // WorkerAccess 显式增加开发用 Worker 接入及消费权限。 + ScheduleAccess bool // ScheduleAccess 显式增加开发用计划查询及管理权限。 + ResourceAccess bool // ResourceAccess 显式授予整个应用的额度查询及管理权限。 + ExistingApplicationID core.ID // ExistingApplicationID 在指定现有应用下创建命名空间,与 Application 二选一。 } // BootstrapResult 包含一次性交付的原始凭据,禁止记录到日志或提交到版本库。 @@ -80,6 +85,24 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( if in.WorkflowAccess { permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish") } + if in.WorkflowAuditAccess { + permissions.Actions = append(permissions.Actions, "workflows:audit:read") + } + if in.WorkflowRunAccess || in.WorkflowControlAccess || in.WorkflowSignalAccess || in.WorkflowRecoveryAccess { + permissions.Actions = append(permissions.Actions, "workflows:read") + } + if in.WorkflowRunAccess { + permissions.Actions = append(permissions.Actions, "workflows:start") + } + if in.WorkflowControlAccess { + permissions.Actions = append(permissions.Actions, "workflows:control") + } + if in.WorkflowSignalAccess { + permissions.Actions = append(permissions.Actions, "workflows:signal") + } + if in.WorkflowRecoveryAccess { + permissions.Actions = append(permissions.Actions, "workflows:recover") + } if in.ScheduleAccess { permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage") } @@ -92,6 +115,9 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func( if in.RecoveryAccess { permissions.Actions = append(permissions.Actions, "tasks:review", "tasks:retry", "tasks:audit:read") } + if err := permissions.Validate(); err != nil { + return err + } raw, err := json.Marshal(permissions) if err != nil { return err diff --git a/internal/storage/mysql/workflow_apply.go b/internal/storage/mysql/workflow_apply.go index 0e79a4a..32b11e7 100644 --- a/internal/storage/mysql/workflow_apply.go +++ b/internal/storage/mysql/workflow_apply.go @@ -214,6 +214,7 @@ func activateWorkflowStep(ctx context.Context, tx *sql.Tx, g *workflowGraph, row return err } row.view.ActivationID = &id + row.view.ActivationSequence = 1 row.view.TaskID = direct deadline := g.view.DeadlineAt if c.Kind == "POLL" { diff --git a/internal/storage/mysql/workflow_bootstrap_integration_test.go b/internal/storage/mysql/workflow_bootstrap_integration_test.go new file mode 100644 index 0000000..cceff4a --- /dev/null +++ b/internal/storage/mysql/workflow_bootstrap_integration_test.go @@ -0,0 +1,68 @@ +package mysql + +import ( + "context" + "encoding/json" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "os" + "testing" + "time" +) + +func TestIntegrationWorkflowBootstrapScopes(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 8, 2) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + in BootstrapInput + actions []string + }{ + {"definitions", BootstrapInput{WorkflowAccess: true}, []string{"workflows:definitions:read", "workflows:definitions:publish"}}, + {"run", BootstrapInput{WorkflowRunAccess: true}, []string{"workflows:start", "workflows:read"}}, + {"control", BootstrapInput{WorkflowControlAccess: true}, []string{"workflows:control", "workflows:read"}}, + {"signal", BootstrapInput{WorkflowSignalAccess: true}, []string{"workflows:signal", "workflows:read"}}, + {"recover", BootstrapInput{WorkflowRecoveryAccess: true}, []string{"workflows:recover", "workflows:read"}}, + {"audit", BootstrapInput{WorkflowAuditAccess: true}, []string{"workflows:audit:read"}}, + } { + t.Run(tc.name, func(t *testing.T) { + in := tc.in + in.Application = "scopes-" + string(executionID(t)) + in.Namespace = "default" + in.Environment = "test" + in.Queue = "work" + var out BootstrapResult + if err := Bootstrap(ctx, db, in, func(v BootstrapResult) error { out = v; return nil }); err != nil { + t.Fatal(err) + } + var raw []byte + if err := db.QueryRowContext(ctx, `SELECT permissions FROM orbit_credentials WHERE namespace_id=? AND id=?`, out.NamespaceID, out.CredentialID).Scan(&raw); err != nil { + t.Fatal(err) + } + var p auth.Permissions + if json.Unmarshal(raw, &p) != nil || p.Validate() != nil { + t.Fatal("bootstrap issued unusable scope") + } + expected := map[string]bool{} + for _, v := range tc.actions { + expected[v] = true + } + for _, v := range []string{"workflows:definitions:read", "workflows:definitions:publish", "workflows:start", "workflows:read", "workflows:control", "workflows:signal", "workflows:recover", "workflows:audit:read"} { + if p.Allows(v) != expected[v] { + t.Fatal("scope widened or missing", v) + } + } + }) + } +} diff --git a/internal/storage/mysql/workflow_details.go b/internal/storage/mysql/workflow_details.go new file mode 100644 index 0000000..7932ccb --- /dev/null +++ b/internal/storage/mysql/workflow_details.go @@ -0,0 +1,79 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + + "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/weave/engine" + "git.yuntuo.hk/k2safe/orbit/weave/instance" +) + +func (s *WorkflowInstanceStore) Step(ctx context.Context, t auth.Token, id core.ID, key string) (instance.StepDetail, error) { + out := instance.StepDetail{InstanceID: id} + err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + v, gate, version, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, id, p) + if err != nil { + return err + } + out.Revision = v.Revision + steps, err := readWorkflowStepViews(ctx, tx, t.NamespaceID, id, gate, version, key) + if err != nil { + return err + } + if len(steps) != 1 { + return fault.NotFound + } + out.Step = steps[0] + a := out.Step.ActivationID + if a == nil { + return nil + } + var input, output []byte + if err = tx.QueryRowContext(ctx, `SELECT input,output,created_at,completed_at FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? AND id=?`, t.NamespaceID, id, out.Step.ID, a).Scan(&input, &output, &out.ActivatedAt, &out.CompletedAt); err != nil { + return err + } + out.Input, out.Output = input, output + program, err := engine.Compile(version.Specification) + if err != nil { + return err + } + node, _ := program.Step(key) + if node.Poll != nil { + q := &instance.PollView{} + var owned, queue *core.ID + err = tx.QueryRowContext(ctx, "SELECT p.used_rounds,p.first_round,p.first_round_attempts_used,p.max_rounds,p.max_attempts_per_round,p.interval_ms,p.deadline_at,p.next_poll_at,p.current_task_id,r.task_id,p.resolved_at,t.id,t.queue_id FROM weave_poll_states p JOIN weave_poll_rounds r ON r.namespace_id=p.namespace_id AND r.workflow_id=p.workflow_id AND r.activation_id=p.activation_id AND r.`round`=p.used_rounds LEFT JOIN pulse_tasks t ON t.namespace_id=r.namespace_id AND t.id=r.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=r.activation_id AND t.gate_id=? WHERE p.namespace_id=? AND p.workflow_id=? AND p.activation_id=?", gate, t.NamespaceID, id, a).Scan(&q.UsedRounds, &q.FirstRound, &q.FirstRoundAttemptsUsed, &q.MaxRounds, &q.MaxAttemptsPerRound, &q.IntervalMS, &q.DeadlineAt, &q.NextPollAt, &q.CurrentTaskID, &q.LastTaskID, &q.ResolvedAt, &owned, &queue) + if err != nil { + return err + } + if owned == nil || *owned != q.LastTaskID || queue == nil || *queue != node.Poll.Task.QueueID || q.CurrentTaskID != nil && *q.CurrentTaskID != q.LastTaskID || q.MaxRounds != node.Poll.MaxRounds || q.MaxAttemptsPerRound != node.Poll.Task.Retry.MaxAttempts || q.IntervalMS != node.Poll.IntervalMS { + return errors.New("workflow poll detail ownership or policy invalid") + } + out.Poll = q + } + if node.Signal != nil || node.Timer != nil || node.Poll != nil && node.Poll.FinalSignal != nil { + w := &instance.WaitView{} + if err = tx.QueryRowContext(ctx, `SELECT id,kind,state,signal_name,payload_version,correlation_key,deadline_at,winner_signal_id,resolved_at FROM weave_waits WHERE namespace_id=? AND workflow_id=? AND activation_id=?`, t.NamespaceID, id, a).Scan(&w.ID, &w.Kind, &w.State, &w.Name, &w.PayloadVersion, &w.CorrelationKey, &w.DeadlineAt, &w.WinnerSignalID, &w.ResolvedAt); err != nil { + return err + } + if w.Kind != string(out.Step.Kind) { + return errors.New("workflow wait kind mismatch") + } + if w.WinnerSignalID != nil { + var n int + if err = tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=? AND consumed_wait_id=? AND target_step_key=? AND target_activation_sequence=? AND state='CONSUMED'`, t.NamespaceID, id, w.WinnerSignalID, w.ID, key, out.Step.ActivationSequence).Scan(&n); err != nil { + return err + } + if n != 1 { + return errors.New("workflow wait winner association invalid") + } + } + out.Wait = w + } + return nil + }) + return out, err +} diff --git a/internal/storage/mysql/workflow_details_integration_test.go b/internal/storage/mysql/workflow_details_integration_test.go new file mode 100644 index 0000000..e77abb5 --- /dev/null +++ b/internal/storage/mysql/workflow_details_integration_test.go @@ -0,0 +1,102 @@ +package mysql + +import ( + "context" + "os" + "reflect" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/pulse/service" + "git.yuntuo.hk/k2safe/orbit/weave/instance" +) + +func TestIntegrationWorkflowPublicDetails(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + db, err := Open(ctx, dsn, 16, 4) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + f := newExecutionFixture(t, ctx, db) + grantWorkflowInstance(t, f) + api := instance.New(NewWorkflowInstanceStore(db)) + input := publishInstanceSpec(t, f, allInitialNodes(f.resources.QueueID)) + started, err := api.Start(ctx, f.token, "public-details", input) + if err != nil { + t.Fatal(err) + } + id := started.Instance.ID + + if _, err = api.ListOperations(ctx, f.token, id, service.HistoryPageInput{}); fault.Code(err) != "PERMISSION_DENIED" { + t.Fatal("ordinary workflow read gained audit", err) + } + f.exec(`UPDATE orbit_credentials SET permissions=JSON_ARRAY_APPEND(permissions,'$.actions','workflows:audit:read') WHERE namespace_id=? AND id=?`, f.resources.NamespaceID, f.resources.CredentialID) + history, err := api.ListOperations(ctx, f.token, id, service.HistoryPageInput{Limit: 1}) + if err != nil || len(history.Items) != 1 { + t.Fatal("workflow audit", err) + } + audit, err := api.GetOperation(ctx, f.token, id, history.Items[0].ID) + if err != nil || audit.WorkflowID != id || audit.Action != "workflow.start" || audit.ActorID != string(f.resources.CredentialID) { + t.Fatal("audit scope", err) + } + list, err := api.Steps(ctx, f.token, id) + if err != nil { + t.Fatal(err) + } + for _, meta := range list.Steps { + detail, err := api.Step(ctx, f.token, id, meta.Key) + if err != nil { + t.Fatal(meta.Key, err) + } + if detail.Revision != list.Revision || !reflect.DeepEqual(detail.Step, meta) { + t.Fatal("inconsistent snapshot", meta.Key) + } + if meta.ActivationID != nil && (meta.ActivationSequence != 1 || detail.ActivatedAt == nil || len(detail.Input) == 0) { + t.Fatal("activation not described", meta.Key) + } + if meta.ActivationID == nil && (meta.ActivationSequence != 0 || len(detail.Input) != 0) { + t.Fatal("inactive step fabricated activation") + } + if meta.Kind == "POLL" && (detail.Poll == nil || detail.Poll.LastTaskID == "" || detail.Poll.CurrentTaskID == nil || *detail.Poll.CurrentTaskID != detail.Poll.LastTaskID || detail.Wait == nil) { + t.Fatal("poll source missing") + } + } + if _, err = api.Step(ctx, f.token, id, "missing"); fault.Code(err) != "NOT_FOUND" { + t.Fatal("missing step", err) + } + if _, err = api.Step(ctx, f.token, id, "$root"); fault.Code(err) != "INVALID_ARGUMENT" { + t.Fatal("internal root exposed", err) + } + poll, err := api.Step(ctx, f.token, id, "query") + if err != nil { + t.Fatal(err) + } + f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, poll.Poll.LastTaskID) + if _, err = api.Step(ctx, f.token, id, "query"); err == nil { + t.Fatal("orphan poll task accepted without foreign key") + } + f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, poll.Step.ActivationID, f.resources.NamespaceID, poll.Poll.LastTaskID) + grantWorkflowInstance(t, f, string(executionID(t))) + if _, err = api.Step(ctx, f.token, id, "query"); fault.Code(err) != "NOT_FOUND" { + t.Fatal("queue restriction bypassed", err) + } + other := newExecutionFixture(t, ctx, db) + grantWorkflowInstance(t, other) + other.exec(`UPDATE orbit_credentials SET permissions=JSON_ARRAY_APPEND(permissions,'$.actions','workflows:audit:read') WHERE namespace_id=? AND id=?`, other.resources.NamespaceID, other.resources.CredentialID) + if _, err = api.GetOperation(ctx, other.token, id, audit.ID); fault.Code(err) != "NOT_FOUND" { + t.Fatal("cross-namespace audit", err) + } + if _, err = api.Step(ctx, other.token, id, "query"); fault.Code(err) != "NOT_FOUND" { + t.Fatal("namespace isolation", err) + } +} diff --git a/internal/storage/mysql/workflow_instances.go b/internal/storage/mysql/workflow_instances.go index 941a0ec..1ab912b 100644 --- a/internal/storage/mysql/workflow_instances.go +++ b/internal/storage/mysql/workflow_instances.go @@ -17,7 +17,7 @@ import ( ) // WorkflowInstanceStore 以应用额度锁串行受理,在一个事务内建立全部归属及初始工作。 -// 当前为内部存储适配层,公共HTTP和SDK在消费运行器接通后开放。 +// 公共HTTP与内部运行器复用同一套固定版本、幂等与事务规则。 type WorkflowInstanceStore struct{ *TaskStore } func NewWorkflowInstanceStore(db *sql.DB) *WorkflowInstanceStore { @@ -226,67 +226,77 @@ func (s *WorkflowInstanceStore) Get(ctx context.Context, t auth.Token, id core.I }) return out, err } + +// readWorkflowForQuery 固定定义授权全部队列后再锁实例,保持与运行器相同的父子锁顺序。 +func readWorkflowForQuery(ctx context.Context, tx *sql.Tx, ns, id core.ID, p auth.Permissions) (instance.View, core.ID, definition.VersionView, error) { + v, gate, err := readWorkflowInstance(ctx, tx, ns, id, false) + var version definition.VersionView + if err != nil { + return v, gate, version, err + } + version, err = instanceVersion(ctx, tx, v) + if err != nil { + return v, gate, version, err + } + if err = authorizeWorkflowQueues(ctx, tx, ns, p, version.Analysis.Queues); err != nil { + return v, gate, version, err + } + v, gate, err = readWorkflowInstance(ctx, tx, ns, id, true) + return v, gate, version, err +} func (s *WorkflowInstanceStore) Steps(ctx context.Context, t auth.Token, id core.ID) (instance.StepsView, error) { out := instance.StepsView{InstanceID: id, Steps: []instance.StepView{}} err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { - // 先核对队列,后锁实例;未来运行器遵守应用→队列→实例,读取不得逆序锁队列。 - v, _, err := readWorkflowInstance(ctx, tx, t.NamespaceID, id, false) - if err != nil { - return err - } - version, err := instanceVersion(ctx, tx, v) - if err != nil { - return err - } - if err := authorizeWorkflowQueues(ctx, tx, t.NamespaceID, p, version.Analysis.Queues); err != nil { - return err - } - var gate core.ID - v, gate, err = readWorkflowInstance(ctx, tx, t.NamespaceID, id, true) + v, gate, version, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, id, p) if err != nil { return err } out.Revision = v.Revision - program, err := engine.Compile(version.Specification) - if err != nil { - return err - } - rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,a.id,a.state,task.id,task.queue_id FROM weave_steps s LEFT JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.id=s.current_activation_id AND a.workflow_id=s.workflow_id AND a.step_id=s.id LEFT JOIN pulse_tasks task ON task.namespace_id=s.namespace_id AND task.id=a.task_id AND task.owner_kind='workflow_activation' AND task.owner_id=a.id AND task.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? AND s.`key`<>? ORDER BY s.`key` LIMIT 129", gate, t.NamespaceID, id, workflowRootKey) - if err != nil { - return err - } - defer rows.Close() - for rows.Next() { - var step instance.StepView - var activation *core.ID - var task, queue *core.ID - var state sql.NullString - if err := rows.Scan(&step.ID, &step.Key, &step.Kind, &step.State, &step.Revision, &step.ActivationID, &step.TaskID, &activation, &state, &task, &queue); err != nil { - return err - } - node, ok := program.Step(step.Key) - if !ok || node.Kind != string(step.Kind) { - return errors.New("workflow step definition mismatch") - } - inactive := step.State == "BLOCKED" || step.State == "SKIPPED" - if inactive && step.ActivationID != nil || !inactive && (step.ActivationID == nil || activation == nil || *activation != *step.ActivationID || state.String != string(step.State)) { - return errors.New("workflow current activation mismatch") - } - if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) { - return errors.New("workflow direct task ownership mismatch") - } - if step.TaskID != nil && (task == nil || queue == nil || *task != *step.TaskID || node.Task == nil || *queue != node.Task.QueueID) { - return errors.New("workflow task association invalid") - } - out.Steps = append(out.Steps, step) - } - if err := rows.Err(); err != nil { - return err - } - if len(out.Steps) != int(version.Analysis.Steps) { - return errors.New("workflow step set incomplete") - } - return nil + out.Steps, err = readWorkflowStepViews(ctx, tx, t.NamespaceID, id, gate, version, "") + return err }) return out, err } +func readWorkflowStepViews(ctx context.Context, tx *sql.Tx, ns, id, gate core.ID, version definition.VersionView, key string) ([]instance.StepView, error) { + items := []instance.StepView{} + program, err := engine.Compile(version.Specification) + if err != nil { + return nil, err + } + rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,COALESCE(a.sequence,0),a.id,a.state,task.id,task.queue_id FROM weave_steps s LEFT JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.id=s.current_activation_id AND a.workflow_id=s.workflow_id AND a.step_id=s.id LEFT JOIN pulse_tasks task ON task.namespace_id=s.namespace_id AND task.id=a.task_id AND task.owner_kind='workflow_activation' AND task.owner_id=a.id AND task.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? AND s.`key`<>? AND (?='' OR s.`key`=?) ORDER BY s.`key` LIMIT 129", gate, ns, id, workflowRootKey, key, key) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var step instance.StepView + var activation *core.ID + var task, queue *core.ID + var state sql.NullString + if err := rows.Scan(&step.ID, &step.Key, &step.Kind, &step.State, &step.Revision, &step.ActivationID, &step.TaskID, &step.ActivationSequence, &activation, &state, &task, &queue); err != nil { + return nil, err + } + node, ok := program.Step(step.Key) + if !ok || node.Kind != string(step.Kind) { + return nil, errors.New("workflow step definition mismatch") + } + inactive := step.State == "BLOCKED" || step.State == "SKIPPED" + if inactive && step.ActivationID != nil || !inactive && (step.ActivationSequence == 0 || step.ActivationID == nil || activation == nil || *activation != *step.ActivationID || state.String != string(step.State)) { + return nil, errors.New("workflow current activation mismatch") + } + if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) { + return nil, errors.New("workflow direct task ownership mismatch") + } + if step.TaskID != nil && (task == nil || queue == nil || *task != *step.TaskID || node.Task == nil || *queue != node.Task.QueueID) { + return nil, errors.New("workflow task association invalid") + } + items = append(items, step) + } + if err := rows.Err(); err != nil { + return nil, err + } + if key == "" && len(items) != int(version.Analysis.Steps) { + return nil, errors.New("workflow step set incomplete") + } + return items, nil +} diff --git a/internal/storage/mysql/workflow_operations.go b/internal/storage/mysql/workflow_operations.go new file mode 100644 index 0000000..686979e --- /dev/null +++ b/internal/storage/mysql/workflow_operations.go @@ -0,0 +1,80 @@ +package mysql + +import ( + "context" + "database/sql" + "errors" + + "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" + "git.yuntuo.hk/k2safe/orbit/weave/instance" +) + +const workflowOperationColumns = `id,resource_id,operation_id,actor_id,action,created_at` + +func scanWorkflowOperation(row scanner) (instance.OperationSummary, error) { + var v instance.OperationSummary + err := row.Scan(&v.ID, &v.WorkflowID, &v.OperationID, &v.ActorID, &v.Action, &v.CreatedAt) + return v, err +} +func (s *WorkflowInstanceStore) ListOperations(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (instance.OperationPage, error) { + out := instance.OperationPage{Items: []instance.OperationSummary{}} + err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error { + if _, _, _, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, id, p); err != nil { + return err + } + query := `SELECT ` + workflowOperationColumns + ` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow' AND resource_id=?` + args := []any{t.NamespaceID, id} + if in.Cursor != "" { + c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, "", "workflow-operations") + if err != nil { + return err + } + query += ` AND (created_at in.Limit { + out.Items = out.Items[:in.Limit] + last := out.Items[len(out.Items)-1] + out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-operations", Namespace: t.NamespaceID, Task: id, ID: last.ID, Created: &last.CreatedAt}) + } + return err + }) + return out, err +} + +func (s *WorkflowInstanceStore) GetOperation(ctx context.Context, t auth.Token, id, audit core.ID) (instance.OperationView, error) { + var out instance.OperationView + err := s.transaction(ctx, t, instance.AuditAction, func(tx *sql.Tx, p auth.Permissions) error { + if _, _, _, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, id, p); err != nil { + return err + } + var before, after []byte + err := tx.QueryRowContext(ctx, `SELECT `+workflowOperationColumns+",reason,COALESCE(evidence_ref,''),`before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow' AND resource_id=? AND id=?", t.NamespaceID, id, audit).Scan(&out.ID, &out.WorkflowID, &out.OperationID, &out.ActorID, &out.Action, &out.CreatedAt, &out.Reason, &out.EvidenceRef, &before, &after) + if errors.Is(err, sql.ErrNoRows) { + return fault.NotFound + } + out.Before, out.After = before, after + return err + }) + return out, err +} diff --git a/internal/storage/mysql/workflow_poll_retry.go b/internal/storage/mysql/workflow_poll_retry.go index 95d229b..0fb6e75 100644 --- a/internal/storage/mysql/workflow_poll_retry.go +++ b/internal/storage/mysql/workflow_poll_retry.go @@ -128,6 +128,7 @@ func retryWorkflowPoll(ctx context.Context, tx *sql.Tx, g *workflowGraph, row *w return nil, errors.New("poll cannot have direct task reference") } row.view.State, row.view.ActivationID = weave.StepActive, &activation + row.view.ActivationSequence = origin.sequence + 1 row.view.Revision++ row.output = nil if err := saveWorkflowClock(ctx, tx, g, row, "POLL", row.view.Revision, p.DeadlineAt); err != nil { diff --git a/internal/storage/mysql/workflow_runtime_graph.go b/internal/storage/mysql/workflow_runtime_graph.go index 269f256..3122f2c 100644 --- a/internal/storage/mysql/workflow_runtime_graph.go +++ b/internal/storage/mysql/workflow_runtime_graph.go @@ -54,7 +54,7 @@ func lockWorkflowGraph(ctx context.Context, tx *sql.Tx, ns, id core.ID, skip boo return nil, false, err } g := &workflowGraph{view: v, gate: gate, version: version, program: program, steps: map[string]*workflowStep{}} - rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,a.id,a.state,a.input,a.output,t.id,t.queue_id FROM weave_steps s LEFT JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.id=s.current_activation_id AND a.workflow_id=s.workflow_id AND a.step_id=s.id LEFT JOIN pulse_tasks t ON t.namespace_id=s.namespace_id AND t.id=a.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? ORDER BY s.`key` LIMIT 130", gate, ns, id) + rows, err := tx.QueryContext(ctx, "SELECT s.id,s.`key`,s.kind,s.state,s.revision,s.current_activation_id,a.task_id,COALESCE(a.sequence,0),a.id,a.state,a.input,a.output,t.id,t.queue_id FROM weave_steps s LEFT JOIN weave_activations a ON a.namespace_id=s.namespace_id AND a.id=s.current_activation_id AND a.workflow_id=s.workflow_id AND a.step_id=s.id LEFT JOIN pulse_tasks t ON t.namespace_id=s.namespace_id AND t.id=a.task_id AND t.owner_kind='workflow_activation' AND t.owner_id=a.id AND t.gate_id=? WHERE s.namespace_id=? AND s.workflow_id=? ORDER BY s.`key` LIMIT 130", gate, ns, id) if err != nil { return nil, false, err } @@ -64,7 +64,7 @@ func lockWorkflowGraph(ctx context.Context, tx *sql.Tx, ns, id core.ID, skip boo var activation, task, queue *core.ID var state sql.NullString var input, output []byte - if err := rows.Scan(&row.view.ID, &row.view.Key, &row.view.Kind, &row.view.State, &row.view.Revision, &row.view.ActivationID, &row.view.TaskID, &activation, &state, &input, &output, &task, &queue); err != nil { + if err := rows.Scan(&row.view.ID, &row.view.Key, &row.view.Kind, &row.view.State, &row.view.Revision, &row.view.ActivationID, &row.view.TaskID, &row.view.ActivationSequence, &activation, &state, &input, &output, &task, &queue); err != nil { return nil, false, err } row.input, row.output = input, output @@ -78,7 +78,7 @@ func lockWorkflowGraph(ctx context.Context, tx *sql.Tx, ns, id core.ID, skip boo return nil, false, errors.New("workflow step definition mismatch") } inactive := row.view.State == weave.StepBlocked || row.view.State == weave.StepSkipped - if inactive && row.view.ActivationID != nil || !inactive && (row.view.ActivationID == nil || activation == nil || *activation != *row.view.ActivationID || state.String != string(row.view.State)) { + if inactive && row.view.ActivationID != nil || !inactive && (row.view.ActivationSequence == 0 || row.view.ActivationID == nil || activation == nil || *activation != *row.view.ActivationID || state.String != string(row.view.State)) { return nil, false, errors.New("workflow current activation mismatch") } if !inactive && (row.view.Kind == weave.NodeTask) != (row.view.TaskID != nil) { diff --git a/internal/storage/mysql/workflow_step_retry.go b/internal/storage/mysql/workflow_step_retry.go index 0266a9e..1241df4 100644 --- a/internal/storage/mysql/workflow_step_retry.go +++ b/internal/storage/mysql/workflow_step_retry.go @@ -142,6 +142,7 @@ func (s *WorkflowRecoveryStore) RetryStep(ctx context.Context, t auth.Token, id return nil, err } row.view.State, row.view.ActivationID, row.view.TaskID = weave.StepActive, &activation, &taskID + row.view.ActivationSequence = sequence + 1 row.view.Revision++ row.output = nil if err := deleteWorkflowClock(ctx, tx, g, row); err != nil { diff --git a/internal/storage/mysql/workflow_wait_rearm.go b/internal/storage/mysql/workflow_wait_rearm.go index c861bb7..fa52093 100644 --- a/internal/storage/mysql/workflow_wait_rearm.go +++ b/internal/storage/mysql/workflow_wait_rearm.go @@ -109,6 +109,7 @@ func (s *WorkflowRecoveryStore) RearmWait(ctx context.Context, t auth.Token, id return nil, err } row.view.State, row.view.ActivationID = weave.StepWaiting, &activation + row.view.ActivationSequence = origin.sequence + 1 row.view.Revision++ row.output = nil due := w.deadline diff --git a/internal/transport/httpserver/tasks.go b/internal/transport/httpserver/tasks.go index c8c3fbb..a9f4b77 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, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_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, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code] if status == 0 { status = 503 } @@ -320,10 +320,23 @@ func exactFields(raw []byte, t reflect.Type) error { } 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 != "-" { + var collect func(reflect.Type) error + collect = func(typ reflect.Type) error { + for i := 0; i < typ.NumField(); i++ { + field := typ.Field(i) + name := strings.Split(field.Tag.Get("json"), ",")[0] + if field.Anonymous && name == "" && field.Type.Kind() == reflect.Struct { + if err := collect(field.Type); err != nil { + return err + } + continue + } + if name == "" || name == "-" { + continue + } + if _, exists := names[name]; exists { + return errors.New("ambiguous embedded JSON field") + } names[name] = field.Type nullable[name] = field.Tag.Get("nullable") == "true" if field.Tag.Get("required") == "true" { @@ -332,7 +345,12 @@ func exactFields(raw []byte, t reflect.Type) error { } } } + return nil } + if err := collect(t); err != nil { + return err + } + for name, value := range object { field, ok := names[name] if !ok { diff --git a/internal/transport/httpserver/workflows.go b/internal/transport/httpserver/workflows.go new file mode 100644 index 0000000..935b8d9 --- /dev/null +++ b/internal/transport/httpserver/workflows.go @@ -0,0 +1,102 @@ +package httpserver + +import ( + "context" + "net/http" + + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/weave/control" + "git.yuntuo.hk/k2safe/orbit/weave/instance" + "git.yuntuo.hk/k2safe/orbit/weave/recovery" + "git.yuntuo.hk/k2safe/orbit/weave/signal" +) + +// RegisterWorkflows 只适配协议,实际授权、并发版本、原键回执及审计由事务服务校验。 +func RegisterWorkflows(server *http.Server, instances *instance.Service, controls *control.Service, signals *signal.Service, reviews *recovery.Service) { + mux := server.Handler.(*http.ServeMux) + mux.HandleFunc("POST /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + var in instance.StartInput + if err := decodeBody(r, &in); err != nil { + return nil, 0, err + } + key, err := idempotencyKey(r) + if err != nil { + return nil, 0, err + } + out, err := instances.Start(r.Context(), t, key, in) + return out, createdStatus(out.Deduplicated), err + })) + mux.HandleFunc("GET /v1/workflows/{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 := instances.Get(r.Context(), t, core.ID(r.PathValue("id"))) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/steps", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := instances.Steps(r.Context(), t, core.ID(r.PathValue("id"))) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := instances.Step(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key")) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/operations", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + in, err := historyQuery(r, true) + if err != nil { + return nil, 0, err + } + out, err := instances.ListOperations(r.Context(), t, core.ID(r.PathValue("id")), in) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/operations/{operation}", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := instances.GetOperation(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("operation"))) + return out, 200, err + })) + mux.HandleFunc("POST /v1/workflows/{id}/control", workflowMutation(controls.Apply, func(control.Result) int { return 200 })) + mux.HandleFunc("POST /v1/workflows/{id}/signals", workflowMutation(signals.Send, func(v signal.Result) int { return createdStatus(v.Deduplicated) })) + mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}", wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + out, err := signals.Get(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal"))) + return out, 200, err + })) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/task-review", workflowMutation(reviews.ReviewTask, recoveryStatus)) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/step-resolve", workflowMutation(reviews.ResolveStep, recoveryStatus)) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/continue", workflowMutation(reviews.Continue, recoveryStatus)) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/step-retry", workflowMutation(reviews.RetryStep, recoveryStatus)) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/poll-continue", workflowMutation(reviews.ContinuePoll, recoveryStatus)) + mux.HandleFunc("POST /v1/workflows/{id}/recovery/wait-rearm", workflowMutation(reviews.RearmWait, recoveryStatus)) +} +func recoveryStatus(recovery.Result) int { return 200 } +func workflowMutation[I, O any](call func(context.Context, auth.Token, core.ID, string, I) (O, error), status func(O) int) http.HandlerFunc { + return wrap(func(r *http.Request, t auth.Token) (any, int, error) { + if _, err := historyQuery(r, false); err != nil { + return nil, 0, err + } + var in I + if err := decodeBody(r, &in); err != nil { + return nil, 0, err + } + key, err := idempotencyKey(r) + if err != nil { + return nil, 0, err + } + out, err := call(r.Context(), t, core.ID(r.PathValue("id")), key, in) + return out, status(out), err + }) +} diff --git a/internal/transport/httpserver/workflows_test.go b/internal/transport/httpserver/workflows_test.go new file mode 100644 index 0000000..95b651f --- /dev/null +++ b/internal/transport/httpserver/workflows_test.go @@ -0,0 +1,69 @@ +package httpserver + +import ( + "encoding/json" + "net/http/httptest" + "strings" + "testing" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + "git.yuntuo.hk/k2safe/orbit/weave/instance" + "git.yuntuo.hk/k2safe/orbit/weave/recovery" +) + +func TestWorkflowRecoveryEmbeddedJSON(t *testing.T) { + valid := `{"expected_revision":"9007199254740993","reason":"核查","evidence_ref":"evidence:1","step_key":"query","activation_id":"11111111111111111111111111111111","expected_step_revision":"3","pending_confirmed":true}` + for _, tc := range []struct { + name, body string + valid bool + }{ + {"flat embedded fields", valid, true}, + {"unknown parent", strings.Replace(valid, `"reason"`, `"Reason"`, 1), false}, + {"unknown step", strings.Replace(valid, `"step_key"`, `"StepKey"`, 1), false}, + {"nested decision", `{"Decision":{"expected_revision":"1"},"pending_confirmed":true}`, false}, + {"missing confirmation", strings.Replace(valid, `,"pending_confirmed":true`, "", 1), false}, + {"null confirmation", strings.Replace(valid, `"pending_confirmed":true`, `"pending_confirmed":null`, 1), false}, + {"numeric revision", strings.Replace(valid, `"9007199254740993"`, `9007199254740993`, 1), false}, + {"duplicate inherited field", strings.Replace(valid, `"reason":"核查"`, `"reason":"核查","reason":"改写"`, 1), false}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body)) + r.Header.Set("Content-Type", "application/json") + var in recovery.ContinuePollInput + err := decodeBody(r, &in) + if (err == nil) != tc.valid { + t.Fatal(err) + } + if tc.valid && (in.ExpectedRevision != 9007199254740993 || in.ExpectedStepRevision != 3 || in.StepKey != "query") { + t.Fatal("embedded fields lost") + } + }) + } + for _, raw := range []string{`{"execution_settled":true,"result":null}`, `{"execution_settled":true,"result":{"n":9007199254740993}}`} { + r := httptest.NewRequest("POST", "/", strings.NewReader(raw)) + r.Header.Set("Content-Type", "application/json") + var in recovery.ResolveStepInput + if err := decodeBody(r, &in); err != nil { + t.Fatal(err) + } + if !json.Valid(in.Result) { + t.Fatal("result lost") + } + } + r := httptest.NewRequest("POST", "/", strings.NewReader(`{"definition_id":"11111111111111111111111111111111","version":"v1","input":null}`)) + r.Header.Set("Content-Type", "application/json") + var in instance.StartInput + if err := decodeBody(r, &in); err != nil || string(in.Input) != "null" { + t.Fatal("explicit null input", err) + } +} + +func TestWorkflowLimitsAreExplicitConflicts(t *testing.T) { + for _, code := range []string{"WORKFLOW_INSTANCE_LIMIT", "WORKFLOW_SIGNAL_LIMIT"} { + w := httptest.NewRecorder() + writeError(w, "request", fault.New(code, "limit reached")) + if w.Code != 409 { + t.Fatal(code, w.Code) + } + } +} diff --git a/platform/auth/token.go b/platform/auth/token.go index 6e6ffcd..08f4d7e 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{"workflows:recover": true, "workflows:signal": true, "workflows:control": true, "workflows:start": true, "workflows:read": true, "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} +var supported = map[string]bool{"workflows:audit:read": true, "workflows:recover": true, "workflows:signal": true, "workflows:control": true, "workflows:start": true, "workflows:read": true, "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 f7a4f33..bed1f00 100755 --- a/scripts/test-mysql.sh +++ b/scripts/test-mysql.sh @@ -82,10 +82,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 .) +# 流程发布、执行、控制、信号、核查和审计权限逐项显式签发。 +ORBIT_MYSQL_DSN="$ORBIT_TEST_MYSQL_DSN" "$TASK_TEST_DIR/orbit-admin" bootstrap --application sdk-workflow --namespace default --environment test --queue workflow --workflow-access --worker-access --workflow-run-access --workflow-control-access --workflow-signal-access --workflow-recovery-access --workflow-audit-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 '^TestIntegrationWorkflow(Definition|Instances)SDK$' -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) +(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) # 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心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"' diff --git a/sdk/go/examples/workflow/main.go b/sdk/go/examples/workflow/main.go new file mode 100644 index 0000000..0ac2ed3 --- /dev/null +++ b/sdk/go/examples/workflow/main.go @@ -0,0 +1,174 @@ +// workflow 通过公共SDK运行并行处理、计时等待、外部回执与后续发布;业务处理器在Worker中执行。 +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "strings" + "time" + + orbit "git.yuntuo.hk/k2safe/orbit/sdk/go" +) + +func main() { + if err := run(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} +func run() error { + raw, err := os.ReadFile(os.Getenv("ORBIT_CREDENTIALS_FILE")) + if err != nil { + return err + } + var credentials struct { + Token string `json:"token"` + QueueID string `json:"queue_id"` + } + if json.Unmarshal(raw, &credentials) != nil || credentials.Token == "" || credentials.QueueID == "" { + return errors.New("invalid private credentials file") + } + client, err := orbit.NewClient(os.Getenv("ORBIT_URL"), orbit.Options{Token: credentials.Token}) + if err != nil { + return err + } + operation, err := orbit.NewWorkerMessageID() + if err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + handler := "artifact.normalize." + operation + typ := orbit.WorkflowType{Kind: "OBJECT", Fields: map[string]orbit.WorkflowType{"text": {Kind: "STRING"}}} + task := orbit.WorkflowTask{QueueID: credentials.QueueID, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: orbit.WorkflowRef("input"), ResultType: typ, TimeoutMS: 20000, Retry: orbit.RetryPolicy{MaxAttempts: 1, AttemptTimeoutMS: 5000}, RecoverySafety: orbit.RecoveryIdempotent} + pair := orbit.WorkflowParallel{Branches: []orbit.WorkflowParallelBranch{ + {Name: "left", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "left", Kind: "TASK", Task: &task}}, OutputType: typ, Output: orbit.WorkflowRef("left")}}, + {Name: "right", Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{{Key: "right", Kind: "TASK", Task: &task}}, OutputType: typ, Output: orbit.WorkflowRef("right")}}, + }} + publish := task + publish.Input = orbit.WorkflowRef("pair", "left") + spec := orbit.WorkflowSpecification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: typ, Body: orbit.WorkflowBlock{Steps: []orbit.WorkflowStep{ + {Key: "pair", Kind: "PARALLEL", Parallel: &pair}, + {Key: "cooldown", Kind: "TIMER", Timer: &orbit.WorkflowTimer{DelayMS: 500}}, + {Key: "receipt", Kind: "SIGNAL", Signal: &orbit.WorkflowSignal{Name: "artifact.confirmed", PayloadVersion: "v1", Correlation: orbit.WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"` + operation + `"`)}, PayloadType: orbit.WorkflowType{Kind: "BOOLEAN"}, TimeoutMS: 30000}}, + {Key: "publish", Kind: "TASK", Task: &publish}, + }, OutputType: typ, Output: orbit.WorkflowRef("publish")}} + definition, err := client.CreateWorkflowDefinition(ctx, operation, orbit.CreateWorkflowDefinitionRequest{Name: "artifact." + operation, Description: "并行结果生成与模拟外部回执"}) + if err != nil { + return err + } + if _, err = client.PublishWorkflowVersion(ctx, definition.Definition.ID, operation, orbit.PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "publish runnable workflow example"}); err != nil { + return err + } + start := orbit.StartWorkflowRequest{DefinitionID: definition.Definition.ID, Version: "v1", Input: json.RawMessage(`{"text":" hello orbit "}`), BusinessKey: operation} + accepted, err := client.StartWorkflow(ctx, operation, start) + if err != nil { + return err + } + repeated, err := client.StartWorkflow(ctx, operation, start) + if err != nil || !repeated.Deduplicated || repeated.Instance.ID != accepted.Instance.ID { + return errors.New("start identity was not preserved") + } + worker, err := orbit.NewWorker(client, orbit.WorkerOptions{QueueID: credentials.QueueID, Capacity: 2, ShutdownTimeout: 5 * time.Second, Handlers: []orbit.WorkerHandler{{Capability: orbit.WorkerCapability{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}, Handle: normalize}}}) + if err != nil { + return err + } + workerCtx, stop := context.WithCancel(ctx) + done := make(chan error, 1) + go func() { done <- worker.Run(workerCtx) }() + waited := false + defer func() { + stop() + if !waited { + <-done + } + }() + var wait orbit.WorkflowStepDetail + for { + wait, err = client.GetWorkflowStep(ctx, accepted.Instance.ID, "receipt") + if err != nil { + return err + } + if wait.Step.State == "WAITING" { + break + } + current, e := client.GetWorkflow(ctx, accepted.Instance.ID) + if e != nil { + return e + } + if current.State == "NEEDS_REVIEW" || current.State == "FAILED" { + return fmt.Errorf("workflow requires attention: %s", current.State) + } + select { + case e := <-done: + waited = true + return fmt.Errorf("Worker stopped: %v", e) + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + if wait.Wait == nil || wait.Step.ActivationSequence == 0 { + return errors.New("wait contract missing") + } + // 示例模拟外部系统的已验证回执。实际系统由独立、具备信号权限的调用者确认业务事实。 + signal := orbit.SendWorkflowSignalRequest{Name: *wait.Wait.Name, PayloadVersion: *wait.Wait.PayloadVersion, TargetStepKey: wait.Step.Key, TargetActivationSequence: wait.Step.ActivationSequence, CorrelationKey: wait.Wait.CorrelationKey, Payload: json.RawMessage(`true`), Reason: "simulated verified external receipt"} + sent, err := client.SendWorkflowSignal(ctx, accepted.Instance.ID, operation, signal) + if err != nil { + return err + } + again, err := client.SendWorkflowSignal(ctx, accepted.Instance.ID, operation, signal) + if err != nil || !again.Deduplicated || again.Signal.ID != sent.Signal.ID { + return errors.New("signal identity was not preserved") + } + for { + current, e := client.GetWorkflow(ctx, accepted.Instance.ID) + if e != nil { + return e + } + if current.State == "SUCCEEDED" { + fmt.Printf("workflow=%s state=%s result=%s start_deduplicated=%t signal_deduplicated=%t\n", current.ID, current.State, current.Output, repeated.Deduplicated, again.Deduplicated) + break + } + if current.State == "NEEDS_REVIEW" || current.State == "FAILED" || current.State == "CANCELLED" { + return fmt.Errorf("workflow requires attention: %s", current.State) + } + select { + case e := <-done: + waited = true + return fmt.Errorf("Worker stopped: %v", e) + case <-ctx.Done(): + return ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } + stop() + err = <-done + waited = true + if err != nil { + return err + } + stats := worker.Stats() + if stats.Started != 3 || stats.ReportsConfirmed != 3 || stats.Running != 0 { + return errors.New("unexpected task count or incomplete drain") + } + fmt.Printf("worker_drained=true started=%d confirmed=%d\n", stats.Started, stats.ReportsConfirmed) + return nil +} +func normalize(ctx context.Context, execution orbit.WorkerExecution) (json.RawMessage, error) { + var in struct { + Text string `json:"text"` + } + if json.Unmarshal(execution.Task.Payload, &in) != nil { + return nil, &orbit.WorkerFailure{Code: "INVALID_TEXT"} + } + if err := ctx.Err(); err != nil { + return nil, err + } + return json.Marshal(struct { + Text string `json:"text"` + }{strings.ToUpper(strings.TrimSpace(in.Text))}) +} diff --git a/sdk/go/workflow_instance_types.go b/sdk/go/workflow_instance_types.go new file mode 100644 index 0000000..b196bab --- /dev/null +++ b/sdk/go/workflow_instance_types.go @@ -0,0 +1,234 @@ +package orbit + +import ( + "encoding/json" + "time" +) + +// 以下协议类型属于独立SDK,不引用服务端模型或存储模块。 +// StartWorkflowRequest 使用显式版本;业务追踪键不能替代请求幂等键。 +type StartWorkflowRequest struct { + DefinitionID string `json:"definition_id"` // DefinitionID 同命名空间的稳定定义身份。 + Version string `json:"version"` // Version 已发布的固定版本名,不解析latest别名。 + Input json.RawMessage `json:"input"` // Input 按固定版本验证的输入,显式null与未提供不同。 + BusinessKey string `json:"business_key,omitempty"` // BusinessKey 最多191个Unicode字符,保留尾部空格。 +} + +// WorkflowInstance 是实例当前权威视图;StartResult中的View冻结在受理事务,不随重报更新。 +type WorkflowInstance struct { + ID string `json:"id"` // ID 实例身份。 + NamespaceID string `json:"namespace_id"` // NamespaceID 授权及数据隔离范围。 + DefinitionID string `json:"definition_id"` // DefinitionID 稳定定义身份。 + DefinitionVersionID string `json:"definition_version_id"` // DefinitionVersionID 固定版本记录身份。 + Version string `json:"version"` // Version 启动时固定的发布版本名。 + State string `json:"state"` // State 执行状态,受理不代表业务成功。 + Revision uint64 `json:"revision,string"` // Revision 实例并发版本。 + Paused bool `json:"paused"` // Paused 独立暂停标志,不表示在途工作已停止。 + ControlRevision uint64 `json:"control_revision,string"` // ControlRevision 控制操作并发版本。 + Input json.RawMessage `json:"input"` // Input 不变的受理输入,保留数字文本。 + Output json.RawMessage `json:"output,omitempty"` // Output 成功输出;缺失与显式null不同。 + BusinessKey string `json:"business_key"` // BusinessKey 原始追踪键。 + DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 数据库时间确定的固定总截止。 + CancelRequestedAt *time.Time `json:"cancel_requested_at,omitempty"` // CancelRequestedAt 已保存的取消意图时间。 + StopKind *string `json:"stop_kind,omitempty"` // StopKind 首次停止原因CANCEL或FAILURE;核查不会清除。 + StopRequestedAt *time.Time `json:"stop_requested_at,omitempty"` // StopRequestedAt 首次停止意图时间,后续操作不会延长。 + CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 终结时间,非终态为空。 + CreatedAt time.Time `json:"created_at"` // CreatedAt UTC受理时间。 + UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近状态更新时间。 +} +type WorkflowStartResult struct { + Instance WorkflowInstance `json:"instance"` // Instance 原受理回执的实例快照。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原键恢复,不新建工作或续期。 +} + +// WorkflowStepStatus 只列元数据,避免把最多128个大输入输出聚合为无界响应。 +type WorkflowStepStatus struct { + ActivationSequence uint32 `json:"activation_sequence,omitempty"` // ActivationSequence 当前激活代次;未激活时省略,信号必须显式指定此代次。 + ID string `json:"id"` // ID 实例内逻辑步骤身份。 + Key string `json:"key"` // Key 固定定义步骤名称。 + Kind string `json:"kind"` // Kind 固定节点类型。 + State string `json:"state"` // State 当前步骤状态。 + Revision uint64 `json:"revision,string"` // Revision 步骤并发版本。 + ActivationID *string `json:"activation_id,omitempty"` // ActivationID 当前激活,未激活时为空。 + TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL任务通过轮次查询。 +} +type WorkflowSteps struct { + InstanceID string `json:"instance_id"` // InstanceID 所属实例。 + Revision uint64 `json:"revision,string"` // Revision 与步骤读取一致的实例版本。 + Steps []WorkflowStepStatus `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。 +} + +// WorkflowStepDetail 只读取一个当前激活的输入输出,避免步骤列表聚合大载荷。 +type WorkflowStepDetail struct { + InstanceID string `json:"instance_id"` // InstanceID 授权实例。 + Revision uint64 `json:"revision,string"` // Revision 与本次步骤详情同一快照的实例版本。 + Step WorkflowStepStatus `json:"step"` // Step 当前步骤与激活身份。 + Input json.RawMessage `json:"input,omitempty"` // Input 当前激活输入,未激活时省略,显式null仍保留。 + Output json.RawMessage `json:"output,omitempty"` // Output 当前激活已有结果,尚无结果时省略。 + ActivatedAt *time.Time `json:"activated_at,omitempty"` // ActivatedAt 当前激活创建时间,不代表等待预算重新开始。 + CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 当前激活结束时间;核查或进行中可能为空。 + Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 当前激活的查询预算及最后任务,仅POLL有值。 + Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 当前信号/定时/最终信号等待,原截止不会因换代延长。 +} +type WorkflowPollStatus struct { + UsedRounds uint32 `json:"used_rounds"` // UsedRounds 已用业务轮编号,跨恢复激活累计。 + FirstRound uint32 `json:"first_round"` // FirstRound 本激活继承的首个业务轮。 + FirstRoundAttemptsUsed uint32 `json:"first_round_attempts_used"` // FirstRoundAttemptsUsed 该轮此前激活实际已用请求尝试。 + MaxRounds uint32 `json:"max_rounds"` // MaxRounds 固定业务轮上限。 + MaxAttemptsPerRound uint32 `json:"max_attempts_per_round"` // MaxAttemptsPerRound 原单轮请求尝试上限。 + IntervalMS uint64 `json:"interval_ms"` // IntervalMS 成功PENDING后的原查询间隔,毫秒。 + DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 第一次查询激活确定的绝对截止。 + NextPollAt *time.Time `json:"next_poll_at,omitempty"` // NextPollAt 下一业务轮可开始时间;非轮间等待为空。 + CurrentTaskID *string `json:"current_task_id,omitempty"` // CurrentTaskID 当前尚待收集结果的查询任务。 + LastTaskID string `json:"last_task_id"` // LastTaskID 本激活最后一轮任务,可据此读取执行详情并核查。 + ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 当前查询预算结清时间。 +} +type WorkflowWaitStatus struct { + ID string `json:"id"` // ID 当前激活等待身份。 + Kind string `json:"kind"` // Kind SIGNAL、TIMER或POLL。 + State string `json:"state"` // State 等待、满足、过期或取消状态。 + Name *string `json:"name,omitempty"` // Name 信号名称;TIMER及未核验的旧等待为空。 + PayloadVersion *string `json:"payload_version,omitempty"` // PayloadVersion 冻结信号内容契约。 + CorrelationKey string `json:"correlation_key"` // CorrelationKey 原精确关联;TIMER为空字符串。 + DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 首次等待的绝对截止。 + WinnerSignalID *string `json:"winner_signal_id,omitempty"` // WinnerSignalID 已选中信号,不表示在途执行已结束。 + ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 等待结清时间。 +} + +// Input 的稳定操作身份由方法参数传入;并发控制只使用control_revision,不与后台结果版本竞争。 +type WorkflowControlRequest struct { + Operation string `json:"operation"` // Operation 为PAUSE、RESUME或CANCEL;终结实例不接受新控制操作。 + ExpectedControlRevision uint64 `json:"expected_control_revision,string"` // ExpectedControlRevision 调用者观察到的控制版本,必须大于0。 + Reason string `json:"reason"` // Reason 必填操作原因,最多1024个Unicode字符,进入不可变审计。 +} +type WorkflowControlResult struct { + Instance WorkflowInstance `json:"instance"` // Instance 首次操作提交后的冻结回执;当前结果用实例Get读取。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作恢复,不重复修改控制版本或计时。 +} +type SendWorkflowSignalRequest struct { + Name string `json:"name"` // Name 固定定义信号名称,最多128个ASCII字符。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 固定内容契约版本,最多32个ASCII字符。 + TargetStepKey string `json:"target_step_key"` // TargetStepKey 固定定义的目标步骤,最多64个ASCII字符。 + TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 显式目标激活代次,首次及早到信号为1,不能自动跟随人工恢复。 + CorrelationKey string `json:"correlation_key"` // CorrelationKey 1至512字节精确UTF8操作关联,保留尾部空格。 + Payload json.RawMessage `json:"payload"` // Payload 固定契约内容,最多256KiB,显式null与缺失不同。 + Reason string `json:"reason"` // Reason 受理原因,1至1024个Unicode字符,进入审计。 + EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 可选受控证据引用,最多512个无空白/控制字符,不携带秘密。 +} + +type WorkflowSignalStatus struct { + ID string `json:"id"` // ID 信号持久身份。 + NamespaceID string `json:"namespace_id"` // NamespaceID 授权和数据隔离范围。 + WorkflowID string `json:"workflow_id"` // WorkflowID 固定目标实例。 + Revision uint64 `json:"revision,string"` // Revision 当前信号记录版本。 + Name string `json:"name"` // Name 固定信号类型。 + PayloadVersion string `json:"payload_version"` // PayloadVersion 固定契约版本。 + TargetStepKey string `json:"target_step_key"` // TargetStepKey 目标步骤名称。 + TargetActivationSequence uint32 `json:"target_activation_sequence"` // TargetActivationSequence 固定激活序号。 + CorrelationKey string `json:"correlation_key"` // CorrelationKey 原始操作关联。 + Payload json.RawMessage `json:"payload"` // Payload 原始精确内容。 + ActorID string `json:"actor_id"` // ActorID 平台认证主体,不接受请求自报身份。 + EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用。 + State string `json:"state"` // State PENDING/CONSUMED/EXPIRED/REJECTED。 + Disposition string `json:"disposition,omitempty"` // Disposition 稳定消费或拒绝原因。 + AcceptedAt time.Time `json:"accepted_at"` // AcceptedAt 数据库可靠受理时间,截止不含等号。 + ExpiresAt time.Time `json:"expires_at"` // ExpiresAt 固定实例总截止,不因重报延长。 + ConsumedWaitID *string `json:"consumed_wait_id,omitempty"` // ConsumedWaitID 消费它的唯一等待身份。 +} +type WorkflowSignalResult struct { + Signal WorkflowSignalStatus `json:"signal"` // Signal 首次受理的冻结视图,当前状态通过Get查询。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原身份恢复,不重报新事件或重新计时。 +} + +// WorkflowRecoveryDecision 核查基于整个实例的最新证据版本;后台新事实会使旧决定失效,原键仍可恢复回执。 +type WorkflowRecoveryDecision struct { + ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前实例版本,不能使用control_revision代替。 + Reason string `json:"reason"` // Reason 必填核查原因,最多1024个Unicode字符。 + EvidenceRef string `json:"evidence_ref"` // EvidenceRef 受控证据引用,最多512个无空白或控制字符的Unicode字符;平台不请求地址。 +} + +type ReviewWorkflowTaskRequest struct { + WorkflowRecoveryDecision + TaskID string `json:"task_id"` // TaskID 本实例任一历史激活拥有的待核查Pulse任务。 + ExpectedTaskRevision uint64 `json:"expected_task_revision,string"` // ExpectedTaskRevision 任务当前状态版本。 + ExecutionSettled bool `json:"execution_settled"` // ExecutionSettled 显式确认该任务全部历史外部执行已结束且不会继续产生效果。 + Outcome TaskState `json:"outcome"` // Outcome SUCCEEDED或FAILED,表示该次请求的核查结论,不代替POLL业务结果。 + Result json.RawMessage `json:"result"` // Result 非null的核查结果,最多256 KiB,保留精确数字。 +} + +type WorkflowStepDecision struct { + WorkflowRecoveryDecision + StepKey string `json:"step_key"` // StepKey 固定定义中的当前步骤,不接受内部根步骤。 + ActivationID string `json:"activation_id"` // ActivationID 调用者核查的当前激活,禁止旧激活作用于新工作。 + ExpectedStepRevision uint64 `json:"expected_step_revision,string"` // ExpectedStepRevision 步骤版本,与实例版本共同校验。 +} + +type ResolveWorkflowStepRequest struct { + WorkflowStepDecision + ExecutionSettled bool `json:"execution_settled"` // ExecutionSettled 确认本步骤关联外部执行与业务结果已核实。 + Outcome string `json:"outcome"` // Outcome SUCCEEDED或FAILED;成功必须符合固定节点输出契约。 + Result json.RawMessage `json:"result"` // Result 核查后的逻辑输出;TIMER成功输出为显式null,失败结论保留在审计摘要。 +} + +type ContinueWorkflowRequest struct { + WorkflowRecoveryDecision + ReviewResolved bool `json:"review_resolved"` // ReviewResolved 确认全部核查事项已处理;服务端仍检查未决步骤、历史任务与Inbox。 +} + +type RetryWorkflowStepRequest struct { + WorkflowStepDecision + SafeToRetry bool `json:"safe_to_retry"` // SafeToRetry 确认旧执行已结束,并按原业务身份再次执行安全;不能只依据未收到响应。 +} + +type ContinueWorkflowPollRequest struct { + WorkflowStepDecision + PendingConfirmed bool `json:"pending_confirmed"` // PendingConfirmed 确认权威查询结果为处理中,按原间隔与剩余业务轮次接续。 +} + +type RearmWorkflowWaitRequest struct { + WorkflowStepDecision + WaitVerified bool `json:"wait_verified"` // WaitVerified 已核验当前等待及旧回执,按原契约/截止开启新等待代次。 +} + +type WorkflowRecoveryResult struct { + Instance WorkflowInstance `json:"instance"` // Instance 操作提交时的冻结实例回执;核查步骤不自动解除实例核查。 + AuditID string `json:"audit_id"` // AuditID 本次流程管理操作的不可变审计标识。 + Step *WorkflowStepStatus `json:"step,omitempty"` // Step 被核查或重新激活步骤的回执。 + Task *Task `json:"task,omitempty"` // Task 被核查或新创建的执行任务。 + TaskAuditID *string `json:"task_audit_id,omitempty"` // TaskAuditID 独立的执行结果核查审计,和流程审计同事务。 + Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作回执恢复,不再次修改状态或预算。 +} + +type WorkflowOperationSummary struct { + // ID 不可改写的审计记录标识。 + ID string `json:"id"` + // WorkflowID 本页授权流程标识。 + WorkflowID string `json:"workflow_id"` + // OperationID 服务端管理操作身份,不是调用方原始幂等键。 + OperationID string `json:"operation_id"` + // ActorID 实际认证主体标识,不接受请求体自报。 + ActorID string `json:"actor_id"` + // Action 管理动作,例如workflow.task.review、workflow.step.retry、workflow.control、workflow.advanced。 + Action string `json:"action"` + // CreatedAt 不可变审计创建时间,UTC微秒精度。 + CreatedAt time.Time `json:"created_at"` +} + +type WorkflowOperation struct { + WorkflowOperationSummary + // Reason 实际提交的操作原因。 + Reason string `json:"reason"` + // EvidenceRef 操作证据引用;非核查类操作可能为空。 + EvidenceRef string `json:"evidence_ref,omitempty"` + // Before 操作前的受控状态摘要,不包含完整业务输入。 + Before json.RawMessage `json:"before"` + // After 操作后摘要、核查声明或关联新激活和任务,不包含凭据秘密。 + After json.RawMessage `json:"after"` +} + +type WorkflowOperationPage struct { + // Items 按创建时间及审计ID倒序排列,不返回原因与摘要正文。 + Items []WorkflowOperationSummary `json:"items"` + // NextCursor 必须沿相同流程继续使用,空表示本次已无下一页。 + NextCursor string `json:"next_cursor,omitempty"` +} diff --git a/sdk/go/workflow_instances.go b/sdk/go/workflow_instances.go new file mode 100644 index 0000000..9545764 --- /dev/null +++ b/sdk/go/workflow_instances.go @@ -0,0 +1,144 @@ +package orbit + +import ( + "context" + "errors" +) + +// StartWorkflow 固定发布版本;响应不明时保留原键及完整请求核对,不自动创建新身份。 +func (c *Client) StartWorkflow(ctx context.Context, key string, in StartWorkflowRequest) (WorkflowStartResult, error) { + var out WorkflowStartResult + if !validID(in.DefinitionID) || !workflowVersionName(in.Version) { + return out, errors.New("invalid workflow definition or version") + } + err := c.call(ctx, "POST", "/v1/workflows", key, in, &out) + if err == nil && (!validID(out.Instance.ID) || out.Instance.DefinitionID != in.DefinitionID || out.Instance.Version != in.Version || out.Instance.Revision == 0) { + return WorkflowStartResult{}, errors.New("invalid workflow receipt; reconcile original identity") + } + return out, err +} +func (c *Client) GetWorkflow(ctx context.Context, id string) (WorkflowInstance, error) { + var out WorkflowInstance + if !validID(id) { + return out, errors.New("invalid workflow id") + } + err := c.call(ctx, "GET", "/v1/workflows/"+id, "", nil, &out) + if err == nil && out.ID != id { + return WorkflowInstance{}, errors.New("workflow identity mismatch") + } + return out, err +} +func (c *Client) GetWorkflowSteps(ctx context.Context, id string) (WorkflowSteps, error) { + var out WorkflowSteps + if !validID(id) { + return out, errors.New("invalid workflow id") + } + err := c.call(ctx, "GET", "/v1/workflows/"+id+"/steps", "", nil, &out) + if err == nil && (out.InstanceID != id || out.Steps == nil || len(out.Steps) > 128) { + return WorkflowSteps{}, errors.New("invalid workflow steps response") + } + return out, err +} +func (c *Client) GetWorkflowStep(ctx context.Context, id, key string) (WorkflowStepDetail, error) { + var out WorkflowStepDetail + if !validID(id) || !workflowVersionName(key) { + return out, errors.New("invalid workflow or step identity") + } + err := c.call(ctx, "GET", "/v1/workflows/"+id+"/steps/"+key, "", nil, &out) + if err == nil && (out.InstanceID != id || out.Step.Key != key) { + return WorkflowStepDetail{}, errors.New("workflow step identity mismatch") + } + return out, err +} + +// ControlWorkflow 使用control_revision;RESUME不会解除人工核查,也不重置截止。 +func (c *Client) ControlWorkflow(ctx context.Context, id, key string, in WorkflowControlRequest) (WorkflowControlResult, error) { + var out WorkflowControlResult + if !validID(id) { + return out, errors.New("invalid workflow id") + } + err := c.call(ctx, "POST", "/v1/workflows/"+id+"/control", key, in, &out) + if err == nil && out.Instance.ID != id { + return WorkflowControlResult{}, errors.New("invalid workflow control receipt; reconcile original identity") + } + return out, err +} + +// SendWorkflowSignal 受理不代表消费;必须显式指定步骤及等待代次,原键重报不会跟随新激活。 +func (c *Client) SendWorkflowSignal(ctx context.Context, id, key string, in SendWorkflowSignalRequest) (WorkflowSignalResult, error) { + var out WorkflowSignalResult + if !validID(id) { + return out, errors.New("invalid workflow id") + } + err := c.call(ctx, "POST", "/v1/workflows/"+id+"/signals", key, in, &out) + if err == nil && (!validID(out.Signal.ID) || out.Signal.WorkflowID != id || out.Signal.TargetStepKey != in.TargetStepKey || out.Signal.TargetActivationSequence != in.TargetActivationSequence) { + return WorkflowSignalResult{}, errors.New("invalid signal receipt; reconcile original identity") + } + return out, err +} +func (c *Client) GetWorkflowSignal(ctx context.Context, id, signal string) (WorkflowSignalStatus, error) { + var out WorkflowSignalStatus + if !validID(id) || !validID(signal) { + return out, errors.New("invalid workflow signal identity") + } + err := c.call(ctx, "GET", "/v1/workflows/"+id+"/signals/"+signal, "", nil, &out) + if err == nil && (out.ID != signal || out.WorkflowID != id) { + return WorkflowSignalStatus{}, errors.New("workflow signal identity mismatch") + } + return out, err +} + +func (c *Client) workflowRecovery(ctx context.Context, id, key, operation string, in any) (WorkflowRecoveryResult, error) { + var out WorkflowRecoveryResult + if !validID(id) { + return out, errors.New("invalid workflow id") + } + err := c.call(ctx, "POST", "/v1/workflows/"+id+"/recovery/"+operation, key, in, &out) + if err == nil && (out.Instance.ID != id || !validID(out.AuditID)) { + return WorkflowRecoveryResult{}, errors.New("invalid workflow recovery receipt; reconcile original identity") + } + return out, err +} + +// ReviewWorkflowTask 仅核查执行事实;结果须经内部消费,再决定步骤结论或等待接续。 +func (c *Client) ReviewWorkflowTask(ctx context.Context, id, key string, in ReviewWorkflowTaskRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "task-review", in) +} +func (c *Client) ResolveWorkflowStep(ctx context.Context, id, key string, in ResolveWorkflowStepRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "step-resolve", in) +} + +// ContinueWorkflow 解除核查;保留原paused及停止意图。 +func (c *Client) ContinueWorkflow(ctx context.Context, id, key string, in ContinueWorkflowRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "continue", in) +} +func (c *Client) RetryWorkflowStep(ctx context.Context, id, key string, in RetryWorkflowStepRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "step-retry", in) +} +func (c *Client) ContinueWorkflowPoll(ctx context.Context, id, key string, in ContinueWorkflowPollRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "poll-continue", in) +} +func (c *Client) RearmWorkflowWait(ctx context.Context, id, key string, in RearmWorkflowWaitRequest) (WorkflowRecoveryResult, error) { + return c.workflowRecovery(ctx, id, key, "wait-rearm", in) +} + +func (c *Client) ListWorkflowOperations(ctx context.Context, id string, in HistoryPageRequest) (WorkflowOperationPage, error) { + var out WorkflowOperationPage + if !validID(id) { + return out, errors.New("invalid workflow id") + } + q, err := historyPageQuery(in) + if err != nil { + return out, err + } + err = c.call(ctx, "GET", "/v1/workflows/"+id+"/operations"+q, "", nil, &out) + return out, err +} +func (c *Client) GetWorkflowOperation(ctx context.Context, id, operation string) (WorkflowOperation, error) { + var out WorkflowOperation + if !validID(id) || !validID(operation) { + return out, errors.New("invalid workflow operation identity") + } + err := c.call(ctx, "GET", "/v1/workflows/"+id+"/operations/"+operation, "", nil, &out) + return out, err +} diff --git a/sdk/go/workflow_instances_integration_test.go b/sdk/go/workflow_instances_integration_test.go new file mode 100644 index 0000000..8c92bf5 --- /dev/null +++ b/sdk/go/workflow_instances_integration_test.go @@ -0,0 +1,403 @@ +package orbit + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "sync/atomic" + "testing" + "time" +) + +var workflowExact = json.RawMessage(`{"n":9007199254740993}`) + +func workflowSDKSpec(queue, handler string) WorkflowSpecification { + typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"n": {Kind: "NUMBER"}}} + task := WorkflowTask{QueueID: queue, Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1", InputType: typ, Input: WorkflowRef("input"), ResultType: typ, TimeoutMS: 60000, Retry: RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 10000, InitialDelayMS: 100, MaxDelayMS: 100}, RecoverySafety: RecoveryReconcileOnly} + return WorkflowSpecification{SchemaVersion: 1, InputType: typ, TimeoutMS: 120000, FailurePolicy: "REVIEW", Body: WorkflowBlock{Steps: []WorkflowStep{{Key: "work", Kind: "TASK", Task: &task}}, OutputType: typ, Output: WorkflowRef("work")}} +} +func publishWorkflowSDK(t *testing.T, ctx context.Context, api *Client, spec WorkflowSpecification) StartWorkflowRequest { + t.Helper() + key := workerTestID(t) + d, err := api.CreateWorkflowDefinition(ctx, key, CreateWorkflowDefinitionRequest{Name: "runtime." + key}) + if err != nil { + t.Fatal(err) + } + if _, err = api.PublishWorkflowVersion(ctx, d.Definition.ID, key, PublishWorkflowVersionRequest{Version: "v1", Specification: spec, Reason: "verify public workflow chain"}); err != nil { + t.Fatal(err) + } + return StartWorkflowRequest{DefinitionID: d.Definition.ID, Version: "v1", Input: workflowExact, BusinessKey: "作业关联 "} +} +func startWorkflowSDK(t *testing.T, ctx context.Context, api *Client, spec WorkflowSpecification) WorkflowInstance { + t.Helper() + out, err := api.StartWorkflow(ctx, workerTestID(t), publishWorkflowSDK(t, ctx, api, spec)) + if err != nil { + t.Fatal(err) + } + return out.Instance +} +func waitWorkflowSDK(t *testing.T, ctx context.Context, api *Client, id, state string) WorkflowInstance { + t.Helper() + for { + v, err := api.GetWorkflow(ctx, id) + if err != nil { + t.Fatal(err) + } + if v.State == state { + return v + } + select { + case <-ctx.Done(): + t.Fatalf("workflow wanted %s, got %s", state, v.State) + case <-time.After(40 * time.Millisecond): + } + } +} +func waitWorkflowStepSDK(t *testing.T, ctx context.Context, api *Client, id, key string, predicate func(WorkflowStepDetail) bool) WorkflowStepDetail { + t.Helper() + for { + v, err := api.GetWorkflowStep(ctx, id, key) + if err != nil { + t.Fatal(err) + } + if predicate(v) { + return v + } + select { + case <-ctx.Done(): + t.Fatalf("workflow step timed out: %s %s", key, v.Step.State) + case <-time.After(40 * time.Millisecond): + } + } +} +func workflowWorkerSDK(t *testing.T, ctx context.Context, api *Client, queue, handler string) *WorkerConnection { + t.Helper() + reg, err := NewWorkerRegistration(queue, 1, []WorkerCapability{{Handler: handler, HandlerVersion: "v1", PayloadVersion: "v1"}}) + if err != nil { + t.Fatal(err) + } + w, err := api.DialWorker(ctx, reg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { w.Close() }) + if _, err = w.Capacity(ctx, workerTestID(t), WorkerCapacity{Sequence: 1, Capacity: 1}); err != nil { + t.Fatal(err) + } + return w +} +func finishWorkflowSDK(t *testing.T, ctx context.Context, w *WorkerConnection, outcome WorkerOutcome, result json.RawMessage) WorkerAssignment { + t.Helper() + a, err := w.NextAssignment(ctx) + if err != nil { + t.Fatal(err) + } + if _, err = w.Start(ctx, workerTestID(t), a.Attempt()); err != nil { + t.Fatal(err) + } + code := "" + if outcome != WorkerSucceeded { + code = "EXAMPLE_EXECUTION_RESULT" + } + if _, err = w.Finish(ctx, workerTestID(t), WorkerFinish{Ref: a.Attempt(), Outcome: outcome, Result: result, ErrorCode: code}); err != nil { + t.Fatal(err) + } + return a +} +func workflowDecision(v WorkflowStepDetail) WorkflowStepDecision { + return WorkflowStepDecision{WorkflowRecoveryDecision: WorkflowRecoveryDecision{ExpectedRevision: v.Revision, Reason: "已核实模拟执行及等待", EvidenceRef: "evidence:workflow-sdk"}, StepKey: v.Step.Key, ActivationID: *v.Step.ActivationID, ExpectedStepRevision: v.Step.Revision} +} +func continueWorkflowSDK(t *testing.T, ctx context.Context, api *Client, id string) { + t.Helper() + v, err := api.GetWorkflow(ctx, id) + if err != nil { + t.Fatal(err) + } + key := workerTestID(t) + in := ContinueWorkflowRequest{WorkflowRecoveryDecision: WorkflowRecoveryDecision{ExpectedRevision: v.Revision, Reason: "核查事项已结清", EvidenceRef: "evidence:workflow-sdk"}, ReviewResolved: true} + first, err := api.ContinueWorkflow(ctx, id, key, in) + if err != nil { + t.Fatal(err) + } + again, err := api.ContinueWorkflow(ctx, id, key, in) + if err != nil || !again.Deduplicated || again.AuditID != first.AuditID { + t.Fatal("continue receipt", err) + } +} +func workflowAPIError(t *testing.T, err error, code string) { + t.Helper() + var v *APIError + if !errors.As(err, &v) || v.Code != code { + t.Fatalf("want %s got %v", code, err) + } +} + +func TestIntegrationWorkflowInstancesSDK(t *testing.T) { + api, queue, token := integrationWorkerClient(t) + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + handler := "workflow." + workerTestID(t) + t.Run("start_receipt_control_timer_signal_and_audit", func(t *testing.T) { + spec := workflowSDKSpec(queue, handler) + task := *spec.Body.Steps[0].Task + spec.Body.Steps = append(spec.Body.Steps, WorkflowStep{Key: "delay", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 500}}, WorkflowStep{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"operation "`)}, PayloadType: spec.InputType, TimeoutMS: 60000}}, WorkflowStep{Key: "tail", Kind: "TASK", Task: &task}) + spec.Body.Output = WorkflowRef("tail") + in := publishWorkflowSDK(t, ctx, api, spec) + 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, e := io.ReadAll(r.Body) + r.Body.Close() + if e != nil { + return e + } + if r.StatusCode != 201 || !json.Valid(raw) { + t.Error("start did not commit") + } + return io.ErrUnexpectedEOF + } + return nil + } + proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, e error) { + conn, _, err := w.(http.Hijacker).Hijack() + if err == nil { + conn.Close() + } + } + gateway := httptest.NewServer(proxy) + defer gateway.Close() + transport := &http.Transport{DisableKeepAlives: true} + defer transport.CloseIdleConnections() + lost, err := NewClient(gateway.URL, Options{Token: token, HTTPClient: &http.Client{Transport: transport}}) + if err != nil { + t.Fatal(err) + } + key := workerTestID(t) + if _, err = lost.StartWorkflow(ctx, key, in); err == nil || calls.Load() != 1 { + t.Fatal("start loss hidden", err) + } + started, err := api.StartWorkflow(ctx, key, in) + if err != nil || !started.Deduplicated { + t.Fatal("start receipt", err) + } + id := started.Instance.ID + before, err := api.GetWorkflowSteps(ctx, id) + if err != nil || len(before.Steps) != 4 { + t.Fatal(err) + } + pause := WorkflowControlRequest{Operation: "PAUSE", ExpectedControlRevision: started.Instance.ControlRevision, Reason: "接收事实但暂停新执行"} + pauseKey := workerTestID(t) + paused, err := api.ControlWorkflow(ctx, id, pauseKey, pause) + if err != nil || !paused.Instance.Paused { + t.Fatal(err) + } + same, err := api.ControlWorkflow(ctx, id, pauseKey, pause) + if err != nil || !same.Deduplicated { + t.Fatal(err) + } + _, err = api.ControlWorkflow(ctx, id, workerTestID(t), pause) + workflowAPIError(t, err, "STATE_CONFLICT") + if _, err = api.ControlWorkflow(ctx, id, workerTestID(t), WorkflowControlRequest{Operation: "RESUME", ExpectedControlRevision: paused.Instance.ControlRevision, Reason: "恢复执行"}); err != nil { + t.Fatal(err) + } + w := workflowWorkerSDK(t, ctx, api, queue, handler) + defer w.Close() + first := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact) + work, err := api.GetWorkflowStep(ctx, id, "work") + if err != nil || work.Step.TaskID == nil || *work.Step.TaskID != first.Task.ID || work.Step.ActivationSequence != 1 { + t.Fatal("step metadata", err) + } + wait := waitWorkflowStepSDK(t, ctx, api, id, "receipt", func(v WorkflowStepDetail) bool { return v.Step.State == "WAITING" }) + if wait.Wait == nil || wait.Wait.CorrelationKey != "operation " { + t.Fatal("wait metadata") + } + signal := SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: wait.Step.ActivationSequence, CorrelationKey: wait.Wait.CorrelationKey, Payload: workflowExact, Reason: "external receipt"} + future := signal + future.TargetActivationSequence++ + _, err = api.SendWorkflowSignal(ctx, id, workerTestID(t), future) + workflowAPIError(t, err, "STATE_CONFLICT") + signalKey := workerTestID(t) + accepted, err := api.SendWorkflowSignal(ctx, id, signalKey, signal) + if err != nil { + t.Fatal(err) + } + repeated, err := api.SendWorkflowSignal(ctx, id, signalKey, signal) + if err != nil || !repeated.Deduplicated || repeated.Signal.ID != accepted.Signal.ID { + t.Fatal("signal dedupe", err) + } + finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact) + done := waitWorkflowSDK(t, ctx, api, id, "SUCCEEDED") + if string(done.Output) != string(workflowExact) { + t.Fatal("precision lost", string(done.Output)) + } + receipt, err := api.GetWorkflowSignal(ctx, id, accepted.Signal.ID) + if err != nil || receipt.State != "CONSUMED" { + t.Fatal(err) + } + old, err := api.StartWorkflow(ctx, key, in) + if err != nil || old.Instance.Revision != started.Instance.Revision || old.Instance.DeadlineAt != started.Instance.DeadlineAt { + t.Fatal("start receipt changed", err) + } + page, err := api.ListWorkflowOperations(ctx, id, HistoryPageRequest{Limit: 1}) + if err != nil || len(page.Items) != 1 || page.NextCursor == "" { + t.Fatal("audit pagination", err) + } + audit, err := api.GetWorkflowOperation(ctx, id, page.Items[0].ID) + if err != nil || audit.WorkflowID != id { + t.Fatal(err) + } + timer := workflowSDKSpec(queue, handler) + timer.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "wait", Kind: "TIMER", Timer: &WorkflowTimer{DelayMS: 30000}}}, OutputType: WorkflowType{Kind: "NULL"}, Output: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`null`)}} + other := startWorkflowSDK(t, ctx, api, timer) + _, err = api.ListWorkflowOperations(ctx, other.ID, HistoryPageRequest{Limit: 1, Cursor: page.NextCursor}) + workflowAPIError(t, err, "INVALID_ARGUMENT") + _, err = api.ControlWorkflow(ctx, other.ID, workerTestID(t), WorkflowControlRequest{Operation: "CANCEL", ExpectedControlRevision: other.ControlRevision, Reason: "stop unused wait"}) + if err != nil { + t.Fatal(err) + } + waitWorkflowSDK(t, ctx, api, other.ID, "CANCELLED") + }) + t.Run("unknown_review_resolve_and_continue", func(t *testing.T) { + v := startWorkflowSDK(t, ctx, api, workflowSDKSpec(queue, handler)) + w := workflowWorkerSDK(t, ctx, api, queue, handler) + defer w.Close() + a := finishWorkflowSDK(t, ctx, w, WorkerUnknown, json.RawMessage(`{"unknown":true}`)) + step := waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" }) + task, err := api.GetTask(ctx, a.Task.ID) + if err != nil { + t.Fatal(err) + } + in := ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: task.ID, ExpectedTaskRevision: task.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: workflowExact} + key := workerTestID(t) + reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, key, in) + if err != nil || reviewed.Task == nil || reviewed.TaskAuditID == nil { + t.Fatal(err) + } + duplicate, err := api.ReviewWorkflowTask(ctx, v.ID, key, in) + if err != nil || !duplicate.Deduplicated || duplicate.AuditID != reviewed.AuditID { + t.Fatal(err) + } + step = waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Revision > reviewed.Instance.Revision }) + resolved, err := api.ResolveWorkflowStep(ctx, v.ID, workerTestID(t), ResolveWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), ExecutionSettled: true, Outcome: "SUCCEEDED", Result: workflowExact}) + if err != nil || resolved.Step.State != "SUCCEEDED" { + t.Fatal(err) + } + audit, err := api.GetWorkflowOperation(ctx, v.ID, resolved.AuditID) + if err != nil || audit.Action != "workflow.step.resolve" || audit.EvidenceRef != "evidence:workflow-sdk" { + t.Fatal(err) + } + continueWorkflowSDK(t, ctx, api, v.ID) + waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED") + attempt, err := api.GetTaskAttempt(ctx, task.ID, a.AttemptID) + if err != nil || attempt.State != "UNKNOWN" { + t.Fatal("old evidence rewritten", err) + } + }) + t.Run("retry_task_and_rearm_signal", func(t *testing.T) { + spec := workflowSDKSpec(queue, handler) + task := spec.Body.Steps[0] + wait := WorkflowStep{Key: "receipt", Kind: "SIGNAL", Signal: &WorkflowSignal{Name: "done", PayloadVersion: "v1", Correlation: WorkflowValue{Kind: "LITERAL", Literal: json.RawMessage(`"shared"`)}, PayloadType: spec.InputType, TimeoutMS: 60000}} + typ := WorkflowType{Kind: "OBJECT", Fields: map[string]WorkflowType{"left": spec.InputType, "right": spec.InputType}} + spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "pair", Kind: "PARALLEL", Parallel: &WorkflowParallel{Branches: []WorkflowParallelBranch{{Name: "left", Body: WorkflowBlock{Steps: []WorkflowStep{task}, OutputType: spec.InputType, Output: WorkflowRef("work")}}, {Name: "right", Body: WorkflowBlock{Steps: []WorkflowStep{wait}, OutputType: spec.InputType, Output: WorkflowRef("receipt")}}}}}}, OutputType: typ, Output: WorkflowRef("pair")} + v := startWorkflowSDK(t, ctx, api, spec) + w := workflowWorkerSDK(t, ctx, api, queue, handler) + defer w.Close() + finishWorkflowSDK(t, ctx, w, WorkerFailed, json.RawMessage(`{"known_failure":true}`)) + waitWorkflowStepSDK(t, ctx, api, v.ID, "work", func(v WorkflowStepDetail) bool { return v.Step.State == "FAILED" }) + old, err := api.GetWorkflowStep(ctx, v.ID, "receipt") + if err != nil { + t.Fatal(err) + } + key := workerTestID(t) + rearm := RearmWorkflowWaitRequest{WorkflowStepDecision: workflowDecision(old), WaitVerified: true} + restored, err := api.RearmWorkflowWait(ctx, v.ID, key, rearm) + if err != nil || restored.Step.ActivationSequence != 2 { + t.Fatal("new wait generation", err) + } + same, err := api.RearmWorkflowWait(ctx, v.ID, key, rearm) + if err != nil || !same.Deduplicated || same.AuditID != restored.AuditID { + t.Fatal(err) + } + current, err := api.GetWorkflowStep(ctx, v.ID, "receipt") + if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt { + t.Fatal("wait extended", err) + } + stale, err := api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 1, CorrelationKey: "shared", Payload: workflowExact, Reason: "late prior generation"}) + if err != nil || stale.Signal.State != "REJECTED" { + t.Fatal("old signal not fenced", err) + } + step, err := api.GetWorkflowStep(ctx, v.ID, "work") + if err != nil { + t.Fatal(err) + } + retryKey := workerTestID(t) + retry := RetryWorkflowStepRequest{WorkflowStepDecision: workflowDecision(step), SafeToRetry: true} + child, err := api.RetryWorkflowStep(ctx, v.ID, retryKey, retry) + if err != nil || child.Step.ActivationSequence != 2 || child.Task.Retry.MaxAttempts != 1 { + t.Fatal("task budget restored incorrectly", err) + } + repeated, err := api.RetryWorkflowStep(ctx, v.ID, retryKey, retry) + if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID { + t.Fatal(err) + } + continueWorkflowSDK(t, ctx, api, v.ID) + a := finishWorkflowSDK(t, ctx, w, WorkerSucceeded, workflowExact) + if a.Task.ID != child.Task.ID { + t.Fatal("wrong retry task") + } + _, err = api.SendWorkflowSignal(ctx, v.ID, workerTestID(t), SendWorkflowSignalRequest{Name: "done", PayloadVersion: "v1", TargetStepKey: "receipt", TargetActivationSequence: 2, CorrelationKey: "shared", Payload: workflowExact, Reason: "verified new wait"}) + if err != nil { + t.Fatal(err) + } + waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED") + }) + t.Run("review_pending_then_continue_original_poll", func(t *testing.T) { + spec := workflowSDKSpec(queue, handler) + task := *spec.Body.Steps[0].Task + task.ResultType = WorkflowType{Kind: "STRING", Enum: []string{"PENDING", "DONE"}} + spec.Body = WorkflowBlock{Steps: []WorkflowStep{{Key: "query", Kind: "POLL", Poll: &WorkflowPoll{Task: task, MaxRounds: 3, TimeoutMS: 60000, IntervalMS: 1000, Pending: []string{"PENDING"}, Succeeded: []string{"DONE"}}}}, OutputType: task.ResultType, Output: WorkflowRef("query")} + v := startWorkflowSDK(t, ctx, api, spec) + w := workflowWorkerSDK(t, ctx, api, queue, handler) + defer w.Close() + finishWorkflowSDK(t, ctx, w, WorkerUnknown, json.RawMessage(`"unknown"`)) + step := waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Step.State == "NEEDS_REVIEW" }) + source, err := api.GetTask(ctx, step.Poll.LastTaskID) + if err != nil { + t.Fatal(err) + } + reviewed, err := api.ReviewWorkflowTask(ctx, v.ID, workerTestID(t), ReviewWorkflowTaskRequest{WorkflowRecoveryDecision: workflowDecision(step).WorkflowRecoveryDecision, TaskID: source.ID, ExpectedTaskRevision: source.Revision, ExecutionSettled: true, Outcome: TaskSucceeded, Result: json.RawMessage(`"PENDING"`)}) + if err != nil { + t.Fatal(err) + } + step = waitWorkflowStepSDK(t, ctx, api, v.ID, "query", func(v WorkflowStepDetail) bool { return v.Revision > reviewed.Instance.Revision }) + key := workerTestID(t) + in := ContinueWorkflowPollRequest{WorkflowStepDecision: workflowDecision(step), PendingConfirmed: true} + out, err := api.ContinueWorkflowPoll(ctx, v.ID, key, in) + if err != nil || out.Step.ActivationSequence != 1 { + t.Fatal(err) + } + same, err := api.ContinueWorkflowPoll(ctx, v.ID, key, in) + if err != nil || !same.Deduplicated || same.AuditID != out.AuditID { + t.Fatal(err) + } + continueWorkflowSDK(t, ctx, api, v.ID) + finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`)) + waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED") + final, err := api.GetWorkflowStep(ctx, v.ID, "query") + if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt { + t.Fatal("poll budget changed", err) + } + }) + if !t.Failed() { + t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports") + } +} diff --git a/weave/instance/details.go b/weave/instance/details.go new file mode 100644 index 0000000..35da0fb --- /dev/null +++ b/weave/instance/details.go @@ -0,0 +1,55 @@ +package instance + +import ( + "context" + "encoding/json" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" +) + +// StepDetail 只读取一个当前激活的输入输出,避免步骤列表聚合大载荷。 +type StepDetail struct { + InstanceID core.ID `json:"instance_id"` // InstanceID 授权实例。 + Revision uint64 `json:"revision,string"` // Revision 与本次步骤详情同一快照的实例版本。 + Step StepView `json:"step"` // Step 当前步骤与激活身份。 + Input json.RawMessage `json:"input,omitempty"` // Input 当前激活输入,未激活时省略,显式null仍保留。 + Output json.RawMessage `json:"output,omitempty"` // Output 当前激活已有结果,尚无结果时省略。 + ActivatedAt *time.Time `json:"activated_at,omitempty"` // ActivatedAt 当前激活创建时间,不代表等待预算重新开始。 + CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 当前激活结束时间;核查或进行中可能为空。 + Poll *PollView `json:"poll,omitempty"` // Poll 当前激活的查询预算及最后任务,仅POLL有值。 + Wait *WaitView `json:"wait,omitempty"` // Wait 当前信号/定时/最终信号等待,原截止不会因换代延长。 +} +type PollView struct { + UsedRounds uint32 `json:"used_rounds"` // UsedRounds 已用业务轮编号,跨恢复激活累计。 + FirstRound uint32 `json:"first_round"` // FirstRound 本激活继承的首个业务轮。 + FirstRoundAttemptsUsed uint32 `json:"first_round_attempts_used"` // FirstRoundAttemptsUsed 该轮此前激活实际已用请求尝试。 + MaxRounds uint32 `json:"max_rounds"` // MaxRounds 固定业务轮上限。 + MaxAttemptsPerRound uint32 `json:"max_attempts_per_round"` // MaxAttemptsPerRound 原单轮请求尝试上限。 + IntervalMS uint64 `json:"interval_ms"` // IntervalMS 成功PENDING后的原查询间隔,毫秒。 + DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 第一次查询激活确定的绝对截止。 + NextPollAt *time.Time `json:"next_poll_at,omitempty"` // NextPollAt 下一业务轮可开始时间;非轮间等待为空。 + CurrentTaskID *core.ID `json:"current_task_id,omitempty"` // CurrentTaskID 当前尚待收集结果的查询任务。 + LastTaskID core.ID `json:"last_task_id"` // LastTaskID 本激活最后一轮任务,可据此读取执行详情并核查。 + ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 当前查询预算结清时间。 +} +type WaitView struct { + ID core.ID `json:"id"` // ID 当前激活等待身份。 + Kind string `json:"kind"` // Kind SIGNAL、TIMER或POLL。 + State string `json:"state"` // State 等待、满足、过期或取消状态。 + Name *string `json:"name,omitempty"` // Name 信号名称;TIMER及未核验的旧等待为空。 + PayloadVersion *string `json:"payload_version,omitempty"` // PayloadVersion 冻结信号内容契约。 + CorrelationKey string `json:"correlation_key"` // CorrelationKey 原精确关联;TIMER为空字符串。 + DeadlineAt time.Time `json:"deadline_at"` // DeadlineAt 首次等待的绝对截止。 + WinnerSignalID *core.ID `json:"winner_signal_id,omitempty"` // WinnerSignalID 已选中信号,不表示在途执行已结束。 + ResolvedAt *time.Time `json:"resolved_at,omitempty"` // ResolvedAt 等待结清时间。 +} + +func (s *Service) Step(ctx context.Context, t auth.Token, id core.ID, key string) (StepDetail, error) { + if id.Validate() != nil || !versionName(key) { + return StepDetail{}, fault.Invalid("valid workflow id and step key required") + } + return s.store.Step(ctx, t, id, key) +} diff --git a/weave/instance/operations.go b/weave/instance/operations.go new file mode 100644 index 0000000..cca29af --- /dev/null +++ b/weave/instance/operations.go @@ -0,0 +1,63 @@ +package instance + +import ( + "context" + "encoding/json" + "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" + "time" +) + +const AuditAction = "workflows:audit:read" + +type OperationSummary struct { + // ID 不可改写的审计记录标识。 + ID core.ID `json:"id"` + // WorkflowID 本页授权流程标识。 + WorkflowID core.ID `json:"workflow_id"` + // OperationID 服务端管理操作身份,不是调用方原始幂等键。 + OperationID core.ID `json:"operation_id"` + // ActorID 实际认证主体标识,不接受请求体自报。 + ActorID string `json:"actor_id"` + // Action 管理动作,例如workflow.task.review、workflow.step.retry、workflow.control、workflow.advanced。 + Action string `json:"action"` + // CreatedAt 不可变审计创建时间,UTC微秒精度。 + CreatedAt time.Time `json:"created_at"` +} + +type OperationView struct { + OperationSummary + // Reason 实际提交的操作原因。 + Reason string `json:"reason"` + // EvidenceRef 操作证据引用;非核查类操作可能为空。 + EvidenceRef string `json:"evidence_ref,omitempty"` + // Before 操作前的受控状态摘要,不包含完整业务输入。 + Before json.RawMessage `json:"before"` + // After 操作后摘要、核查声明或关联新激活和任务,不包含凭据秘密。 + After json.RawMessage `json:"after"` +} + +type OperationPage struct { + // Items 按创建时间及审计ID倒序排列,不返回原因与摘要正文。 + Items []OperationSummary `json:"items"` + // NextCursor 必须沿相同流程继续使用,空表示本次已无下一页。 + NextCursor string `json:"next_cursor,omitempty"` +} + +func (s *Service) ListOperations(ctx context.Context, t auth.Token, id core.ID, in service.HistoryPageInput) (OperationPage, error) { + if in.Limit == 0 { + in.Limit = 20 + } + if id.Validate() != nil || in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 { + return OperationPage{}, fault.Invalid("invalid workflow history query") + } + return s.store.ListOperations(ctx, t, id, in) +} +func (s *Service) GetOperation(ctx context.Context, t auth.Token, id, operation core.ID) (OperationView, error) { + if id.Validate() != nil || operation.Validate() != nil { + return OperationView{}, fault.Invalid("invalid workflow operation identity") + } + return s.store.GetOperation(ctx, t, id, operation) +} diff --git a/weave/instance/service.go b/weave/instance/service.go index c00e5ed..37b0b62 100644 --- a/weave/instance/service.go +++ b/weave/instance/service.go @@ -57,13 +57,14 @@ type StartResult struct { // StepView 只列元数据,避免把最多128个大输入输出聚合为无界响应。 type StepView struct { - ID core.ID `json:"id"` // ID 实例内逻辑步骤身份。 - Key string `json:"key"` // Key 固定定义步骤名称。 - Kind weave.NodeKind `json:"kind"` // Kind 固定节点类型。 - State weave.StepState `json:"state"` // State 当前步骤状态。 - Revision uint64 `json:"revision,string"` // Revision 步骤并发版本。 - ActivationID *core.ID `json:"activation_id,omitempty"` // ActivationID 当前激活,未激活时为空。 - TaskID *core.ID `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL任务通过轮次查询。 + ActivationSequence uint32 `json:"activation_sequence,omitempty"` // ActivationSequence 当前激活代次;未激活时省略,信号必须显式指定此代次。 + ID core.ID `json:"id"` // ID 实例内逻辑步骤身份。 + Key string `json:"key"` // Key 固定定义步骤名称。 + Kind weave.NodeKind `json:"kind"` // Kind 固定节点类型。 + State weave.StepState `json:"state"` // State 当前步骤状态。 + Revision uint64 `json:"revision,string"` // Revision 步骤并发版本。 + ActivationID *core.ID `json:"activation_id,omitempty"` // ActivationID 当前激活,未激活时为空。 + TaskID *core.ID `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL任务通过轮次查询。 } type StepsView struct { InstanceID core.ID `json:"instance_id"` // InstanceID 所属实例。 @@ -71,9 +72,12 @@ type StepsView struct { Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。 } type Store interface { + ListOperations(context.Context, auth.Token, core.ID, service.HistoryPageInput) (OperationPage, error) + GetOperation(context.Context, auth.Token, core.ID, core.ID) (OperationView, error) Start(context.Context, auth.Token, string, StartInput) (StartResult, error) Get(context.Context, auth.Token, core.ID) (View, error) Steps(context.Context, auth.Token, core.ID) (StepsView, error) + Step(context.Context, auth.Token, core.ID, string) (StepDetail, error) } type Service struct{ store Store } diff --git a/weave/instance/service_test.go b/weave/instance/service_test.go index 0f646f1..1bb42a9 100644 --- a/weave/instance/service_test.go +++ b/weave/instance/service_test.go @@ -8,6 +8,7 @@ import ( core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/pulse/service" ) type captureStore struct { @@ -28,6 +29,10 @@ func (s *captureStore) Steps(context.Context, auth.Token, core.ID) (StepsView, e s.calls++ return StepsView{}, nil } +func (s *captureStore) Step(context.Context, auth.Token, core.ID, string) (StepDetail, error) { + s.calls++ + return StepDetail{}, nil +} func TestAdmissionBoundaryPreservesExactInput(t *testing.T) { store := &captureStore{} api := New(store) @@ -57,3 +62,12 @@ func TestAdmissionBoundaryPreservesExactInput(t *testing.T) { t.Fatal("missing identity accepted") } } + +func (s *captureStore) ListOperations(context.Context, auth.Token, core.ID, service.HistoryPageInput) (OperationPage, error) { + s.calls++ + return OperationPage{}, nil +} +func (s *captureStore) GetOperation(context.Context, auth.Token, core.ID, core.ID) (OperationView, error) { + s.calls++ + return OperationView{}, nil +}