feat: expose workflow runtime through HTTP and Go SDK
This commit is contained in:
15
AGENTS.md
15
AGENTS.md
@@ -31,17 +31,17 @@
|
|||||||
|
|
||||||
- P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。
|
- 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隔离缺快照历史,禁止用当前任务重建旧事件。
|
- 内部结果交付使用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字节。
|
- 实例根步骤`$root`/JOIN不属于用户定义,Steps查询排除它;More对应根步骤版本化持久检查点。TASK直接关联激活task_id,POLL通过轮次关联,实际任务截止必须受实例/查询绝对截止限制。Wait名称及契约版本冻结,旧NULL等待须核实,不能盲目解释;新关联512字节、存储兼容历史764字节。
|
||||||
|
|
||||||
- 结果消费按身份→应用额度→排序队列→实例→门控→步骤/Inbox持锁,禁止先锁Inbox再锁父资源。来源使用独立冻结快照,旧激活/低版本留痕;数据库故障不归类为业务失败。消费确认、步骤、后继或根待续、事件及审计同事务。
|
- 结果消费按身份→应用额度→排序队列→实例→门控→步骤/Inbox持锁,禁止先锁Inbox再锁父资源。来源使用独立冻结快照,旧激活/低版本留痕;数据库故障不归类为业务失败。消费确认、步骤、后继或根待续、事件及审计同事务。
|
||||||
- 后继满额用SAVEPOINT回滚整轮新工作,保留已接受结果和待续;恢复必须重读持久图。多次根推进使用独立系统审计操作身份;迟到证据也递增实例历史版本,禁止复用已有workflow.advanced唯一身份。
|
- 后继满额用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结清;不得由时钟假定外部请求已结束。
|
- 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恢复旧停止意图,不添加外键。
|
- StopKind/StopRequestedAt冻结首次CANCEL或FAILURE目标,后来的用户取消不覆盖原失败目标。结清复用ROOT,每事务最多一个Pulse取消、32项步骤收尾;结果流锁取得后不能再锁另一任务。终态前校验全部历史任务的双向归属、未结束尝试和未释放许可,UNKNOWN不得终结。迁移000021恢复旧停止意图,不添加外键。
|
||||||
|
|
||||||
- 人工核查使用显式workflows:recover、当前实例revision、步骤/任务版本及激活身份,必须有原因和证据;执行结论、步骤结论、解除核查分别确认。新决定拒绝PENDING/QUARANTINED Inbox,原键回执仍核验权限。核查回执/额度/双审计/结果流同事务,结果流锁最后取得。
|
- 人工核查使用显式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人工恢复以新激活重查同一业务轮,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不自动解除核查。
|
- 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;ContinuePoll显式恢复原等待,必须核验当前SUCCEEDED/PENDING任务、已处理Inbox及不可变来源。新审计固定来源事件/版本、原间隔与截止,时钟/最终信号核验证明后才推进;不改写旧Inbox处置,ReviewTask不自动解除核查。
|
||||||
|
|
||||||
- RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。
|
- RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。
|
||||||
|
|
||||||
|
- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。完整历史、实例列表、信号审计列表与Pulse流程来源仍未交付。
|
||||||
|
- 恢复请求的匿名嵌入结构按扁平精确JSON字段解析,继承字段也拒绝别名/重复/未知字段,保留显式null例外与必填确认。bootstrap先验证权限集合;工作流执行/控制/信号/恢复/审计分别显式授予,旧workflow-access仅定义发布。
|
||||||
|
|||||||
@@ -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) 继续实现。
|
已完成 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)。
|
数据库禁止外键,关联一致性通过服务端事务校验,索引、唯一约束和 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/model/ 定义、实例、步骤、信号和查询轮次
|
||||||
weave/definition/ 有限结构/类型校验与定义发布契约
|
weave/definition/ 有限结构/类型校验与定义发布契约
|
||||||
weave/engine/ 不可变规则的有限步骤决策与精确结果映射
|
weave/engine/ 不可变规则的有限步骤决策与精确结果映射
|
||||||
weave/instance/ 内部实例受理/查询契约,公共运行API继续开发
|
weave/instance/ 实例受理、当前详情及操作审计契约
|
||||||
weave/signal/ 内部可靠信号受理/查询契约及显式激活身份
|
weave/signal/ 内部可靠信号受理/查询契约及显式激活身份
|
||||||
weave/control/ 内部版本化暂停/继续/取消契约
|
weave/control/ 内部版本化暂停/继续/取消契约
|
||||||
internal/config/ 配置校验
|
internal/config/ 配置校验
|
||||||
|
|||||||
@@ -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.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.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.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 {
|
if err := flags.Parse(os.Args[2:]); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,11 @@ import (
|
|||||||
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
"git.yuntuo.hk/k2safe/orbit/pulse/schedule"
|
||||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
"git.yuntuo.hk/k2safe/orbit/pulse/worker"
|
"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/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() {
|
func main() {
|
||||||
@@ -81,6 +85,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
httpserver.RegisterSchedules(server, schedule.New(schedules))
|
httpserver.RegisterSchedules(server, schedule.New(schedules))
|
||||||
httpserver.RegisterWorkflowDefinitions(server, definition.New(mysql.NewWorkflowDefinitionStore(db)))
|
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)
|
workflows, err := mysql.NewWorkflowRuntime(db, node)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -159,7 +164,7 @@ func run() error {
|
|||||||
}
|
}
|
||||||
done := make(chan error, 1)
|
done := make(chan error, 1)
|
||||||
go func() { done <- server.Serve(listener) }()
|
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 {
|
select {
|
||||||
case err := <-done:
|
case err := <-done:
|
||||||
if errors.Is(err, http.ErrServerClosed) {
|
if errors.Is(err, http.ErrServerClosed) {
|
||||||
|
|||||||
@@ -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/workflow-definitions/{definition_id}/versions` | 研发通过 SDK 发布不可变定义;同版本同内容幂等,不同内容冲突 |
|
||||||
| `POST /v1/workflows` | 幂等启动流程,固定定义版本 |
|
| `POST /v1/workflows` | 幂等启动流程,固定定义版本 |
|
||||||
| `GET /v1/workflows/{workflow_id}` | 查询实例、定义版本、执行状态、暂停标记与等待摘要 |
|
| `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` | 幂等受理信号,不等于信号已经被消费 |
|
| `POST /v1/workflows/{workflow_id}/signals` | 幂等受理信号,不等于信号已经被消费 |
|
||||||
| `GET /v1/workflows/{workflow_id}/signals/{signal_id}` | 查询信号受理、匹配与消费结果 |
|
| `GET /v1/workflows/{workflow_id}/signals/{signal_id}` | 查询信号受理、匹配与消费结果 |
|
||||||
| `POST /v1/workflows/{workflow_id}:pause` | 持久化暂停门控与审计;不代表在途业务已停止 |
|
| `POST /v1/workflows/{workflow_id}/control` | PAUSE/RESUME/CANCEL;按control_revision保存门控与意图 |
|
||||||
| `POST /v1/workflows/{workflow_id}:resume` | 解除暂停并安排受控推进;不重置预算、核查状态或原版本 |
|
| `POST /v1/workflows/{workflow_id}/recovery/*` | 六类有证据恢复,按当前实例/步骤/任务版本核对 |
|
||||||
| `POST /v1/workflows/{workflow_id}:cancel` | 记录取消意图,结清关联工作 |
|
| `GET /v1/workflows/{workflow_id}/operations` | 有界分页读取流程操作审计摘要,正文另用单条查询 |
|
||||||
|
|
||||||
流程列表、事件查询与人工核查/恢复接口待细化;恢复必须带预期状态版本、稳定操作身份、原因与证据,不提供任意修改状态的通用入口。
|
流程列表和完整历史查询仍待交付;恢复必须带预期状态版本、稳定操作身份、原因与证据,不提供任意修改状态的通用入口。
|
||||||
|
|
||||||
暂停和继续使用独立管理权限、预期控制版本、幂等操作身份与原因;同一操作重报返回原确认,过期控制版本不能覆盖后来的操作。建议 `200` 确认门控已提交,后续执行进度通过查询观察;门控失效条件和终态操作错误在协议评审时固定。
|
暂停和继续使用独立管理权限、预期控制版本、幂等操作身份与原因;同一操作重报返回原确认,过期控制版本不能覆盖后来的操作。建议 `200` 确认门控已提交,后续执行进度通过查询观察;门控失效条件和终态操作错误在协议评审时固定。
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 流程编排功能与设计
|
# 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. 职责与边界
|
## 1. 职责与边界
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ Weave 不执行特定业务逻辑,也不把整段业务代码放入平台进
|
|||||||
3. 在一个事务内记录管理操作,创建新的步骤激活及关联 Pulse 任务,并固定关联关系。
|
3. 在一个事务内记录管理操作,创建新的步骤激活及关联 Pulse 任务,并固定关联关系。
|
||||||
4. 保留旧激活与旧任务结果,沿用恰当的业务幂等身份。
|
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)。普通任务人工重试入口应拒绝绕过流程管理的关联任务请求。已经确认成功的步骤不能通过“重试流程”随意再执行一次。
|
||||||
|
|
||||||
取消和补偿是不同业务操作。若未来增加补偿,补偿本身也必须是有权限、可审计、可重试或可核查的业务步骤;不能宣称撤销流程就能撤回已经生效的外部业务操作。
|
取消和补偿是不同业务操作。若未来增加补偿,补偿本身也必须是有权限、可审计、可重试或可核查的业务步骤;不能宣称撤销流程就能撤回已经生效的外部业务操作。
|
||||||
|
|
||||||
|
|||||||
@@ -28,24 +28,24 @@
|
|||||||
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
|
| P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) |
|
||||||
| P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 |
|
| 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推进 |
|
| 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继续开发 |
|
| 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继续开发 |
|
| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;完整历史、来源与更多场景继续开发 |
|
||||||
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
| P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 |
|
||||||
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
|
| P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 |
|
||||||
|
|
||||||
SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。
|
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 下一步实施顺序
|
### P4/P5 下一步实施顺序
|
||||||
|
|
||||||
1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。
|
1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。
|
||||||
2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。
|
2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。
|
||||||
3. 已实现内部执行核查、步骤结论、解除核查及TASK新激活和旧激活隔离,见[核查报告](reports/phase-5-recovery.md)。POLL跨激活恢复已实现(见[查询恢复](17-workflow-poll-recovery.md));[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)也已接入内部服务,保留原预算、轮次、结果和证据。
|
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的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
|
5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。
|
||||||
|
|
||||||
公共接入下一批按以下调用链交付,逐项补齐服务端、独立SDK、Markdown手册和真实HTTP回归:
|
公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。以下历史、来源、分页和更多独立场景继续交付:
|
||||||
|
|
||||||
| 调用链 | 需要补齐的内容 | 验收重点 |
|
| 调用链 | 需要补齐的内容 | 验收重点 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
@@ -105,10 +105,10 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
|||||||
| 业务操作定位 | 精确业务键 → 创建区间 → 游标分页 → 查询详情 | P3,已实现完整示例 |
|
| 业务操作定位 | 精确业务键 → 创建区间 → 游标分页 → 查询详情 | P3,已实现完整示例 |
|
||||||
| 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 |
|
| 独立任务人工恢复 | 未知结果 → 证据核查 → 保留原任务 → 关联新任务 → 审计查询 | P3,已实现完整示例 |
|
||||||
| 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) |
|
| 研发定义发布 | 六类节点描述 → 校验/发布 → 原键确认 → 新版本 → 读取旧版本 | P4定义子项,[已实现示例](examples/workflow-definition.md) |
|
||||||
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4 |
|
| 并行生成结果 | 发布流程 → 并行执行 → 汇合 → 完成 | P4,[已实现示例](examples/workflow.md) |
|
||||||
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4 |
|
| 审核与外部回执 | 提交流程 → 等待信号 → 授权信号注入 → 继续 | P4,[已实现模拟外部回执示例](examples/workflow.md);实际外部系统由接入方对接 |
|
||||||
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5 |
|
| 外部作业轮询 | 提交一次 → 有界查询处理中状态 → 回执或最终结果 → 超限核查 | P5,HTTP/SDK恢复场景已回归,独立可运行示例待补 |
|
||||||
| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5 |
|
| 暂停与人工恢复 | 暂停 → 保存结果 → 核查/恢复 → 查看审计 | P5,HTTP/SDK及审计场景已回归,独立可运行示例待补 |
|
||||||
| 故障恢复 | 节点/连接中断 → 相同身份核对 → 恢复或人工核查 | P2–P6 |
|
| 故障恢复 | 节点/连接中断 → 相同身份核对 → 恢复或人工核查 | P2–P6 |
|
||||||
| 执行历史核对 | 读取尝试 → 查看冲突/迟到证据 → 对照权威结果 | P2,已实现完整示例 |
|
| 执行历史核对 | 读取尝试 → 查看冲突/迟到证据 → 对照权威结果 | P2,已实现完整示例 |
|
||||||
|
|
||||||
@@ -130,6 +130,11 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](
|
|||||||
|
|
||||||
## 7. 文档入口
|
## 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)
|
- [等待恢复](18-workflow-wait-recovery.md)
|
||||||
- [等待恢复验证记录](reports/phase-5-wait-recovery.md)
|
- [等待恢复验证记录](reports/phase-5-wait-recovery.md)
|
||||||
- [有界查询恢复](17-workflow-poll-recovery.md)
|
- [有界查询恢复](17-workflow-poll-recovery.md)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ v16及以前的未处理记录缺少冻结来源,升级后统一标为QUARANTI
|
|||||||
|
|
||||||
## 4. 后续存储适配必须满足的条件
|
## 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. 启动时认证、固定不可变版本/输入和策略、校验全部队列、计算实例截止,并在同事务创建实例、门控、首批激活/任务及审计回执。相同身份返回原实例。
|
1. 启动时认证、固定不可变版本/输入和策略、校验全部队列、计算实例截止,并在同事务创建实例、门控、首批激活/任务及审计回执。相同身份返回原实例。
|
||||||
2. 消费者领取内部事件后核对命名空间、来源任务版本、激活/轮次及当前步骤版本;消费确认、步骤变化、后续任务或持久化待续记录同事务提交。
|
2. 消费者领取内部事件后核对命名空间、来源任务版本、激活/轮次及当前步骤版本;消费确认、步骤变化、后续任务或持久化待续记录同事务提交。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 实例受理与首批工作
|
# 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. 受理事务
|
## 1. 受理事务
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 结果消费与自动推进
|
# 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. 后台运行和事务边界
|
## 1. 后台运行和事务边界
|
||||||
|
|
||||||
@@ -64,4 +64,4 @@ flowchart LR
|
|||||||
|
|
||||||
进程回归由`make integration`构建二进制后显式执行。缺少一次性数据库或`ORBIT_TEST_SERVER_BINARY`时测试会跳过;跳过不构成进程恢复证据。验证记录见[本阶段报告](reports/phase-4-runtime.md)。
|
进程回归由`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)执行。
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ TIMER由待续表直接唤醒,不创建Pulse业务任务,也不占Worker执
|
|||||||
|
|
||||||
当前任务仍在途且查询总截止已到时,实例转NEEDS_REVIEW并关闭新执行门控;步骤保持ACTIVE、轮次保持当前任务引用,继续收集Pulse结果。时钟不释放执行许可,也不假定外部请求已经停止。Pulse恢复将失联执行记录为UNKNOWN后,内部消费再结清当前查询记录,保留核查状态。
|
当前任务仍在途且查询总截止已到时,实例转NEEDS_REVIEW并关闭新执行门控;步骤保持ACTIVE、轮次保持当前任务引用,继续收集Pulse结果。时钟不释放执行许可,也不假定外部请求已经停止。Pulse恢复将失联执行记录为UNKNOWN后,内部消费再结清当前查询记录,保留核查状态。
|
||||||
|
|
||||||
最终信号与在途查询的竞争已按[信号运行约定](14-workflow-signals.md)接入:保存赢家后等待Pulse查询结清,不能提前释放许可或覆盖未知效果。实例及信号公共HTTP/SDK尚未开放。
|
最终信号与在途查询的竞争已按[信号运行约定](14-workflow-signals.md)接入:保存赢家后等待Pulse查询结清,不能提前释放许可或覆盖未知效果。实例及信号公共HTTP/SDK已接入,见[实例接口](api/workflows.md)。
|
||||||
|
|
||||||
## 5. 升级和验证
|
## 5. 升级和验证
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 外部信号与最终查询结果
|
# 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. 受理身份与权限
|
## 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)。
|
[受理回归](../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切换与容量测试。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 控制与停止结清
|
# Weave 控制与停止结清
|
||||||
|
|
||||||
状态:内部Go暂停、继续、取消服务及后台失败/取消结清运行器已接入。**公共实例/控制HTTP与独立SDK尚未开放,[内部核查及TASK新激活](16-workflow-recovery.md)已接入,等待恢复已接入内部服务,公共入口仍在后续交付范围**。本文说明已实现的持久化规则,不把内部存储回归当作全部Weave或生产验收。
|
状态:暂停、继续、取消与后台失败/取消结清已实现,并通过[公共控制API及SDK](api/workflows.md#4-暂停继续与取消)调用。人工恢复另见[恢复API](api/workflow-recovery.md)。当前功能回归不等同于生产验收。
|
||||||
|
|
||||||
## 1. 控制操作
|
## 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)。
|
[控制及结清回归](../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验证。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 人工核查与接续
|
# 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. 六类操作
|
## 1. 六类操作
|
||||||
|
|
||||||
@@ -74,6 +74,6 @@ POLL跨激活恢复已实现,保留原业务轮、同轮剩余尝试、查询
|
|||||||
| POLL次数耗尽,人工取得最终结果 | 核实历史请求均已结束 → ResolveStep(符合固定成功映射的结果) → Continue;原used_rounds不改变 |
|
| POLL次数耗尽,人工取得最终结果 | 核实历史请求均已结束 → ResolveStep(符合固定成功映射的结果) → Continue;原used_rounds不改变 |
|
||||||
| 取消后出现UNKNOWN | ReviewTask → 等待消费 → ResolveStep → Continue → 原取消结清;不创建后继工作 |
|
| 取消后出现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)记录验证范围。本阶段不替代生产数据库高可用、灾难恢复和容量验收。
|
[存储专项](../internal/storage/mysql/workflow_recovery_integration_test.go)、[实际进程测试](../internal/storage/mysql/workflow_runtime_process_integration_test.go)和[阶段报告](reports/phase-5-recovery.md)记录验证范围。本阶段不替代生产数据库高可用、灾难恢复和容量验收。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 有界查询的人工恢复
|
# 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. 业务轮次与请求尝试
|
## 1. 业务轮次与请求尝试
|
||||||
|
|
||||||
@@ -50,7 +50,7 @@ POLL 带最终信号时,恢复创建同契约、同精确关联键、同原截
|
|||||||
|
|
||||||
当前代次仍有 PENDING 信号时,先消费这些事实,恢复拒绝直接丢弃。已有赢家时核对其受理证明与关联后保留历史。最终信号原截止已过时不重新开始等待。
|
当前代次仍有 PENDING 信号时,先消费这些事实,恢复拒绝直接丢弃。已有赢家时核对其受理证明与关联后保留历史。最终信号原截止已过时不重新开始等待。
|
||||||
|
|
||||||
这些规则覆盖 POLL 附属等待;独立 SIGNAL/TIMER 使用[RearmWait](18-workflow-wait-recovery.md),公共接口仍待交付。
|
这些规则覆盖 POLL 附属等待;独立 SIGNAL/TIMER 使用[RearmWait](18-workflow-wait-recovery.md),公共接口已接入。
|
||||||
|
|
||||||
## 5. 内部调用场景
|
## 5. 内部调用场景
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Weave 等待核查与恢复
|
# 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. 两类操作
|
## 1. 两类操作
|
||||||
|
|
||||||
|
|||||||
@@ -26,8 +26,9 @@
|
|||||||
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
|
| `/v1/schedules`、不可变版本、暂停/恢复及触发查询 | P3 已实现 | [计划接口与 SDK 场景](schedules.md) |
|
||||||
| `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) |
|
| `/v1/subscriptions` 及管理、交付查询与重试 | 已实现 HTTP、管理 SDK 和完整管理示例 | [订阅手册](subscriptions.md) |
|
||||||
| `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.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/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例另用启动接口 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) |
|
||||||
| `/v1/workflows` 与流程管理 | 计划 P4–P5,尚未实现 | [流程接口规划](workflows.md) |
|
| `/v1/workflows`、步骤详情、控制、信号及操作审计 | 已接入HTTP与独立SDK | [实例接口](workflows.md) |
|
||||||
|
| `/v1/workflows/{id}/recovery/*` 六类人工恢复 | 已接入有证据恢复及独立SDK | [恢复接口](workflow-recovery.md) |
|
||||||
|
|
||||||
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。
|
||||||
|
|
||||||
|
|||||||
@@ -63,3 +63,5 @@ P3 独立任务核查、人工重试和操作审计分别增加 `tasks:review`
|
|||||||
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
P3 结果订阅核心新增 `events:read`、`events:manage` 和 `events:consume`,bootstrap 仅显式 `--event-access` 时授予。订阅绑定创建凭据与单个队列,不能用同命名空间的另一张凭据消费或管理。当前 HTTP/WSS、管理及消费 SDK 已接入;凭据重绑继续开发,详见 [订阅接口](subscriptions.md)。
|
||||||
|
|
||||||
P4定义发布新增`workflows:definitions:read`与`workflows:definitions:publish`,仅显式`--workflow-access`授予。创建/读取元数据作用于命名空间,发布及读取完整版本额外校验全部引用队列;默认提交/Worker凭据不获此权限。发布不创建任务,不要求tasks:submit,详见[定义API](workflow-definitions.md)。
|
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)。初始化权限集合在交付凭据前校验。
|
||||||
|
|||||||
@@ -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. 权限、传输与范围
|
## 1. 权限、传输与范围
|
||||||
|
|
||||||
|
|||||||
146
docs/api/workflow-recovery.md
Normal file
146
docs/api/workflow-recovery.md
Normal file
@@ -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)。
|
||||||
@@ -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 <token>` 和一个稳定的 `Idempotency-Key`;键最长128字节,字符规则沿用[任务协议](tasks.md)。请求体最大512 KiB,拒绝重复字段、大小写别名、未声明字段和多余查询参数。
|
||||||
|
|
||||||
|
| 方法与路径 | 权限 | Go SDK 方法 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 启动实例 | `POST /v1/workflows` | 固定版本并幂等启动,首批任务与实例原子创建 |
|
| `POST /v1/workflows` | `workflows:start`;有任务队列时还需 `tasks:submit` | StartWorkflow |
|
||||||
| 查询实例 | `GET /v1/workflows/{workflow_id}` | 状态、暂停标记、版本、等待原因、预算摘要 |
|
| `GET /v1/workflows/{id}` | `workflows:read` | GetWorkflow |
|
||||||
| 查询步骤 | `GET /v1/workflows/{workflow_id}/steps` | 步骤、激活、查询轮次、输入/结果引用与任务关联 |
|
| `GET /v1/workflows/{id}/steps` | `workflows:read` | GetWorkflowSteps |
|
||||||
| 提交信号 | `POST /v1/workflows/{workflow_id}/signals` | 幂等、授权、业务操作匹配;受理与消费分别可查 |
|
| `GET /v1/workflows/{id}/steps/{key}` | `workflows:read` | GetWorkflowStep |
|
||||||
| 暂停/继续 | `POST /v1/workflows/{workflow_id}:pause`、`:resume` | 控制版本与门控原子变化,保留进度、预算与审计 |
|
| `POST /v1/workflows/{id}/control` | `workflows:control` | ControlWorkflow |
|
||||||
| 取消 | `POST /v1/workflows/{workflow_id}:cancel` | 停止后续工作,收集在途结果,不伪装业务撤销 |
|
| `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)。
|
||||||
|
|||||||
@@ -6,7 +6,13 @@
|
|||||||
|
|
||||||
- [完整命令和结果](workflow-definition.md)、[SDK源码](../../sdk/go/examples/workflow-definition/main.go)。
|
- [完整命令和结果](workflow-definition.md)、[SDK源码](../../sdk/go/examples/workflow-definition/main.go)。
|
||||||
- 六类节点的有限结构定义、原键及同版本确认、新版本发布、旧版本读取保持不变。
|
- 六类节点的有限结构定义、原键及同版本确认、新版本发布、旧版本读取保持不变。
|
||||||
- 当前只发布定义,实例执行仍在P4–P5开发。
|
- 本例只发布定义;实际运行见下方流程示例。
|
||||||
|
|
||||||
|
## 已可运行:并行作业、定时等待与外部回执
|
||||||
|
|
||||||
|
- [完整命令、输入输出与失败处理](workflow.md)、[独立SDK源码](../../sdk/go/examples/workflow/main.go)。
|
||||||
|
- 两个任务并行处理并汇合,计时等待后接收显式目标代次的信号,再执行后续任务;最终三个任务完成并有界排空。
|
||||||
|
- 启动及信号原键重复不会建立新的实例或信号。
|
||||||
|
|
||||||
## 已可运行:业务事务与任务提交Outbox
|
## 已可运行:业务事务与任务提交Outbox
|
||||||
|
|
||||||
|
|||||||
@@ -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)。
|
源码:[workflow-definition/main.go](../../sdk/go/examples/workflow-definition/main.go)。接口:[定义发布](../api/workflow-definitions.md);字段规范:[结构版本1](../api/workflow-definition-format.md)。
|
||||||
|
|
||||||
|
|||||||
57
docs/examples/workflow.md
Normal file
57
docs/examples/workflow.md
Normal file
@@ -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)推进。
|
||||||
42
docs/reports/phase-5-public-api.md
Normal file
42
docs/reports/phase-5-public-api.md
Normal file
@@ -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验证。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Go SDK 接入说明
|
# 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`,不引用服务端模型或数据库实现。
|
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)。
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ import (
|
|||||||
|
|
||||||
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
// BootstrapInput 仅供持有数据库管理权限的初始化命令使用,不暴露为公共 HTTP。
|
||||||
type BootstrapInput struct {
|
type BootstrapInput struct {
|
||||||
|
WorkflowAuditAccess bool // WorkflowAuditAccess 显式授予流程操作审计读取;不授予其他管理操作。
|
||||||
|
WorkflowRunAccess bool // WorkflowRunAccess 显式授予流程启动和读取,不包含发布或管理。
|
||||||
|
WorkflowControlAccess bool // WorkflowControlAccess 显式授予流程暂停、继续和取消及读取。
|
||||||
|
WorkflowSignalAccess bool // WorkflowSignalAccess 显式授予流程信号受理及读取。
|
||||||
|
WorkflowRecoveryAccess bool // WorkflowRecoveryAccess 显式授予有证据的流程人工恢复及读取。
|
||||||
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
WorkflowAccess bool // WorkflowAccess 显式授予流程定义读取和发布权限,不含实例执行权限。
|
||||||
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
RecoveryAccess bool // RecoveryAccess 显式授予任务核查、人工重试及操作审计读取权限。
|
||||||
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
EventAccess bool // EventAccess 显式增加结果订阅查询、管理和消费权限。
|
||||||
@@ -80,6 +85,24 @@ func Bootstrap(ctx context.Context, db *sql.DB, in BootstrapInput, deliver func(
|
|||||||
if in.WorkflowAccess {
|
if in.WorkflowAccess {
|
||||||
permissions.Actions = append(permissions.Actions, "workflows:definitions:read", "workflows:definitions:publish")
|
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 {
|
if in.ScheduleAccess {
|
||||||
permissions.Actions = append(permissions.Actions, "schedules:read", "schedules:manage")
|
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 {
|
if in.RecoveryAccess {
|
||||||
permissions.Actions = append(permissions.Actions, "tasks:review", "tasks:retry", "tasks:audit:read")
|
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)
|
raw, err := json.Marshal(permissions)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ func activateWorkflowStep(ctx context.Context, tx *sql.Tx, g *workflowGraph, row
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
row.view.ActivationID = &id
|
row.view.ActivationID = &id
|
||||||
|
row.view.ActivationSequence = 1
|
||||||
row.view.TaskID = direct
|
row.view.TaskID = direct
|
||||||
deadline := g.view.DeadlineAt
|
deadline := g.view.DeadlineAt
|
||||||
if c.Kind == "POLL" {
|
if c.Kind == "POLL" {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
79
internal/storage/mysql/workflow_details.go
Normal file
79
internal/storage/mysql/workflow_details.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
102
internal/storage/mysql/workflow_details_integration_test.go
Normal file
102
internal/storage/mysql/workflow_details_integration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// WorkflowInstanceStore 以应用额度锁串行受理,在一个事务内建立全部归属及初始工作。
|
// WorkflowInstanceStore 以应用额度锁串行受理,在一个事务内建立全部归属及初始工作。
|
||||||
// 当前为内部存储适配层,公共HTTP和SDK在消费运行器接通后开放。
|
// 公共HTTP与内部运行器复用同一套固定版本、幂等与事务规则。
|
||||||
type WorkflowInstanceStore struct{ *TaskStore }
|
type WorkflowInstanceStore struct{ *TaskStore }
|
||||||
|
|
||||||
func NewWorkflowInstanceStore(db *sql.DB) *WorkflowInstanceStore {
|
func NewWorkflowInstanceStore(db *sql.DB) *WorkflowInstanceStore {
|
||||||
@@ -226,34 +226,46 @@ func (s *WorkflowInstanceStore) Get(ctx context.Context, t auth.Token, id core.I
|
|||||||
})
|
})
|
||||||
return out, err
|
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) {
|
func (s *WorkflowInstanceStore) Steps(ctx context.Context, t auth.Token, id core.ID) (instance.StepsView, error) {
|
||||||
out := instance.StepsView{InstanceID: id, Steps: []instance.StepView{}}
|
out := instance.StepsView{InstanceID: id, Steps: []instance.StepView{}}
|
||||||
err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error {
|
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)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
out.Revision = v.Revision
|
out.Revision = v.Revision
|
||||||
|
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)
|
program, err := engine.Compile(version.Specification)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
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,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)
|
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 {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
@@ -261,32 +273,30 @@ func (s *WorkflowInstanceStore) Steps(ctx context.Context, t auth.Token, id core
|
|||||||
var activation *core.ID
|
var activation *core.ID
|
||||||
var task, queue *core.ID
|
var task, queue *core.ID
|
||||||
var state sql.NullString
|
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 {
|
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 err
|
return nil, err
|
||||||
}
|
}
|
||||||
node, ok := program.Step(step.Key)
|
node, ok := program.Step(step.Key)
|
||||||
if !ok || node.Kind != string(step.Kind) {
|
if !ok || node.Kind != string(step.Kind) {
|
||||||
return errors.New("workflow step definition mismatch")
|
return nil, errors.New("workflow step definition mismatch")
|
||||||
}
|
}
|
||||||
inactive := step.State == "BLOCKED" || step.State == "SKIPPED"
|
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)) {
|
if inactive && step.ActivationID != nil || !inactive && (step.ActivationSequence == 0 || step.ActivationID == nil || activation == nil || *activation != *step.ActivationID || state.String != string(step.State)) {
|
||||||
return errors.New("workflow current activation mismatch")
|
return nil, errors.New("workflow current activation mismatch")
|
||||||
}
|
}
|
||||||
if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) {
|
if !inactive && (step.Kind == "TASK") != (step.TaskID != nil) {
|
||||||
return errors.New("workflow direct task ownership mismatch")
|
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) {
|
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")
|
return nil, errors.New("workflow task association invalid")
|
||||||
}
|
}
|
||||||
out.Steps = append(out.Steps, step)
|
items = append(items, step)
|
||||||
}
|
}
|
||||||
if err := rows.Err(); err != nil {
|
if err := rows.Err(); err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if len(out.Steps) != int(version.Analysis.Steps) {
|
if key == "" && len(items) != int(version.Analysis.Steps) {
|
||||||
return errors.New("workflow step set incomplete")
|
return nil, errors.New("workflow step set incomplete")
|
||||||
}
|
}
|
||||||
return nil
|
return items, nil
|
||||||
})
|
|
||||||
return out, err
|
|
||||||
}
|
}
|
||||||
|
|||||||
80
internal/storage/mysql/workflow_operations.go
Normal file
80
internal/storage/mysql/workflow_operations.go
Normal file
@@ -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<? OR (created_at=? AND id<?))`
|
||||||
|
args = append(args, *c.Created, *c.Created, c.ID)
|
||||||
|
}
|
||||||
|
query += ` ORDER BY created_at DESC,id DESC LIMIT ?`
|
||||||
|
args = append(args, in.Limit+1)
|
||||||
|
rows, err := tx.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
v, err := scanWorkflowOperation(rows)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
out.Items = append(out.Items, v)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(out.Items) > 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
|
||||||
|
}
|
||||||
@@ -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")
|
return nil, errors.New("poll cannot have direct task reference")
|
||||||
}
|
}
|
||||||
row.view.State, row.view.ActivationID = weave.StepActive, &activation
|
row.view.State, row.view.ActivationID = weave.StepActive, &activation
|
||||||
|
row.view.ActivationSequence = origin.sequence + 1
|
||||||
row.view.Revision++
|
row.view.Revision++
|
||||||
row.output = nil
|
row.output = nil
|
||||||
if err := saveWorkflowClock(ctx, tx, g, row, "POLL", row.view.Revision, p.DeadlineAt); err != nil {
|
if err := saveWorkflowClock(ctx, tx, g, row, "POLL", row.view.Revision, p.DeadlineAt); err != nil {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func lockWorkflowGraph(ctx context.Context, tx *sql.Tx, ns, id core.ID, skip boo
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
g := &workflowGraph{view: v, gate: gate, version: version, program: program, steps: map[string]*workflowStep{}}
|
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 {
|
if err != nil {
|
||||||
return nil, false, err
|
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 activation, task, queue *core.ID
|
||||||
var state sql.NullString
|
var state sql.NullString
|
||||||
var input, output []byte
|
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
|
return nil, false, err
|
||||||
}
|
}
|
||||||
row.input, row.output = input, output
|
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")
|
return nil, false, errors.New("workflow step definition mismatch")
|
||||||
}
|
}
|
||||||
inactive := row.view.State == weave.StepBlocked || row.view.State == weave.StepSkipped
|
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")
|
return nil, false, errors.New("workflow current activation mismatch")
|
||||||
}
|
}
|
||||||
if !inactive && (row.view.Kind == weave.NodeTask) != (row.view.TaskID != nil) {
|
if !inactive && (row.view.Kind == weave.NodeTask) != (row.view.TaskID != nil) {
|
||||||
|
|||||||
@@ -142,6 +142,7 @@ func (s *WorkflowRecoveryStore) RetryStep(ctx context.Context, t auth.Token, id
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row.view.State, row.view.ActivationID, row.view.TaskID = weave.StepActive, &activation, &taskID
|
row.view.State, row.view.ActivationID, row.view.TaskID = weave.StepActive, &activation, &taskID
|
||||||
|
row.view.ActivationSequence = sequence + 1
|
||||||
row.view.Revision++
|
row.view.Revision++
|
||||||
row.output = nil
|
row.output = nil
|
||||||
if err := deleteWorkflowClock(ctx, tx, g, row); err != nil {
|
if err := deleteWorkflowClock(ctx, tx, g, row); err != nil {
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ func (s *WorkflowRecoveryStore) RearmWait(ctx context.Context, t auth.Token, id
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
row.view.State, row.view.ActivationID = weave.StepWaiting, &activation
|
row.view.State, row.view.ActivationID = weave.StepWaiting, &activation
|
||||||
|
row.view.ActivationSequence = origin.sequence + 1
|
||||||
row.view.Revision++
|
row.view.Revision++
|
||||||
row.output = nil
|
row.output = nil
|
||||||
due := w.deadline
|
due := w.deadline
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
|
|||||||
if !errors.As(err, &known) {
|
if !errors.As(err, &known) {
|
||||||
known = fault.Unavailable
|
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 {
|
if status == 0 {
|
||||||
status = 503
|
status = 503
|
||||||
}
|
}
|
||||||
@@ -320,10 +320,23 @@ func exactFields(raw []byte, t reflect.Type) error {
|
|||||||
}
|
}
|
||||||
names := map[string]reflect.Type{}
|
names := map[string]reflect.Type{}
|
||||||
nullable := map[string]bool{}
|
nullable := map[string]bool{}
|
||||||
for i := 0; i < t.NumField(); i++ {
|
var collect func(reflect.Type) error
|
||||||
field := t.Field(i)
|
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]
|
name := strings.Split(field.Tag.Get("json"), ",")[0]
|
||||||
if name != "" && name != "-" {
|
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
|
names[name] = field.Type
|
||||||
nullable[name] = field.Tag.Get("nullable") == "true"
|
nullable[name] = field.Tag.Get("nullable") == "true"
|
||||||
if field.Tag.Get("required") == "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 {
|
for name, value := range object {
|
||||||
field, ok := names[name]
|
field, ok := names[name]
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
102
internal/transport/httpserver/workflows.go
Normal file
102
internal/transport/httpserver/workflows.go
Normal file
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
69
internal/transport/httpserver/workflows_test.go
Normal file
69
internal/transport/httpserver/workflows_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ type Permissions struct {
|
|||||||
Queues []string `json:"queues"`
|
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 {
|
func (p Permissions) Validate() error {
|
||||||
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
|
if len(p.Actions) == 0 || len(p.Actions) > 32 || len(p.Queues) == 0 || len(p.Queues) > 128 {
|
||||||
|
|||||||
@@ -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_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)
|
(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"
|
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 '^TestIntegrationWorkflowDefinitionSDK$' -v .)
|
(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-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。
|
# 接收方业务数据独立建库;数据库驱动仅属于此示例模块,不加入核心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"'
|
"$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"'
|
||||||
|
|||||||
174
sdk/go/examples/workflow/main.go
Normal file
174
sdk/go/examples/workflow/main.go
Normal file
@@ -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))})
|
||||||
|
}
|
||||||
234
sdk/go/workflow_instance_types.go
Normal file
234
sdk/go/workflow_instance_types.go
Normal file
@@ -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"`
|
||||||
|
}
|
||||||
144
sdk/go/workflow_instances.go
Normal file
144
sdk/go/workflow_instances.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
403
sdk/go/workflow_instances_integration_test.go
Normal file
403
sdk/go/workflow_instances_integration_test.go
Normal file
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
55
weave/instance/details.go
Normal file
55
weave/instance/details.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
63
weave/instance/operations.go
Normal file
63
weave/instance/operations.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ type StartResult struct {
|
|||||||
|
|
||||||
// StepView 只列元数据,避免把最多128个大输入输出聚合为无界响应。
|
// StepView 只列元数据,避免把最多128个大输入输出聚合为无界响应。
|
||||||
type StepView struct {
|
type StepView struct {
|
||||||
|
ActivationSequence uint32 `json:"activation_sequence,omitempty"` // ActivationSequence 当前激活代次;未激活时省略,信号必须显式指定此代次。
|
||||||
ID core.ID `json:"id"` // ID 实例内逻辑步骤身份。
|
ID core.ID `json:"id"` // ID 实例内逻辑步骤身份。
|
||||||
Key string `json:"key"` // Key 固定定义步骤名称。
|
Key string `json:"key"` // Key 固定定义步骤名称。
|
||||||
Kind weave.NodeKind `json:"kind"` // Kind 固定节点类型。
|
Kind weave.NodeKind `json:"kind"` // Kind 固定节点类型。
|
||||||
@@ -71,9 +72,12 @@ type StepsView struct {
|
|||||||
Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。
|
Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。
|
||||||
}
|
}
|
||||||
type Store interface {
|
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)
|
Start(context.Context, auth.Token, string, StartInput) (StartResult, error)
|
||||||
Get(context.Context, auth.Token, core.ID) (View, error)
|
Get(context.Context, auth.Token, core.ID) (View, error)
|
||||||
Steps(context.Context, auth.Token, core.ID) (StepsView, 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 }
|
type Service struct{ store Store }
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
|
|
||||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||||
|
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
type captureStore struct {
|
type captureStore struct {
|
||||||
@@ -28,6 +29,10 @@ func (s *captureStore) Steps(context.Context, auth.Token, core.ID) (StepsView, e
|
|||||||
s.calls++
|
s.calls++
|
||||||
return StepsView{}, nil
|
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) {
|
func TestAdmissionBoundaryPreservesExactInput(t *testing.T) {
|
||||||
store := &captureStore{}
|
store := &captureStore{}
|
||||||
api := New(store)
|
api := New(store)
|
||||||
@@ -57,3 +62,12 @@ func TestAdmissionBoundaryPreservesExactInput(t *testing.T) {
|
|||||||
t.Fatal("missing identity accepted")
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user