From 17a8e0126c5f0b7a7d79e98d44895a902c959ec9 Mon Sep 17 00:00:00 2001 From: k2safe Date: Fri, 18 Sep 2026 07:56:18 +0800 Subject: [PATCH] feat: expose bounded workflow activation and signal history --- AGENTS.md | 14 +- README.md | 2 +- docs/07-data-model.md | 4 + docs/08-development-plan.md | 16 +- docs/README.md | 1 + docs/api/README.md | 1 + docs/api/workflow-history.md | 164 ++++++++++++ docs/api/workflows.md | 4 +- docs/examples/README.md | 2 +- docs/examples/workflow.md | 3 +- docs/reports/phase-5-history.md | 34 +++ docs/sdk/README.md | 2 + ...tion_resources_upgrade_integration_test.go | 3 +- .../000023_workflow_signal_history.up.sql | 3 + .../mysql/no_foreign_keys_integration_test.go | 3 +- .../storage/mysql/no_foreign_keys_test.go | 29 ++ internal/storage/mysql/task_history.go | 4 +- internal/storage/mysql/workflow_details.go | 83 +++--- internal/storage/mysql/workflow_history.go | 206 ++++++++++++++ .../workflow_history_integration_test.go | 251 ++++++++++++++++++ ...rkflow_history_upgrade_integration_test.go | 121 +++++++++ .../storage/mysql/workflow_signal_history.go | 141 ++++++++++ .../transport/httpserver/workflow_history.go | 58 ++++ internal/transport/httpserver/workflows.go | 1 + sdk/go/examples/workflow/main.go | 23 ++ sdk/go/workflow_history.go | 173 ++++++++++++ sdk/go/workflow_history_test.go | 89 +++++++ sdk/go/workflow_history_types.go | 92 +++++++ sdk/go/workflow_instances_integration_test.go | 71 +++++ weave/instance/history.go | 93 +++++++ weave/instance/service.go | 3 + weave/instance/service_test.go | 13 + weave/signal/history.go | 91 +++++++ weave/signal/service.go | 3 + weave/signal/service_test.go | 14 + 35 files changed, 1756 insertions(+), 59 deletions(-) create mode 100644 docs/api/workflow-history.md create mode 100644 docs/reports/phase-5-history.md create mode 100644 internal/storage/mysql/migrations/000023_workflow_signal_history.up.sql create mode 100644 internal/storage/mysql/no_foreign_keys_test.go create mode 100644 internal/storage/mysql/workflow_history.go create mode 100644 internal/storage/mysql/workflow_history_integration_test.go create mode 100644 internal/storage/mysql/workflow_history_upgrade_integration_test.go create mode 100644 internal/storage/mysql/workflow_signal_history.go create mode 100644 internal/transport/httpserver/workflow_history.go create mode 100644 sdk/go/workflow_history.go create mode 100644 sdk/go/workflow_history_test.go create mode 100644 sdk/go/workflow_history_types.go create mode 100644 weave/instance/history.go create mode 100644 weave/signal/history.go diff --git a/AGENTS.md b/AGENTS.md index 1f64105..54c093c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,17 +31,17 @@ - P4定义发布使用schema_version=1有限结构,词法作用域禁止跨分支/前向读取,任务策略显式冻结;SDK类型独立。发布按认证→应用额度→操作身份→定义→排序队列持锁;原键与同版本去重仍校验当前权限。定义/版本/revision/审计/回执同事务;同版本不同内容不得覆盖。保留精确数字,校验存储原文与结构摘要;不得把发布通过或DSL支持误称为实例执行已实现。 -- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,继续完整历史;P4整体和P5–P7仍未完成。 +- P4定义子项已通过真实MySQL、HTTP丢回执、独立SDK和全量回归(docs/reports/phase-4-definitions.md)。实例启动/固定版本/首批任务同事务的内部存储已补齐;内部事件消费、顺序与组合推进及根接续已接入;TIMER与后续POLL时钟已接入,SIGNAL及最终查询竞争已接入,内部控制与停止结清已接入,等待恢复已接入,实例公共API/SDK已接入,流程任务来源已接入,已接入有界历史查询,继续实例分页和统一事件时间线范围核对;P4整体和P5–P7仍未完成。 - 内部结果交付使用workflow_activation所有者并固定激活ID,TASK经activation.task_id、POLL经轮次校验归属;weave_inbox来源及独立快照与Pulse结果同事务,先于外部结果流行锁,不在结果路径反锁实例/步骤/订阅。迁移000017隔离缺快照历史,禁止用当前任务重建旧事件。 -- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,流程任务来源已接入,完整历史继续开发。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。 +- weave/engine仅计算有限决策;实例受理、Inbox消费与根接续已接入,等待和停止结清运行器已接入,内部核查/TASK恢复已接入,等待恢复已接入内部服务,公共API/SDK已接入,流程任务来源已接入,有界历史已接入,继续实例分页和事件时间线范围核对。Plan最多32个变更,More必须持久化待续;耗尽变更预算仍须遍历其他已活动分支,不能误判在途工作不可达。冻结分支选择,精确数值,不以PENDING查询结果完成POLL。后续消费确认/当前激活校验/新任务或检查点必须同事务,不能把纯快照往返测试当作真实进程恢复。 - 实例内部受理已实现,信号内部运行器已接入,公共实例/信号HTTP及独立SDK已接入;结果消费和根待续扫描已接入后台。Start按认证→应用额度→原身份→排序队列建立门控/固定版本/全部步骤/首批工作,所有失败同事务回滚。首批推进递增revision至2,原键返回冻结回执且仍核验当前权限,不重算截止。 - 实例根步骤`$root`/JOIN不属于用户定义,Steps查询排除它;More对应根步骤版本化持久检查点。TASK直接关联激活task_id,POLL通过轮次关联,实际任务截止必须受实例/查询绝对截止限制。Wait名称及契约版本冻结,旧NULL等待须核实,不能盲目解释;新关联512字节、存储兼容历史764字节。 - 结果消费按身份→应用额度→排序队列→实例→门控→步骤/Inbox持锁,禁止先锁Inbox再锁父资源。来源使用独立冻结快照,旧激活/低版本留痕;数据库故障不归类为业务失败。消费确认、步骤、后继或根待续、事件及审计同事务。 - 后继满额用SAVEPOINT回滚整轮新工作,保留已接受结果和待续;恢复必须重读持久图。多次根推进使用独立系统审计操作身份;迟到证据也递增实例历史版本,禁止复用已有workflow.advanced唯一身份。 -- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK已接入,流程任务来源已接入,完整历史仍须完成,不得按部分自动运行宣称P4/P5完成。 +- 本轮消费运行器和真实Orbit强杀/双节点接管已验证,见docs/reports/phase-4-runtime.md。TIMER到期与POLL后续轮次已接入,见docs/13-workflow-clock.md;SIGNAL及最终信号竞争已接入(docs/14-workflow-signals.md);内部FAIL_AFTER_SETTLED分支结清与控制已接入(docs/15-workflow-controls.md);公共控制API和实例SDK已接入,流程任务来源已接入,实例列表、统一事件时间线范围核对与更多场景仍须完成,不得按部分自动运行宣称P4/P5完成。 - TIMER/POLL复用weave_pending_advances及到期索引,时钟固定激活ID;迁移000019只回填原等待/查询时钟,不改截止和预算。后续POLL只由已确认POLL_PENDING开启,首轮精确输入/资源键冻结,每轮Pulse尝试独立计数;满额/暂停不消耗轮数。截止遇到已落库结果先等待Inbox,仍在途则关闭门控但保留ACTIVE和当前任务,许可由Pulse结清;不得由时钟假定外部请求已结束。 @@ -52,16 +52,18 @@ - StopKind/StopRequestedAt冻结首次CANCEL或FAILURE目标,后来的用户取消不覆盖原失败目标。结清复用ROOT,每事务最多一个Pulse取消、32项步骤收尾;结果流锁取得后不能再锁另一任务。终态前校验全部历史任务的双向归属、未结束尝试和未释放许可,UNKNOWN不得终结。迁移000021恢复旧停止意图,不添加外键。 - 人工核查使用显式workflows:recover、当前实例revision、步骤/任务版本及激活身份,必须有原因和证据;执行结论、步骤结论、解除核查分别确认。新决定拒绝PENDING/QUARANTINED Inbox,原键回执仍核验权限。核查回执/额度/双审计/结果流同事务,结果流锁最后取得。 -- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK已接入,流程任务来源已接入,完整历史仍需补齐,不能据此宣称P5完成。 +- TASK人工重试保留首次绝对截止和精确输入,以新激活/关联任务继续消耗原max_attempts;激活数也受该上限限制,不能无限制造零尝试任务。成功步骤、未知执行、原停止意图或预算耗尽均不得重试。Continue不解除paused、重置预算或撤销停止。POLL跨激活恢复已实现(docs/17-workflow-poll-recovery.md),等待恢复已接入内部服务,公共HTTP/SDK已接入,流程任务来源已接入,实例列表、统一事件时间线范围核对与更多场景仍需补齐,不能据此宣称P5完成。 - POLL人工恢复以新激活重查同一业务轮,FirstRound/FirstRoundAttemptsUsed保留原轮次和已用尝试;实际尝试行、retry_of链和冻结参数必须相符。同轮任务数也受原max_attempts限制,PENDING不得人工重查绕过轮次上限。查询截止按首次激活、请求截止按该轮首任务,不允许重计时;下一业务轮才获得完整单轮策略。 -- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK已接入,完整历史与生产验收继续推进。 +- POLL恢复的最终信号等待保留原契约/关联/截止并更换代次,旧winner和已消费信号只留历史;存在当前代次PENDING信号时拒绝重建。迁移000022无外键,新增2个中文注释字段。公共HTTP/SDK已接入,有界历史查询已接入,实例列表、事件时间线范围核对与生产验收继续推进。 - 核查将UNKNOWN请求确认成成功PENDING时,当前RetryStep仍拒绝重查同轮;ContinuePoll显式恢复原等待,必须核验当前SUCCEEDED/PENDING任务、已处理Inbox及不可变来源。新审计固定来源事件/版本、原间隔与截止,时钟/最终信号核验证明后才推进;不改写旧Inbox处置,ReviewTask不自动解除核查。 - RearmWait仅重建独立SIGNAL/TIMER,显式wait_verified及最新步骤/激活/实例版本,连续激活上限32。旧WAITING激活/等待关闭,新代次保留首次输入、契约、关联及截止;PENDING信号、已有赢家、停止意图及过期均拒绝。TIMER运行和成功核查按首次激活计时,恢复后仍须Continue;无新持久化字段或外键。 -- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。Pulse流程来源已交付;完整历史、实例列表与信号审计列表仍未交付。 +- 公共流程API包括实例启动/读取、最多128项步骤元数据、单步当前输入输出/预算/等待、控制、信号、六类恢复和流程资源操作审计。当前激活代次同时进入Steps、详情和新恢复回执;旧冻结回执缺字段时只能另读当前详情,不改写历史回执。Pulse流程来源已交付;历史激活/轮次/等待/信号及信号审计已接入;实例列表、统一状态事件时间线范围核对与更多独立示例仍未完成。 - 恢复请求的匿名嵌入结构按扁平精确JSON字段解析,继承字段也拒绝别名/重复/未知字段,保留显式null例外与必填确认。bootstrap先验证权限集合;工作流执行/控制/信号/恢复/审计分别显式授予,旧workflow-access仅定义发布。 - 任务来源WORKFLOW只返回已授权任务的固定流程/版本/步骤/历史激活及POLL轮次;tasks:read不授予完整流程和其他队列权限。核对正反向关联、固定定义及门控,恢复父任务须为前一激活且POLL同轮。TASK恢复的retry_of位于workflow对象内,后续POLL新轮无父任务。不能改指当前激活、递归展开历史或将来源查询加入派发路径;无外键。 + +- 历史读取按固定实例及所有队列授权,明确步骤/激活/信号归属,不依赖外键;摘要分页20/100,游标绑定种类和父资源。指定激活详情的Step是当前视图、Activation是历史视图,不自动改代次;POLL轮次限定单个激活,恢复同轮任务不可与下一业务轮混淆。信号列表保留未核验旧身份,正文单条读取;信号审计用显式workflows:audit:read,不能按全局审计ID越权。000023只增加分页索引;全量回归必须保留零外键与旧数据/索引断言。 diff --git a/README.md b/README.md index 11caece..e9b3c29 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Orbit 是面向多个业务项目的通用任务调度与流程编排平台, 已完成 P0–P3 平台功能阶段验收:35 个数据模型、版本化迁移、凭据及范围校验、队列与任务 API、WSS 执行、独立 SDK、自动 Worker 和 [执行尝试/证据查询](docs/api/execution-history.md)。[Worker Schema](docs/api/worker-schema.md) 与真实进程强杀恢复回归已通过;[安全重试](docs/api/retries.md)、[定时计划](docs/api/schedules.md)、[运行中取消](docs/api/cancellation.md) 、[队列资源控制](docs/api/queues.md) 、[应用共享配额](docs/api/application-resources.md) 、[资源键互斥](docs/api/keyed-concurrency.md) 和 [优先级/节点公平派发](docs/api/priority-and-fairness.md) 已接入服务端和 SDK,[可靠结果订阅](docs/api/subscriptions.md) 已实现持久化核心、HTTP/WSS、管理 SDK 与示例,[消费 SDK](docs/sdk/event-consumer.md) 和 [独立 MySQL Inbox 示例](docs/examples/result-inbox.md) 已实现;[批量任务受理](docs/api/batch-tasks.md) 已提供逐项幂等、失败分类与 SDK 示例;[人工核查与重试](docs/api/task-recovery.md) 已提供带证据的管理接口、关联新任务、审计查询及运行示例;[业务键与时间范围查询](docs/api/task-queries.md) 已提供过滤、分页与SDK场景;[任务来源查询](docs/api/task-source.md) 已提供人工重试父任务、计划触发记录与固定版本;[业务事务Outbox](docs/examples/task-outbox.md) 已提供独立业务库、稳定提交身份、发送恢复与CLI;[相对延迟执行](docs/examples/delayed-task.md)已提供数据库计时及原请求恢复;完整范围核对见[P3验收](docs/reports/phase-3.md),流程引擎与生产验证按 [持续开发计划](docs/08-development-plan.md) 继续实现。 -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)提供完整调用链;实例筛选分页、历史激活/轮次/信号列表及更多独立恢复示例继续交付;[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,P4/P5与生产验收尚未整体完成。 +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)提供完整调用链;[历史激活/轮次/信号及信号审计](docs/api/workflow-history.md)已接入;实例筛选分页和更多独立恢复示例继续交付;[任务流程来源](docs/api/task-source.md#5-流程固定来源)已接入,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)。 diff --git a/docs/07-data-model.md b/docs/07-data-model.md index fa0da9d..c543321 100644 --- a/docs/07-data-model.md +++ b/docs/07-data-model.md @@ -226,3 +226,7 @@ PollState新增FirstRound和FirstRoundAttemptsUsed,记录当前激活的首个 ## 流程任务直接来源 [WORKFLOW来源](api/task-source.md#5-流程固定来源)复用实例固定版本、步骤、激活、查询轮次及Task.RetryOf;不增加迁移、表、持久化字段或外键。响应模型和独立SDK新增字段均有中文注释,仍为35个持久化实体/305个直接字段声明。沿已有任务唯一索引和步骤代次唯一键核验正反向归属,只返回直接父任务;人工恢复不改写旧任务的历史激活来源。 + +## 流程历史查询索引 + +000023_workflow_signal_history只为weave_signals增加(namespace_id,workflow_id,accepted_at,id)分页索引,保留全部原信号数据和状态。仍为35个持久化实体/305个直接字段声明,无新增字段或外键;HTTP/SDK的摘要和详情字段均有中文注释。[历史查询](api/workflow-history.md)明确区分当前逻辑步骤与指定历史激活,不以当前代次替换旧来源。基础测试禁止000008之后再次引入数据库引用约束,真实MySQL升级及readiness验证外键为0。 diff --git a/docs/08-development-plan.md b/docs/08-development-plan.md index c139b91..46beeeb 100644 --- a/docs/08-development-plan.md +++ b/docs/08-development-plan.md @@ -28,30 +28,30 @@ | P1 任务受理闭环 | 应用凭据与作用域权限、队列、幂等提交、查询、待执行取消、受理事件;SDK 客户端 | API/SDK 创建查询闭环;重复键与内容冲突、响应未确认重报、跨命名空间拒绝、参数边界 | 已完成并通过阶段回归(见 P1 报告;真实断路/提交 ACK 丢失注入留后续故障阶段) | | P2 Worker 执行闭环 | WSS 接入、能力/容量、领取与开始、租约、续租、结果确认、排空与恢复;Worker SDK | 真 Worker 执行;失联/重连/重复确认/旧尝试拒绝;业务效果未知转核查;节点故障接管 | 已完成并通过阶段回归:服务端、自动 SDK、历史查询、完整 Schema 与真实进程强杀恢复;测试拓扑和剩余生产故障矩阵见 P2 验收报告 | | P3 Pulse 完整能力 | 安全重试、延时、Cron/固定间隔、漏跑策略、计划版本、运行中取消、并发许可、配额/背压、可靠结果订阅、批量受理、独立任务核查/人工重试、查询筛选与事务Outbox接入 | 多实例领取/触发竞争、重试上限、取消竞争、队列隔离、积压恢复、事件重复交付 | 已完成平台功能阶段验收:[原范围逐项核对](reports/phase-3.md)、[本轮完整回归](reports/phase-3-delay-recovery.md)。补齐相对延迟、改参数关联示例和四种重试全停恢复场景;20个可运行示例全部通过。全平台生产门槛及SDK首版交付剩余项仍按P6–P7推进 | -| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;流程任务来源已接入;完整历史与更多场景继续开发 | -| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;完整历史与更多场景继续开发 | +| P4 Weave 主流程 | SDK 定义构建与发布校验、实例、顺序/分支/结构化并行汇合、信号/定时等待、版本绑定 | 重启接续、早到/重复信号、并行竞争、内部事件重放、发布版本兼容;端到端通用流程 | 定义格式/词法类型校验、不可变发布、独立SDK及六类节点定义例子已实现,已通过[子项验收](reports/phase-4-definitions.md);已补[有限决策器及内部结果交付](reports/phase-4-engine-core.md);[实例受理及首批事务](reports/phase-4-admission.md)已实现;[结果消费和根接续](reports/phase-4-runtime.md)已接入后台,顺序/分支/并行及真实进程恢复已验证;[TIMER与后续POLL轮次](13-workflow-clock.md)已接入;[信号消费和最终查询竞争](14-workflow-signals.md)已接入内部运行器;[内部控制与停止结清](15-workflow-controls.md)已接入;[内部核查与TASK新激活](16-workflow-recovery.md)已接入;等待恢复已接入内部服务;实例公共API/SDK与基础可运行场景已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例列表、统一事件时间线范围核对与更多场景继续开发 | +| P5 Weave 恢复与查询 | 有界查询、暂停继续、取消、人工核查恢复、控制门控与审计 | 轮次与尝试独立预算;暂停/开始/回执/超时竞争;恢复保留预算;未知结果不盲目重复 | 有界查询轮次/尝试独立预算及原截止恢复已接入内部运行器;最终信号竞争及在途查询结清已接入;内部暂停/继续/取消与失败结清已接入;执行核查、步骤结论、解除核查及TASK新激活已接入;[POLL跨激活恢复](17-workflow-poll-recovery.md)已接入;等待恢复已接入内部服务;公共API/SDK已接入;流程任务来源已接入;历史激活/轮次/信号与信号审计已接入;实例列表、统一事件时间线范围核对与更多场景继续开发 | | P6 生产验证与运维 | 完整监控、凭据轮换、备份恢复、滚动升级、运行手册、可复现负载和故障工具 | 对照生产验收清单执行,报告环境/指标/遗留项;容量、RPO/RTO 按真实拓扑验证 | 待开发 | | P7 首版交付核对 | SDK 兼容与发布准备、完整 API 手册、场景示例、全量回归及交付清单 | 新接入者按文档跑通全部示例;所有首版能力可追溯到实现与测试;报告实际生产边界 | 待开发 | SDK、文档与示例随 P1–P5 同步开发,P7 是全量核对,不是最后才开始补文档。 -P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源已接入,完整历史仍须完成。 +P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告](reports/phase-4-clock.md)。外部信号与最终查询竞争见[信号报告](reports/phase-4-signals.md)。公共实例、控制、信号、六类恢复和操作审计已接入HTTP与独立SDK,见[公共接入报告](reports/phase-5-public-api.md)。流程任务来源及[历史查询](api/workflow-history.md)已接入,实例列表、统一事件时间线及更多恢复示例仍须核对交付。 ### P4/P5 下一步实施顺序 1. 已实现FAIL_AFTER_SETTLED与显式取消的内部分支结清,见[控制报告](reports/phase-5-controls.md):持久取消意图、复用Pulse取消、收集已发出查询/任务结果、等待全部已激活工作结清后才进入失败或取消终态。效果未知仍保留核查。 2. 已实现实例暂停/继续/取消内部操作,以control_revision、操作幂等身份、明确原因及审计保护控制决定;暂停不延长原截止,继续不覆盖核查或取消状态。 3. 已实现内部执行核查、步骤结论、解除核查及TASK新激活和旧激活隔离,见[核查报告](reports/phase-5-recovery.md)。POLL跨激活恢复已实现(见[查询恢复](17-workflow-poll-recovery.md));[核查后PENDING接续和独立等待重建](18-workflow-wait-recovery.md)也已接入内部服务,保留原预算、轮次、结果和证据。 -4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。继续补齐实例筛选分页、完整激活/轮次/信号历史及信号资源审计,并提供独立轮询及恢复示例;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。 +4. 实例/当前步骤/信号/控制/六类恢复及流程操作审计HTTP和独立SDK已接入,步骤查询返回实际激活代次;并行执行与信号回执场景已有可运行示例。激活/轮次/信号历史及信号资源审计已接入;继续补齐实例筛选分页、核对统一状态事件时间线,并提供独立轮询及恢复示例;[Pulse任务流程来源](api/task-source.md#5-流程固定来源)已交付。逐项验证原键恢复、权限、版本冲突、竞争与进程全停。 5. 转入P6/P7的生产故障、容量、运维、发布与全部文档核对。内部存储阶段通过不会提前勾选公共接口或生产验收。 -公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源也已接入。以下完整历史、分页和更多独立场景继续交付: +公共接入按以下调用链推进;本批已接入启动/当前详情、控制、信号、六类恢复和流程操作审计,并提供独立SDK及基础示例。任务流程来源和有界历史查询也已接入。以下实例分页、统一事件时间线范围核对和更多独立场景继续交付: | 调用链 | 需要补齐的内容 | 验收重点 | | --- | --- | --- | -| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;后续补筛选分页和单次有界历史读取 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 | +| 启动与读取 | 显式固定版本启动、当前实例、步骤及当前激活代次;已补有界历史读取,后续补筛选分页 | 原键丢回执恢复、当前权限、读取快照一致性、未激活步骤语义 | | 控制与信号 | 暂停/继续/取消、固定步骤和代次的信号提交及原受理回执 | control_revision与通用revision区分、迟到/旧代次、暂停期间结果收集 | -| 核查与追踪 | 六类人工恢复操作、完整激活/轮次/等待/信号历史读取及信号管理审计;Pulse任务流程来源已交付 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 | +| 核查与追踪 | 六类恢复、激活/轮次/等待/信号历史读取及信号管理审计已接入;统一状态事件时间线待核对;Pulse任务流程来源已交付 | 当前实例/步骤/任务版本、证据和显式声明、旧历史保留、有界响应和权限隔离 | | 通用接入场景 | 并行汇合、外部回执、有限轮询、暂停核查恢复的可运行SDK示例 | 真实Worker执行、完整进程退出后接续、未知效果保留、全链路回归 | 接口只使用已经落地的内部事务规则;SDK保持独立module,不能引用服务端模型或数据库实现。生产故障及容量结论仍由P6专门验收。 @@ -130,6 +130,8 @@ P4/P5定时等待与后续查询轮次的实现及回归记录见[时钟报告]( ## 7. 文档入口 +- [流程历史查询与SDK](api/workflow-history.md) +- [历史查询验证记录](reports/phase-5-history.md) - [流程实例公共API](api/workflows.md) - [流程恢复API及SDK](api/workflow-recovery.md) - [并行作业与回执示例](examples/workflow.md) diff --git a/docs/README.md b/docs/README.md index 51020c7..7649950 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ | [11 Weave实例受理](11-workflow-admission.md) | 固定版本、初始图和首批任务事务 | | [12 Weave结果消费与推进](12-workflow-runtime.md) | 后台消费、根接续、事务与进程恢复,及尚未完成的等待/控制边界 | | [13 Weave定时等待与查询轮次](13-workflow-clock.md) | 原截止、轮次/尝试预算、时钟检查点、升级与全停恢复 | +| [流程历史查询](api/workflow-history.md) | 指定历史激活、查询轮次、信号摘要与信号资源审计 | | [接口手册](api/README.md) | 实际接口状态、调用说明与后续接口规划 | | [SDK 与场景](sdk/README.md) | 可运行示例、接入方法及分阶段 SDK 交付 | diff --git a/docs/api/README.md b/docs/api/README.md index 6ee6401..eb9c113 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -28,6 +28,7 @@ | `/v1/events/connect` | 已实现 WSS、消息 Schema、连接 SDK 和自动消费器 | [消息协议](subscriptions.md#5-websocket-建连与边界) / [消费 SDK](../sdk/event-consumer.md) | | `/v1/workflow-definitions` 创建、发布与固定版本读取 | P4定义子项已实现,实例另用启动接口 | [定义接口](workflow-definitions.md)、[格式规范](workflow-definition-format.md) | | `/v1/workflows`、步骤详情、控制、信号及操作审计 | 已接入HTTP与独立SDK | [实例接口](workflows.md) | +| 历史激活/轮次/信号列表、历史激活详情及信号资源审计 | 已接入有界分页HTTP与独立SDK | [历史查询](workflow-history.md) | | `/v1/workflows/{id}/recovery/*` 六类人工恢复 | 已接入有证据恢复及独立SDK | [恢复接口](workflow-recovery.md) | 未实现路由返回 404。健康检查就绪不表示任务执行、定时调度或流程引擎已经可用。 diff --git a/docs/api/workflow-history.md b/docs/api/workflow-history.md new file mode 100644 index 0000000..e0e7adf --- /dev/null +++ b/docs/api/workflow-history.md @@ -0,0 +1,164 @@ +# 流程激活、查询轮次、信号与信号审计 + +状态:HTTP与独立Go SDK已接入;本页查询明确指定的历史激活,不跟随逻辑步骤的当前激活。实例筛选分页、统一状态事件时间线及更多独立恢复场景仍按[开发计划](../08-development-plan.md)核对与交付。测试与范围见[本批报告](../reports/phase-5-history.md)。 + +## 1. 路由与调用规则 + +所有接口为GET,成功200,使用`Authorization: Bearer `,不需要请求体或幂等键。`{id}`是流程实例ID,`{activation}`是激活ID,`{signal}`是信号ID,`{operation}`是审计记录ID;均为32位小写十六进制。`{key}`是固定定义中的步骤键,1–64个ASCII字符,首位字母或数字,之后允许字母、数字、`_ . -`,禁止内部`$root`。操作审计的operation_id不是查询路径中的审计ID。 + +| 路径 | 权限 | SDK方法 | +| --- | --- | --- | +| `/v1/workflows/{id}/steps/{key}/activations` | workflows:read | ListWorkflowActivations(ctx, id, key, page) | +| `/v1/workflows/{id}/steps/{key}/activations/{activation}` | workflows:read | GetWorkflowActivation(ctx, id, key, activation) | +| `/v1/workflows/{id}/steps/{key}/activations/{activation}/rounds` | workflows:read | ListWorkflowPollRounds(ctx, id, key, activation, page) | +| `/v1/workflows/{id}/signals` | workflows:read | ListWorkflowSignals(ctx, id, page) | +| `/v1/workflows/{id}/signals/{signal}/operations` | workflows:audit:read | ListWorkflowSignalOperations(ctx, id, signal, page) | +| `/v1/workflows/{id}/signals/{signal}/operations/{operation}` | workflows:audit:read | GetWorkflowSignalOperation(ctx, id, signal, operation) | + +服务端先校验命名空间和固定流程引用的全部队列,再读取子资源。普通流程读取权限不包含审计权限;bootstrap用`--workflow-audit-access`显式签发审计权限。知道激活、任务、信号或审计ID不能绕过父资源权限。 + +四个列表只接受可选`limit`、`cursor`。limit为十进制整数1–100,省略默认20;SDK的`HistoryPageRequest.Limit=0`表示省略。cursor最多1024字节,按URL查询参数编码,必须来自相同查询种类、实例以及对应步骤/激活/信号。未知、重复或非法参数返回400;详情不接受查询参数。 + +每页最多读取limit+1条判断后续,空列表返回`items: []`,没有下一页时省略next_cursor。游标不是授权凭据;改权限后继续分页仍重新授权。翻页之间不是固定数据库快照,状态可能变化、新记录可能产生;要看最新记录需重新从第一页开始,不能把游标用于执行或确认操作。 + +## 2. 激活列表和单次详情 + +列表按sequence倒序,不聚合input、output、查询预算或等待正文。返回类型是[WorkflowActivationPage](../../sdk/go/workflow_history_types.go): + +| 字段 | 类型与含义 | +| --- | --- | +| instance_id、step_id | 字符串,已验证父实例与逻辑步骤 | +| revision | 十进制字符串,本页读取时的实例版本;SDK为uint64 | +| current_activation_id | 可选字符串,本页读取时的当前激活;尚未激活时省略 | +| items | 激活摘要数组,未激活步骤为[] | +| next_cursor | 可选字符串,后续位置 | + +每项含id、step_id、sequence(正uint32)、state、revision(uint64字符串)、created_at、updated_at;task_id仅用于TASK直接任务,completed_at仅已保存结束时间时返回。时间均为UTC RFC3339。state反映这条激活当前保存的状态,不代表整个实例状态;核查可能改变记录状态,列表不是每次状态变化的事件流水。 + +下面是恢复后的第二页,当前激活已经是第2代,本页仍读取第1代: + +```json +{ + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data": { + "instance_id": "11111111111111111111111111111111", + "revision": "12", + "step_id": "22222222222222222222222222222222", + "current_activation_id": "44444444444444444444444444444444", + "items": [{ + "id": "33333333333333333333333333333333", + "step_id": "22222222222222222222222222222222", + "sequence": 1, + "state": "FAILED", + "revision": "3", + "task_id": "55555555555555555555555555555555", + "created_at": "2026-09-18T08:00:00Z", + "updated_at": "2026-09-18T08:00:01Z", + "completed_at": "2026-09-18T08:00:01Z" + }] + } +} +``` + +详情的data为`{instance_id, revision, step, activation, input, output?, poll?, wait?}`: + +- `step`是[逻辑步骤当前视图](workflows.md#3-步骤及当前激活),`activation`是明确请求的历史激活摘要,两者的激活ID和代次允许不同。 +- `input/output`只属于指定激活。缺失output与显式null不同,精确JSON数字由SDK的json.RawMessage保留;不会展开其他激活大正文。 +- POLL的`poll`返回这一激活的first_round、first_round_attempts_used、used_rounds、冻结上限、原截止和最后任务。字段与[当前查询详情](workflows.md#3-步骤及当前激活)一致,范围改为指定激活。 +- SIGNAL、TIMER和有最终信号的POLL返回该激活的wait,包含原等待ID、原截止、状态和已有赢家。恢复后旧等待仍可读,新激活不能延长原截止或替换旧winner。 +- TASK尝试、未知结果和迟到证据通过task_id继续查询[执行历史](execution-history.md)。人工决定的前后摘要通过[流程操作审计](workflows.md#6-操作审计与错误处理)读取。 + +## 3. POLL轮次列表 + +data为`{instance_id, revision, activation_id, items, next_cursor?}`,items按业务轮round倒序。每项为`{id, activation_id, round, task_id, created_at}`;round是正uint32,其他身份为字符串。 + +必须明确指定一个POLL激活;TASK或其他节点调用返回400。人工恢复同一业务轮会新建激活和任务,因此“第1代的业务第1轮”和“第2代的业务第1轮”可以同时存在。先遍历激活,再遍历每个激活的轮次,不能把激活序号、业务轮次和Pulse尝试epoch混为一谈。 + +```json +{ + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data": { + "instance_id": "11111111111111111111111111111111", + "revision": "20", + "activation_id": "44444444444444444444444444444444", + "items": [{ + "id": "66666666666666666666666666666666", + "activation_id": "44444444444444444444444444444444", + "round": 1, + "task_id": "77777777777777777777777777777777", + "created_at": "2026-09-18T08:00:02Z" + }] + } +} +``` + +列表显式验证每轮任务属于该命名空间、激活、固定队列和门控。关联损坏不返回部分成功,也不利用外键推断归属。GetTaskSource可以反向核对[固定来源](task-source.md#5-流程固定来源)。 + +## 4. 信号与信号资源审计 + +信号列表data为`{workflow_id, revision, items, next_cursor?}`,按accepted_at和id倒序。每项含id、workflow_id、revision、name、payload_version、target_step_key、target_activation_sequence、state、accepted_at、expires_at,有处置原因时增加disposition。列表不携带payload、correlation_key、actor_id或evidence_ref;完整原信号使用[GetWorkflowSignal](workflows.md)读取。 + +早期未核验历史可能payload_version为空、target_activation_sequence为0,列表保留这些事实,不补造可执行身份。CONSUMED表示该信号已消费,单条详情的consumed_wait_id与指定历史激活的wait.winner_signal_id可以交叉核对。 + +信号操作列表data为`{items, next_cursor?}`,按created_at和id倒序,每项含id、workflow_id、signal_id、operation_id、actor_id、action、created_at。当前受理动作action为`workflow.signal`。操作详情的data直接是该摘要,再增加reason、可选evidence_ref、before和after: + +```json +{ + "request_id": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "data": { + "id": "88888888888888888888888888888888", + "workflow_id": "11111111111111111111111111111111", + "signal_id": "99999999999999999999999999999999", + "operation_id": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "actor_id": "cccccccccccccccccccccccccccccccc", + "action": "workflow.signal", + "created_at": "2026-09-18T08:00:02Z", + "reason": "verified external completion", + "evidence_ref": "proof:event-1", + "before": null, + "after": {"signal_id": "99999999999999999999999999999999", "state": "PENDING"} + } +} +``` + +上例after省略其他受理身份字段,完整内容以原操作保存的JSON为准。after是原受理审计,之后信号已消费也不能把其中PENDING改成CONSUMED。此接口只查`workflow_signal`资源,流程推进/控制/恢复操作在流程审计中。普通信号消费状态通过Get/List查看,不能将本接口解释成所有内部状态事件的统一时间线。 + +## 5. SDK调用场景 + +以下代码接在已配置client与ctx、已知workflowID及stepKey的调用方中;page使用SDK类型,与服务端模型无依赖: + +```go +cursor := "" +for { + page, err := client.ListWorkflowActivations(ctx, workflowID, stepKey, + orbit.HistoryPageRequest{Limit: 20, Cursor: cursor}) + if err != nil { return err } + for _, activation := range page.Items { + detail, err := client.GetWorkflowActivation(ctx, workflowID, stepKey, activation.ID) + if err != nil { return err } + fmt.Printf("activation=%s sequence=%d state=%s\n", + detail.Activation.ID, detail.Activation.Sequence, detail.Activation.State) + // 按需读取单次结果;禁止拿当前step的代次冒充历史激活代次。 + } + if page.NextCursor == "" { break } + cursor = page.NextCursor +} +``` + +POLL继续用ListWorkflowPollRounds(ctx, workflowID, stepKey, activation.ID, page)分页获取该次激活的任务,再按任务查询尝试和证据。信号场景用ListWorkflowSignals定位旧代次和当前代次的信号;具备审计权限后用ListWorkflowSignalOperations和GetWorkflowSignalOperation读取原因与原受理证据。 + +[可运行并行作业示例](../examples/workflow.md)会核对已完成等待的历史赢家与CONSUMED信号。[SDK真实链路测试](../../sdk/go/workflow_instances_integration_test.go)另外覆盖TASK恢复、SIGNAL换代、POLL同轮恢复及下一业务轮分页;入口为`make integration`。调用方法见[SDK源码](../../sdk/go/workflow_history.go),各字段中文注释见[独立类型](../../sdk/go/workflow_history_types.go)。 + +## 6. 错误、部署与兼容 + +| HTTP / 错误码 | 含义与处理 | +| --- | --- | +| 400 INVALID_ARGUMENT | 身份、步骤键、分页参数或游标范围错误;或非POLL读取轮次。修正查询,不能丢掉筛选继续 | +| 401 UNAUTHENTICATED | 凭据无效或过期,按[认证协议](authentication.md)处理 | +| 403 PERMISSION_DENIED | 缺少读取或显式审计权限 | +| 404 NOT_FOUND | 父/子资源不属于该作用域、不存在或队列不可访问;不跨资源查找 | +| 503 UNAVAILABLE | 关联损坏或数据库暂不可用;保留资源身份并检查服务记录,不能将空列表当作成功 | + +所有查询只读,不重算截止、重置预算、消耗轮次或触发恢复。SDK拒绝非法路径/分页及响应父资源不匹配;不自动创建新身份。读操作网络失败可以按原资源重读,状态可能已经前进。 + +服务端需先应用`000023_workflow_signal_history`,只增加(namespace_id,workflow_id,accepted_at,id)索引;原数据、迁移历史、模型字段和零外键要求保持。维护窗口和DDL锁需按目标数据库演练。当前SDK仍为开发版,使用与服务端匹配的开发提交;P4/P5整体和生产验收尚未完成。 diff --git a/docs/api/workflows.md b/docs/api/workflows.md index 2466727..117a4de 100644 --- a/docs/api/workflows.md +++ b/docs/api/workflows.md @@ -2,7 +2,7 @@ 状态:下表公共 HTTP 与独立 Go SDK 已接入。定义发布见[定义接口](workflow-definitions.md),人工恢复见[恢复接口](workflow-recovery.md),完整运行例子见[并行作业与回执](../examples/workflow.md)。SDK 尚未发布正式版本,服务端与 SDK 应使用同一开发版本。 -实例筛选分页及历史激活/轮次/信号列表继续开发;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。 +实例筛选分页继续开发;[历史激活、轮次、信号和信号资源审计](workflow-history.md)已接入;[Pulse任务流程来源](task-source.md#5-流程固定来源)已接入。旧冻结回执可能不含新增的activation_sequence,应另读当前步骤详情;当前步骤详情只反映当前激活;旧记录保留在数据库,不能把本页的当前视图当作全部历史。 ## 1. 路由、权限与 SDK @@ -155,7 +155,7 @@ name/payload_version/target_step_key必填,分别最多128/32/64个ASCII名称 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读取,信号资源审计列表及完整激活历史继续开发。 +该接口当前查询resource_kind=workflow的启动、推进、控制和恢复审计;独立任务核查审计通过恢复回执的task_audit_id关联。信号自身的受理状态通过GetWorkflowSignal读取;[信号资源审计及历史激活查询](workflow-history.md)使用独立路由,统一状态事件时间线仍待核对交付。 | HTTP/错误码 | 处理方式 | | --- | --- | diff --git a/docs/examples/README.md b/docs/examples/README.md index b7e3ca9..9a2d682 100644 --- a/docs/examples/README.md +++ b/docs/examples/README.md @@ -185,5 +185,5 @@ - [来源接口与SDK](../api/task-source.md)随现有tasks、schedules、recovery和workflow四个示例运行,无额外服务或数据库表。 - 步骤:取得任务ID → GetTaskSource → 区分普通提交/人工重试/计划触发 → 按权限读取父任务或固定计划版本。 - 预期:source_kind分别为DIRECT、MANUAL_RETRY、SCHEDULE;人工重试关联原任务,计划任务保留实际触发版本。 -- workflow示例逐项核对三个任务所属流程、固定版本、逻辑步骤及创建激活,输出workflow_sources_verified=3;恢复旧激活和POLL轮次由SDK集成测试覆盖。 +- workflow示例逐项核对三个任务所属流程、固定版本、逻辑步骤及创建激活,输出workflow_sources_verified=3;示例同时查询历史等待赢家与已消费信号;恢复旧激活、原等待截止和POLL轮次分页由SDK集成测试覆盖。 - 异常:来源缺失或归属不匹配返回503,不能降级为DIRECT;新版本不覆盖历史版本,引用查询不授予完整计划或流程读取权限。 diff --git a/docs/examples/workflow.md b/docs/examples/workflow.md index 8906f81..761eea0 100644 --- a/docs/examples/workflow.md +++ b/docs/examples/workflow.md @@ -38,6 +38,7 @@ CreateWorkflowDefinition → PublishWorkflowVersion(v1) → StartWorkflow → ```text workflow=<32位实例ID> state=SUCCEEDED result={"text":"HELLO ORBIT"} start_deduplicated=true signal_deduplicated=true workflow_sources_verified=3 fixed_version=v1 +workflow_history_verified=true signal_state=CONSUMED worker_drained=true started=3 confirmed=3 ``` @@ -55,4 +56,4 @@ worker_drained=true started=3 confirmed=3 [公共SDK集成测试](../../sdk/go/workflow_instances_integration_test.go)还实际运行:启动响应丢失的原键恢复;暂停/继续、定时等待、信号消费和取消;UNKNOWN执行核查、步骤结论及解除核查;剩余尝试内TASK重试与SIGNAL换代;确认PENDING后沿原查询预算继续。使用`make integration`在一次性环境复现。 -历史激活列表、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。 +历史激活和信号列表已接入,示例核对指定历史等待的winner与信号CONSUMED状态。实例筛选分页、更多独立业务示例及生产验收仍按[计划](../08-development-plan.md)推进。 diff --git a/docs/reports/phase-5-history.md b/docs/reports/phase-5-history.md new file mode 100644 index 0000000..0e1a4f7 --- /dev/null +++ b/docs/reports/phase-5-history.md @@ -0,0 +1,34 @@ +# P4/P5 流程历史查询回归 + +日期:2026-09-18。本批交付指定历史激活、POLL轮次、信号摘要及信号资源操作审计的HTTP与独立SDK;不将这些列表等同于统一状态事件时间线,P4/P5仍保留实例筛选分页和独立恢复场景等剩余项。 + +## 实现与验证边界 + +- 六条只读路由:激活列表/详情、单激活的查询轮次列表、信号列表、信号操作审计列表/详情。所有父资源按命名空间与固定流程全部队列授权,审计使用显式workflows:audit:read。 +- 列表默认20、最多100,游标绑定查询类型及父资源,数据库limit+1判断下一页;不聚合输入/结果/信号正文。单条详情保留精确JSON,区分当前逻辑步骤和明确请求的历史激活。 +- 人工恢复后的旧TASK、POLL和SIGNAL/TIMER等待可读,原截止、轮次与尝试预算不重算;POLL同轮恢复跨激活,后续新业务轮仍单独编号。旧等待赢家须对应原消费信号和原代次。 +- 信号列表保留早期未核验身份,不补造契约或激活序号。信号受理审计的原PENDING快照与后来CONSUMED状态分开。没有新增状态推进、恢复授权或业务副作用。 +- 迁移000023只添加信号历史分页索引,无新模型字段、表或外键。历史迁移不回改;基础测试禁止000008之后重新引入引用约束,readiness继续拒绝实际数据库中的外键。 + +## 验证记录 + +环境:darwin/arm64、Go 1.27.1、Docker MySQL 8.4.10;一次性隔离数据库、真实Orbit服务与Worker协议,SDK不引用服务端代码。 + +| 验证 | 实际结果 | +| --- | --- | +| 基础检查 | make fmt/test/vet/build通过,覆盖主模块、独立SDK及两个业务模块 | +| 存储专项 | 17.546秒通过;包括五组历史场景、v22索引升级、历史70外键移除及无外键应用层归属校验 | +| HTTP与SDK专项 | 包24.637秒通过;五组实例场景23.23秒,覆盖六条新增历史路由及原有六类恢复 | +| 可运行示例 | 流程SUCCEEDED,workflow_sources_verified=3、workflow_history_verified=true、signal_state=CONSUMED,三个任务确认并排空 | +| 全量隔离回归 | scripts/test-mysql.sh退出0;存储223.344秒、流程进程恢复49.047秒、流程SDK23.868秒(五组实例22.52秒)、最终进程故障组95.473秒;20个SDK示例及2个独立业务模块全部通过,一次性环境已清理 | +| 模型与文档检查 | 305个直接持久化字段和122个新增协议字段均有中文注释;实际MySQL为v23,外键0、业务字段缺COMMENT为0;1045个本地链接、65段JSON及git diff --check通过 | + +存储测试覆盖TASK第2代→第1代分页、原输入/缺失输出、未激活空页、不同步骤/种类游标拒绝、不存在历史身份、TASK误读POLL轮次拒绝、旧任务所有者漂移后不返回部分页;POLL恢复第1轮→第2轮分页与旧预算;SIGNAL/TIMER第1代等待保留原截止;同一受理时刻信号按ID稳定排序、200 KiB正文不进入摘要、旧NULL契约保留、信号资源与操作审计隔离。真实SDK覆盖历史等待winner、恢复前后激活、同轮恢复与下一轮的任务身份,以及原信号审计原因。SDK单元检查非法路径/分页不发送请求及响应父资源不匹配。 + +v22→当前迁移逐字段比较信号、精确数字、旧身份与时间,检查新增索引及重复迁移;v7→当前验证70外键全部移除、323个既有索引组成项保留、原任务不变,手工注入违规外键会被CheckSchema拒绝。初次历史专项发现两处测试断言不匹配既有契约(缺失output不能作为JSON比较、权限错误码为PERMISSION_DENIED),修正断言后重跑通过。首轮全量发现v9额度升级测试的人工降级夹具未移除本轮新增索引,导致再次升级时报重复索引;已补齐夹具的结构还原,不改正式迁移。最终全量结果在上表记录。 + +源码:[历史存储](../../internal/storage/mysql/workflow_history.go)、[信号历史与审计](../../internal/storage/mysql/workflow_signal_history.go)、[专项回归](../../internal/storage/mysql/workflow_history_integration_test.go)、[索引升级](../../internal/storage/mysql/workflow_history_upgrade_integration_test.go)、[零外键升级](../../internal/storage/mysql/no_foreign_keys_integration_test.go)、[迁移约束检查](../../internal/storage/mysql/no_foreign_keys_test.go)、[HTTP路由](../../internal/transport/httpserver/workflow_history.go)、[独立SDK](../../sdk/go/workflow_history.go)、[真实SDK链路](../../sdk/go/workflow_instances_integration_test.go)。完整复现入口为`make integration`,见[脚本](../../scripts/test-mysql.sh)。 + +## 继续交付 + +实例筛选分页、统一状态事件时间线的原范围核对,以及独立POLL、核查和暂停恢复示例继续推进;再逐项核对P4/P5原功能与异常矩阵,开展P6/P7监控、凭据轮换、运维、容量及生产故障验收。本地回归没有证明生产复制、跨故障域部署、目标容量或实际RPO/RTO;列表跨页也不提供固定数据库快照。 diff --git a/docs/sdk/README.md b/docs/sdk/README.md index 9b85a76..f3e1f65 100644 --- a/docs/sdk/README.md +++ b/docs/sdk/README.md @@ -6,6 +6,8 @@ module:`git.yuntuo.hk/k2safe/orbit/sdk/go`。SDK 使用独立 go.mod,依赖 任务相对延迟使用SubmitTaskRequest.DelayMS,由数据库首次受理时间计算,原键重报不重新计时;[完整延迟示例](../examples/delayed-task.md)包含Worker执行和历史核对。非零DelayMS不能与AvailableAt同用,旧服务须先升级。 +[流程历史查询](../api/workflow-history.md)已提供激活/轮次/信号有界分页、历史等待与预算详情及显式权限的信号操作审计;当前视图与指定历史激活分开。 + ## 1. 从初始化到运行示例 需要 Go 1.27.1、MySQL 8.4+。在仓库根目录,通过环境设置专用 ORBIT_MYSQL_DSN: diff --git a/internal/storage/mysql/application_resources_upgrade_integration_test.go b/internal/storage/mysql/application_resources_upgrade_integration_test.go index 9610775..f6cacb1 100644 --- a/internal/storage/mysql/application_resources_upgrade_integration_test.go +++ b/internal/storage/mysql/application_resources_upgrade_integration_test.go @@ -63,7 +63,8 @@ func TestIntegrationApplicationResourcesUpgrade(t *testing.T) { finished := retryStart(t, g) retryFinish(t, g, finished, "SUCCEEDED", false) assertApplicationCounts(t, f, 4, 2, 1) - // 还原后续新增表和字段,得到真实 v9 结构;现有任务内容保持不变。 + // 还原后续新增表、字段和索引,得到真实 v9 结构;现有任务内容保持不变。 + f.exec(`ALTER TABLE weave_signals DROP INDEX ix_signal_history`) f.exec(`ALTER TABLE weave_poll_states DROP CHECK ck_poll_recovery_budget, DROP COLUMN first_round, DROP COLUMN first_round_attempts_used`) f.exec(`ALTER TABLE pulse_tasks DROP INDEX ix_task_workflow_owner`) f.exec(`ALTER TABLE weave_instances DROP CHECK ck_workflow_stop_intent, DROP COLUMN stop_kind, DROP COLUMN stop_requested_at`) diff --git a/internal/storage/mysql/migrations/000023_workflow_signal_history.up.sql b/internal/storage/mysql/migrations/000023_workflow_signal_history.up.sql new file mode 100644 index 0000000..8ddd5fb --- /dev/null +++ b/internal/storage/mysql/migrations/000023_workflow_signal_history.up.sql @@ -0,0 +1,3 @@ +-- 信号按实例及原受理时间分页;不改变历史内容、截止、幂等或消费状态,不增加外键。 +ALTER TABLE weave_signals + ADD KEY ix_signal_history (namespace_id,workflow_id,accepted_at,id); diff --git a/internal/storage/mysql/no_foreign_keys_integration_test.go b/internal/storage/mysql/no_foreign_keys_integration_test.go index 8447c1f..acef890 100644 --- a/internal/storage/mysql/no_foreign_keys_integration_test.go +++ b/internal/storage/mysql/no_foreign_keys_integration_test.go @@ -89,7 +89,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) { indexes := func() []string { t.Helper() // v15替换retry_of索引、v17增加内部结果唯一索引、v20增加信号索引,下方单独验证定义。 - rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`) + rows, err := db.QueryContext(ctx, `SELECT CONCAT_WS(':',TABLE_NAME,INDEX_NAME,NON_UNIQUE,SEQ_IN_INDEX,COLUMN_NAME,COALESCE(SUB_PART,0),INDEX_TYPE,IS_VISIBLE) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME NOT IN ('orbit_application_resources','orbit_event_streams','orbit_result_events') AND INDEX_NAME NOT IN ('ix_task_priority_dispatch','ix_delivery_subscription_due','ix_delivery_subscription_lease','ix_delivery_history','ix_delivery_state_history','uq_task_retry_parent','fk_pulse_tasks_3','ix_task_queue_business_created','ix_task_queue_business_state_created','uq_weave_task_result','ix_signal_expiry','ix_signal_activation','uq_wait_winner_signal','ix_task_workflow_owner','ix_signal_history') ORDER BY TABLE_NAME,INDEX_NAME,SEQ_IN_INDEX`) if err != nil { t.Fatal(err) } @@ -180,6 +180,7 @@ func TestIntegrationNoForeignKeysUpgrade(t *testing.T) { var collation string for _, index := range []struct{ table, name, want string }{ {"pulse_tasks", "ix_task_workflow_owner", "namespace_id:1,owner_kind:1,owner_id:1,gate_id:1,id:1"}, + {"weave_signals", "ix_signal_history", "namespace_id:1,workflow_id:1,accepted_at:1,id:1"}, {"weave_signals", "ix_signal_expiry", "state:1,available_at:1,namespace_id:1,id:1"}, {"weave_signals", "ix_signal_activation", "namespace_id:1,workflow_id:1,target_step_key:1,target_activation_sequence:1,state:1,accepted_at:1,id:1"}, {"weave_waits", "uq_wait_winner_signal", "namespace_id:0,winner_signal_id:0"}, diff --git a/internal/storage/mysql/no_foreign_keys_test.go b/internal/storage/mysql/no_foreign_keys_test.go new file mode 100644 index 0000000..3717fcb --- /dev/null +++ b/internal/storage/mysql/no_foreign_keys_test.go @@ -0,0 +1,29 @@ +package mysql + +import ( + "regexp" + "testing" +) + +// 已应用的历史文件不改写;移除迁移之后的DDL不能再引入数据库引用约束。 +func TestMigrationsAfterForeignKeyRemovalDoNotReintroduceReferences(t *testing.T) { + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + forbidden := regexp.MustCompile(`(?i)\b(FOREIGN\s+KEY|REFERENCES)\b`) + for _, m := range migrations { + if m.Version <= 8 { + continue + } + statements, err := splitSQL(m.SQL) + if err != nil { + t.Fatal(err) + } + for _, statement := range statements { + if forbidden.MatchString(statement) { + t.Errorf("migration %d introduces forbidden reference DDL", m.Version) + } + } + } +} diff --git a/internal/storage/mysql/task_history.go b/internal/storage/mysql/task_history.go index c145106..e78ba96 100644 --- a/internal/storage/mysql/task_history.go +++ b/internal/storage/mysql/task_history.go @@ -61,8 +61,8 @@ func decodeHistoryCursorKind(value string, ns, task, attempt core.ID, kind strin if d.Decode(&c) != nil || c.Version != 1 || c.Namespace != ns || c.Task != task || c.Attempt != attempt { return c, fault.Invalid("cursor does not match this history query") } - if kind == "attempts" { - if c.Kind != "attempts" || c.Epoch == 0 || c.ID != "" || c.Created != nil { + if kind == "attempts" || kind == "workflow-activations" || kind == "workflow-rounds" { + if c.Kind != kind || c.Epoch == 0 || kind != "attempts" && c.Epoch > 1<<32-1 || c.ID != "" || c.Created != nil { return c, fault.Invalid("invalid attempts cursor") } } else { diff --git a/internal/storage/mysql/workflow_details.go b/internal/storage/mysql/workflow_details.go index 7932ccb..a35d664 100644 --- a/internal/storage/mysql/workflow_details.go +++ b/internal/storage/mysql/workflow_details.go @@ -8,6 +8,7 @@ import ( "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/definition" "git.yuntuo.hk/k2safe/orbit/weave/engine" "git.yuntuo.hk/k2safe/orbit/weave/instance" ) @@ -37,43 +38,51 @@ func (s *WorkflowInstanceStore) Step(ctx context.Context, t auth.Token, id core. 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 + out.Poll, out.Wait, err = readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, gate, key, string(out.Step.Kind), *a, out.Step.ActivationSequence, version) + return err }) return out, err } + +// readWorkflowActivationAttachments 查询指定激活的等待与预算,不替换为当前激活。 +func readWorkflowActivationAttachments(ctx context.Context, tx *sql.Tx, ns, workflow, gate core.ID, key, kind string, activation core.ID, sequence uint32, version definition.VersionView) (*instance.PollView, *instance.WaitView, error) { + var poll *instance.PollView + var wait *instance.WaitView + program, err := engine.Compile(version.Specification) + if err != nil { + return nil, nil, 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, ns, workflow, activation).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 nil, nil, 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 nil, nil, errors.New("workflow poll detail ownership or policy invalid") + } + 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=?`, ns, workflow, activation).Scan(&w.ID, &w.Kind, &w.State, &w.Name, &w.PayloadVersion, &w.CorrelationKey, &w.DeadlineAt, &w.WinnerSignalID, &w.ResolvedAt); err != nil { + return nil, nil, err + } + if w.Kind != kind { + return nil, nil, 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'`, ns, workflow, w.WinnerSignalID, w.ID, key, sequence).Scan(&n); err != nil { + return nil, nil, err + } + if n != 1 { + return nil, nil, errors.New("workflow wait winner association invalid") + } + } + wait = w + } + return poll, wait, nil +} diff --git a/internal/storage/mysql/workflow_history.go b/internal/storage/mysql/workflow_history.go new file mode 100644 index 0000000..363cf4d --- /dev/null +++ b/internal/storage/mysql/workflow_history.go @@ -0,0 +1,206 @@ +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/definition" + "git.yuntuo.hk/k2safe/orbit/weave/engine" + "git.yuntuo.hk/k2safe/orbit/weave/instance" +) + +type workflowHistoryParent struct { + view instance.View + gate core.ID + version definition.VersionView + step instance.StepView + node definition.Step +} + +func readWorkflowHistoryParent(ctx context.Context, tx *sql.Tx, ns, id core.ID, key string, p auth.Permissions) (workflowHistoryParent, error) { + var h workflowHistoryParent + var err error + h.view, h.gate, h.version, err = readWorkflowForQuery(ctx, tx, ns, id, p) + if err != nil { + return h, err + } + steps, err := readWorkflowStepViews(ctx, tx, ns, id, h.gate, h.version, key) + if err != nil { + return h, err + } + if len(steps) != 1 { + return h, fault.NotFound + } + h.step = steps[0] + program, err := engine.Compile(h.version.Specification) + if err != nil { + return h, err + } + h.node, _ = program.Step(key) + return h, nil +} + +const activationHistorySelect = `SELECT a.id,a.step_id,a.sequence,a.state,a.revision,a.task_id,a.created_at,a.updated_at,a.completed_at,t.id,t.queue_id FROM weave_activations a LEFT JOIN pulse_tasks t ON t.namespace_id=a.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 a.namespace_id=? AND a.workflow_id=? AND a.step_id=?` + +func scanWorkflowActivation(row scanner, h workflowHistoryParent) (instance.ActivationSummary, error) { + var a instance.ActivationSummary + var task, queue *core.ID + err := row.Scan(&a.ID, &a.StepID, &a.Sequence, &a.State, &a.Revision, &a.TaskID, &a.CreatedAt, &a.UpdatedAt, &a.CompletedAt, &task, &queue) + if err != nil { + return a, err + } + if a.StepID != h.step.ID || a.Sequence == 0 || (h.node.Task != nil) != (a.TaskID != nil) { + return a, fault.Unavailable + } + if a.TaskID != nil && (task == nil || queue == nil || *task != *a.TaskID || *queue != h.node.Task.QueueID) { + return a, fault.Unavailable + } + return a, nil +} +func readHistoryActivation(ctx context.Context, tx *sql.Tx, h workflowHistoryParent, activation core.ID) (instance.ActivationSummary, error) { + a, err := scanWorkflowActivation(tx.QueryRowContext(ctx, activationHistorySelect+` AND a.id=?`, h.gate, h.view.NamespaceID, h.view.ID, h.step.ID, activation), h) + if errors.Is(err, sql.ErrNoRows) { + return a, fault.NotFound + } + return a, err +} + +func (s *WorkflowInstanceStore) ListActivations(ctx context.Context, t auth.Token, id core.ID, key string, in service.HistoryPageInput) (instance.ActivationPage, error) { + out := instance.ActivationPage{InstanceID: id, Items: []instance.ActivationSummary{}} + err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p) + if err != nil { + return err + } + out.Revision, out.StepID, out.CurrentActivationID = h.view.Revision, h.step.ID, h.step.ActivationID + q := activationHistorySelect + args := []any{h.gate, t.NamespaceID, id, h.step.ID} + if in.Cursor != "" { + c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, h.step.ID, "workflow-activations") + if err != nil { + return err + } + q += ` AND a.sequence in.Limit { + out.Items = out.Items[:in.Limit] + last := out.Items[len(out.Items)-1] + out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-activations", Namespace: t.NamespaceID, Task: id, Attempt: h.step.ID, Epoch: uint64(last.Sequence)}) + } + return err + }) + if err != nil { + return instance.ActivationPage{}, err + } + return out, nil +} +func (s *WorkflowInstanceStore) GetActivation(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID) (instance.ActivationDetail, error) { + out := instance.ActivationDetail{InstanceID: id} + err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p) + if err != nil { + return err + } + out.Revision, out.Step = h.view.Revision, h.step + out.Activation, err = readHistoryActivation(ctx, tx, h, activation) + if err != nil { + return err + } + var input, output []byte + if err = tx.QueryRowContext(ctx, `SELECT input,output FROM weave_activations WHERE namespace_id=? AND workflow_id=? AND step_id=? AND id=?`, t.NamespaceID, id, h.step.ID, activation).Scan(&input, &output); err != nil { + return err + } + out.Input, out.Output = input, output + out.Poll, out.Wait, err = readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, h.gate, key, string(h.step.Kind), activation, out.Activation.Sequence, h.version) + return err + }) + if err != nil { + return instance.ActivationDetail{}, err + } + return out, nil +} +func (s *WorkflowInstanceStore) ListPollRounds(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID, in service.HistoryPageInput) (instance.PollRoundPage, error) { + out := instance.PollRoundPage{InstanceID: id, ActivationID: activation, Items: []instance.PollRoundSummary{}} + err := s.transaction(ctx, t, instance.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + h, err := readWorkflowHistoryParent(ctx, tx, t.NamespaceID, id, key, p) + if err != nil { + return err + } + a, err := readHistoryActivation(ctx, tx, h, activation) + if err != nil { + return err + } + if h.node.Poll == nil { + return fault.Invalid("round history requires a POLL activation") + } + out.Revision = h.view.Revision + poll, _, err := readWorkflowActivationAttachments(ctx, tx, t.NamespaceID, id, h.gate, key, string(h.step.Kind), activation, a.Sequence, h.version) + if err != nil { + return err + } + q := "SELECT r.id,r.activation_id,r.`round`,r.task_id,r.created_at,t.id,t.queue_id FROM weave_poll_rounds r 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 r.namespace_id=? AND r.workflow_id=? AND r.activation_id=?" + args := []any{h.gate, t.NamespaceID, id, activation} + if in.Cursor != "" { + c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, id, activation, "workflow-rounds") + if err != nil { + return err + } + q += " AND r.`round` poll.UsedRounds { + return fault.Unavailable + } + out.Items = append(out.Items, round) + } + 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-rounds", Namespace: t.NamespaceID, Task: id, Attempt: activation, Epoch: uint64(last.Round)}) + } + return err + }) + if err != nil { + return instance.PollRoundPage{}, err + } + return out, nil +} diff --git a/internal/storage/mysql/workflow_history_integration_test.go b/internal/storage/mysql/workflow_history_integration_test.go new file mode 100644 index 0000000..3879be6 --- /dev/null +++ b/internal/storage/mysql/workflow_history_integration_test.go @@ -0,0 +1,251 @@ +package mysql + +import ( + "bytes" + "context" + "encoding/json" + "os" + "strings" + "testing" + "time" + + "git.yuntuo.hk/k2safe/orbit/internal/fault" + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/pulse/service" + "git.yuntuo.hk/k2safe/orbit/weave/instance" + "git.yuntuo.hk/k2safe/orbit/weave/recovery" +) + +func TestIntegrationWorkflowHistory(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*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) + } + api := instance.New(NewWorkflowInstanceStore(db)) + t.Run("task_generations_input_pagination_and_no_foreign_key_corruption", func(t *testing.T) { + f := newExecutionFixture(t, ctx, db) + spec := runtimeSpec(f.resources.QueueID, 2) + spec.Body.Steps[0].Task.Retry.MaxAttempts = 2 + spec.Body.Steps[0].Task.Retry.InitialDelayMS = 1000 + spec.Body.Steps[0].Task.Retry.MaxDelayMS = 1000 + v := runtimeStart(t, f, spec) + grantWorkflowRecovery(f) + empty, err := api.ListActivations(ctx, f.token, v.ID, "task1", service.HistoryPageInput{}) + if err != nil || empty.Items == nil || len(empty.Items) != 0 || empty.CurrentActivationID != nil { + t.Fatal("unactivated history", err) + } + ref := retryStart(t, f) + retryFinish(t, f, ref, "FAILED", false) + runtimeConsume(t, f, newRuntime(t, db), ref.TaskID) + old := recoveryStep(t, f, v.ID, "task0") + before, err := api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID) + if err != nil { + t.Fatal(err) + } + child, err := recovery.New(NewWorkflowRecoveryStore(db)).RetryStep(ctx, f.token, v.ID, string(executionID(t)), recovery.RetryStepInput{StepDecision: old, SafeToRetry: true}) + if err != nil { + t.Fatal(err) + } + page, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{Limit: 1}) + if err != nil || len(page.Items) != 1 || page.Items[0].Sequence != 2 || page.NextCursor == "" || *page.CurrentActivationID != *child.Step.ActivationID { + t.Fatal("latest page", err) + } + older, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{Limit: 1, Cursor: page.NextCursor}) + if err != nil || len(older.Items) != 1 || older.Items[0].ID != old.ActivationID || older.NextCursor != "" { + t.Fatal("historical page", err) + } + detail, err := api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID) + if err != nil || detail.Activation.ID != old.ActivationID || detail.Activation.Sequence != 1 || detail.Step.ActivationSequence != 2 || *detail.Activation.TaskID != ref.TaskID || !sameWorkflowJSON(detail.Input, before.Input) || !bytes.Equal(detail.Output, before.Output) { + t.Fatal("history replaced by current activation", err) + } + _, err = api.ListActivations(ctx, f.token, v.ID, "task1", service.HistoryPageInput{Cursor: page.NextCursor}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + _, err = api.GetActivation(ctx, f.token, v.ID, "task1", old.ActivationID) + expectWorkerCode(t, err, "NOT_FOUND") + _, err = api.GetActivation(ctx, f.token, v.ID, "task0", executionID(t)) + expectWorkerCode(t, err, "NOT_FOUND") + _, err = api.ListPollRounds(ctx, f.token, v.ID, "task0", old.ActivationID, service.HistoryPageInput{}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + for _, in := range []service.HistoryPageInput{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} { + _, err = api.ListActivations(ctx, f.token, v.ID, "task0", in) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + } + _, err = api.ListActivations(ctx, f.token, v.ID, "$root", service.HistoryPageInput{}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + // 无外键时模拟旧任务归属漂移:历史读取必须失败且不返回部分页。 + f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, ref.TaskID) + bad, err := api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{}) + if fault.Code(err) != "UNAVAILABLE" || bad.Items != nil { + t.Fatal("foreign historical task leaked", err) + } + if _, err = api.GetActivation(ctx, f.token, v.ID, "task0", old.ActivationID); fault.Code(err) != "UNAVAILABLE" { + t.Fatal("foreign activation accepted", err) + } + f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, old.ActivationID, f.resources.NamespaceID, ref.TaskID) + f.exec(`UPDATE orbit_credentials SET permissions=JSON_SET(permissions,'$.queues',JSON_ARRAY(?)) WHERE namespace_id=? AND id=?`, string(executionID(t)), f.resources.NamespaceID, f.resources.CredentialID) + _, err = api.ListActivations(ctx, f.token, v.ID, "task0", service.HistoryPageInput{}) + expectWorkerCode(t, err, "NOT_FOUND") + }) + t.Run("poll_same_round_recovery_then_two_round_pages", func(t *testing.T) { + f, v, oldTask := failedPoll(t, ctx, db, false) + old := recoveryStep(t, f, v.ID, "query") + before, err := api.GetActivation(ctx, f.token, v.ID, "query", old.ActivationID) + if err != nil { + t.Fatal(err) + } + child := retryPollStep(t, f, v.ID) + continueWorkflow(t, f, v.ID) + ref := retryStart(t, f) + finishPoll(t, f, ref, "PENDING") + c := clockCandidate(t, f, v.ID, "query") + waitClockDue(t, f, c) + runClock(t, f, c) + last := retryStart(t, f) + finishPoll(t, f, last, "DONE") + runtimeView(t, f, v.ID, "SUCCEEDED") + prior, err := api.ListPollRounds(ctx, f.token, v.ID, "query", old.ActivationID, service.HistoryPageInput{}) + if err != nil || len(prior.Items) != 1 || prior.Items[0].Round != 1 || prior.Items[0].TaskID != oldTask.TaskID { + t.Fatal("old round lost", err) + } + page, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{Limit: 1}) + if err != nil || len(page.Items) != 1 || page.Items[0].Round != 2 || page.Items[0].TaskID != last.TaskID || page.NextCursor == "" { + t.Fatal("latest round", err) + } + older, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{Limit: 1, Cursor: page.NextCursor}) + if err != nil || len(older.Items) != 1 || older.Items[0].Round != 1 || older.Items[0].TaskID != child.Task.ID || older.NextCursor != "" { + t.Fatal("recovered round", err) + } + _, err = api.ListPollRounds(ctx, f.token, v.ID, "query", old.ActivationID, service.HistoryPageInput{Cursor: page.NextCursor}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + _, err = api.ListActivations(ctx, f.token, v.ID, "query", service.HistoryPageInput{Cursor: page.NextCursor}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + after, err := api.GetActivation(ctx, f.token, v.ID, "query", old.ActivationID) + if err != nil || after.Poll.UsedRounds != 1 || after.Poll.DeadlineAt != before.Poll.DeadlineAt || after.Poll.LastTaskID != oldTask.TaskID || after.Activation.Sequence != 1 || after.Step.ActivationSequence != 2 { + t.Fatal("old poll replaced", err) + } + // 破坏非末轮的任务所有者,必须由轮次列表本身发现。 + f.exec(`UPDATE pulse_tasks SET owner_id=? WHERE namespace_id=? AND id=?`, executionID(t), f.resources.NamespaceID, child.Task.ID) + bad, err := api.ListPollRounds(ctx, f.token, v.ID, "query", *child.Step.ActivationID, service.HistoryPageInput{}) + if fault.Code(err) != "UNAVAILABLE" || bad.Items != nil { + t.Fatal("round ownership bypassed", err) + } + }) + for _, kind := range []string{"SIGNAL", "TIMER"} { + t.Run("historical_wait_"+kind, func(t *testing.T) { + f, v := waitRecoveryFixture(t, ctx, db, kind, 30000) + key := "signal" + if kind == "TIMER" { + key = "timer" + } + old, err := api.Step(ctx, f.token, v.ID, key) + if err != nil { + t.Fatal(err) + } + rearmWorkflowWait(t, f, v.ID, key) + detail, err := api.GetActivation(ctx, f.token, v.ID, key, *old.Step.ActivationID) + if err != nil || detail.Wait.ID != old.Wait.ID || detail.Wait.DeadlineAt != old.Wait.DeadlineAt || detail.Step.ActivationSequence != 2 || detail.Activation.Sequence != 1 || detail.Activation.State != "CANCELLED" { + t.Fatal("wait history lost", err) + } + }) + } + t.Run("signal_pages_legacy_summary_and_resource_audit", func(t *testing.T) { + f, v, signals := signalStart(t, ctx, db, true) + empty, err := signals.List(ctx, f.token, v.ID, service.HistoryPageInput{}) + if err != nil || empty.Items == nil || len(empty.Items) != 0 { + t.Fatal(err) + } + sent := make([]core.ID, 0, 3) + for i := 0; i < 3; i++ { + out, err := signals.Send(ctx, f.token, v.ID, string(executionID(t)), signalInput(v)) + if err != nil { + t.Fatal(err) + } + sent = append(sent, out.Signal.ID) + } + _, err = signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{}) + expectWorkerCode(t, err, "PERMISSION_DENIED") + 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) + audits, err := signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{Limit: 1}) + if err != nil || len(audits.Items) != 1 || audits.Items[0].Action != "workflow.signal" { + t.Fatal("signal audit list", err) + } + audit, err := signals.GetOperation(ctx, f.token, v.ID, sent[0], audits.Items[0].ID) + if err != nil || audit.SignalID != sent[0] || audit.WorkflowID != v.ID || audit.Reason != signalInput(v).Reason || audit.EvidenceRef != signalInput(v).EvidenceRef { + t.Fatal("signal audit detail", err) + } + _, err = signals.GetOperation(ctx, f.token, v.ID, sent[1], audit.ID) + expectWorkerCode(t, err, "NOT_FOUND") + // 构造相同受理时间、历史未核验身份和大正文,验证有界摘要及稳定二级排序;不将夹具作为合法信号送入运行器。 + f.exec(`UPDATE weave_signals SET accepted_at='2026-01-01 00:00:00.000000',state='REJECTED',disposition='LEGACY_UNVERIFIED',payload_version=NULL,target_activation_sequence=NULL,payload=? WHERE namespace_id=? AND workflow_id=?`, `"`+strings.Repeat("p", 200<<10)+`"`, f.resources.NamespaceID, v.ID) + seen := map[core.ID]bool{} + cursor := "" + last := core.ID("") + for { + page, err := signals.List(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 1, Cursor: cursor}) + if err != nil || len(page.Items) != 1 { + t.Fatal("signal page", err) + } + raw, _ := json.Marshal(page) + item := page.Items[0] + if len(raw) > 2048 || seen[item.ID] || last != "" && item.ID >= last || item.TargetActivationSequence != 0 || item.PayloadVersion != "" { + t.Fatal("signal summary lost bounds or identity") + } + seen[item.ID] = true + last = item.ID + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + } + if len(seen) != 3 { + t.Fatal("signal omitted") + } + _, err = signals.ListOperations(ctx, f.token, v.ID, sent[0], service.HistoryPageInput{Cursor: cursor}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + other := runtimeStart(t, f, signalSpec(f.resources.QueueID, true)) + grantWorkflowSignal(f) + _, err = signals.List(ctx, f.token, other.ID, service.HistoryPageInput{Cursor: cursor}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + _, err = signals.List(ctx, f.token, v.ID, service.HistoryPageInput{Limit: 101}) + expectWorkerCode(t, err, "INVALID_ARGUMENT") + _, err = signals.ListOperations(ctx, f.token, v.ID, executionID(t), service.HistoryPageInput{}) + expectWorkerCode(t, err, "PERMISSION_DENIED") // runtimeStart重签普通权限,审计权限不能随读取继承。 + + 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) + _, err = signals.ListOperations(ctx, f.token, other.ID, sent[0], service.HistoryPageInput{}) + expectWorkerCode(t, err, "NOT_FOUND") + _, err = signals.GetOperation(ctx, f.token, other.ID, sent[0], audit.ID) + expectWorkerCode(t, err, "NOT_FOUND") + + }) +} + +func TestWorkflowHistoryCursorScopeAndWidth(t *testing.T) { + ns, id, step := executionID(t), executionID(t), executionID(t) + for _, kind := range []string{"workflow-activations", "workflow-rounds", "attempts"} { + cursor := historyCursor{Version: 1, Kind: kind, Namespace: ns, Task: id, Attempt: step, Epoch: 1<<32 - 1} + raw, _ := encodeHistoryCursor(cursor) + if _, err := decodeHistoryCursorKind(raw, ns, id, step, kind); err != nil { + t.Fatal(err) + } + if _, err := decodeHistoryCursorKind(raw, ns, id, executionID(t), kind); err == nil { + t.Fatal("cursor crossed child") + } + cursor.Epoch = 1 << 32 + raw, _ = encodeHistoryCursor(cursor) + _, err := decodeHistoryCursorKind(raw, ns, id, step, kind) + if (err == nil) != (kind == "attempts") { + t.Fatal("sequence width or legacy epoch changed", kind, err) + } + } +} diff --git a/internal/storage/mysql/workflow_history_upgrade_integration_test.go b/internal/storage/mysql/workflow_history_upgrade_integration_test.go new file mode 100644 index 0000000..85c0638 --- /dev/null +++ b/internal/storage/mysql/workflow_history_upgrade_integration_test.go @@ -0,0 +1,121 @@ +package mysql + +import ( + "context" + "database/sql" + "os" + "reflect" + "testing" + "time" + + driver "github.com/go-sql-driver/mysql" +) + +// 用真实v22结构验证历史索引升级,逐字段核对信号未被改写。 +func TestIntegrationWorkflowHistoryUpgrade(t *testing.T) { + dsn := os.Getenv("ORBIT_TEST_MYSQL_DSN") + if dsn == "" { + t.Skip("requires disposable MySQL") + } + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + admin, err := Open(ctx, dsn, 2, 1) + if err != nil { + t.Fatal(err) + } + defer admin.Close() + name := "orbit_history_upgrade_" + string(executionID(t)) + if _, err := admin.ExecContext(ctx, "CREATE DATABASE `"+name+"`"); err != nil { + t.Fatal(err) + } + defer func() { + cleanup, stop := context.WithTimeout(context.Background(), 10*time.Second) + defer stop() + if _, err := admin.ExecContext(cleanup, "DROP DATABASE `"+name+"`"); err != nil { + t.Error(err) + } + }() + cfg, err := driver.ParseDSN(dsn) + if err != nil { + t.Fatal("invalid test DSN") + } + cfg.DBName = name + db, err := Open(ctx, cfg.FormatDSN(), 4, 2) + if err != nil { + t.Fatal(err) + } + defer db.Close() + exec := func(q string, args ...any) { + t.Helper() + if _, err := db.ExecContext(ctx, q, args...); err != nil { + t.Fatal(err) + } + } + exec(`CREATE TABLE orbit_schema_migrations (version BIGINT UNSIGNED PRIMARY KEY,name VARCHAR(191) NOT NULL,checksum CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,dirty BOOLEAN NOT NULL,applied_at DATETIME(6) NULL) ENGINE=InnoDB`) + migrations, err := Migrations() + if err != nil { + t.Fatal(err) + } + for _, m := range migrations { + if m.Version > 22 { + break + } + statements, err := splitSQL(m.SQL) + if err != nil { + t.Fatal(err) + } + for _, q := range statements { + exec(q) + } + exec(`INSERT INTO orbit_schema_migrations(version,name,checksum,dirty,applied_at) VALUES (?,?,?,FALSE,UTC_TIMESTAMP(6))`, m.Version, m.Name, m.Checksum) + } + + ns, workflow, id := executionID(t), executionID(t), executionID(t) + exec(`INSERT INTO weave_signals(namespace_id,id,workflow_id,key_digest,request_digest,type,target_step_key,correlation_key,payload,actor_id,state,accepted_at,expires_at,available_at,disposition) VALUES (?,?,?,UNHEX(SHA2('k',256)),UNHEX(SHA2('r',256)),'external.done','signal','关联尾部 ','{"n":9007199254740993}','legacy','REJECTED',UTC_TIMESTAMP(6),DATE_ADD(UTC_TIMESTAMP(6),INTERVAL 1 HOUR),UTC_TIMESTAMP(6),'LEGACY_UNVERIFIED')`, ns, id, workflow) + snapshot := func() []sql.NullString { + t.Helper() + rows, err := db.QueryContext(ctx, `SELECT * FROM weave_signals WHERE namespace_id=? AND id=?`, ns, id) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + cols, err := rows.Columns() + if err != nil { + t.Fatal(err) + } + if !rows.Next() { + t.Fatal("signal missing") + } + out := make([]sql.NullString, len(cols)) + args := make([]any, len(cols)) + for i := range out { + args[i] = &out[i] + } + if err = rows.Scan(args...); err != nil { + t.Fatal(err) + } + return out + } + before := snapshot() + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(before, snapshot()) { + t.Fatal("history index changed signal fields") + } + if err = CheckSchema(ctx, db); err != nil { + t.Fatal(err) + } + var index string + if err = db.QueryRowContext(ctx, `SELECT GROUP_CONCAT(COLUMN_NAME ORDER BY SEQ_IN_INDEX) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME='weave_signals' AND INDEX_NAME='ix_signal_history' AND NON_UNIQUE=1`).Scan(&index); err != nil || index != "namespace_id,workflow_id,accepted_at,id" { + t.Fatal("history index", index, err) + } + var fks int + if err = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM information_schema.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA=DATABASE()`).Scan(&fks); err != nil || fks != 0 { + t.Fatal("foreign keys", fks, err) + } + if err = Migrate(ctx, db); err != nil { + t.Fatal(err) + } + t.Log("v22 upgraded; signal values, precision, legacy identity and timestamps unchanged; history index installed; foreign keys=0") +} diff --git a/internal/storage/mysql/workflow_signal_history.go b/internal/storage/mysql/workflow_signal_history.go new file mode 100644 index 0000000..c8244f3 --- /dev/null +++ b/internal/storage/mysql/workflow_signal_history.go @@ -0,0 +1,141 @@ +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/signal" +) + +func (s *WorkflowSignalStore) List(ctx context.Context, t auth.Token, workflow core.ID, in service.HistoryPageInput) (signal.Page, error) { + out := signal.Page{WorkflowID: workflow, Items: []signal.Summary{}} + err := s.transaction(ctx, t, signal.ReadAction, func(tx *sql.Tx, p auth.Permissions) error { + v, _, _, err := readWorkflowForQuery(ctx, tx, t.NamespaceID, workflow, p) + if err != nil { + return err + } + out.Revision = v.Revision + q := `SELECT id,workflow_id,revision,type,COALESCE(payload_version,''),target_step_key,COALESCE(target_activation_sequence,0),state,COALESCE(disposition,''),accepted_at,expires_at FROM weave_signals WHERE namespace_id=? AND workflow_id=?` + args := []any{t.NamespaceID, workflow} + if in.Cursor != "" { + c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, workflow, "", "workflow-signals") + if err != nil { + return err + } + q += ` AND (accepted_at in.Limit { + out.Items = out.Items[:in.Limit] + last := out.Items[len(out.Items)-1] + out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "workflow-signals", Namespace: t.NamespaceID, Task: workflow, Created: &last.AcceptedAt, ID: last.ID}) + } + return err + }) + if err != nil { + return signal.Page{}, err + } + return out, nil +} + +// authorizeSignalAudit 先授权固定流程全部队列,再验证信号属于该实例,不通过审计ID跨资源查找。 +func authorizeSignalAudit(ctx context.Context, tx *sql.Tx, ns, workflow, id core.ID, p auth.Permissions) error { + if _, _, _, err := readWorkflowForQuery(ctx, tx, ns, workflow, p); err != nil { + return err + } + var found core.ID + err := tx.QueryRowContext(ctx, `SELECT id FROM weave_signals WHERE namespace_id=? AND workflow_id=? AND id=?`, ns, workflow, id).Scan(&found) + if errors.Is(err, sql.ErrNoRows) { + return fault.NotFound + } + return err +} + +const signalOperationColumns = `id,resource_id,operation_id,actor_id,action,created_at` + +func (s *WorkflowSignalStore) ListOperations(ctx context.Context, t auth.Token, workflow, id core.ID, in service.HistoryPageInput) (signal.OperationPage, error) { + out := signal.OperationPage{Items: []signal.OperationSummary{}} + err := s.transaction(ctx, t, signal.AuditAction, func(tx *sql.Tx, p auth.Permissions) error { + if err := authorizeSignalAudit(ctx, tx, t.NamespaceID, workflow, id, p); err != nil { + return err + } + q := `SELECT ` + signalOperationColumns + ` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow_signal' AND resource_id=?` + args := []any{t.NamespaceID, id} + if in.Cursor != "" { + c, err := decodeHistoryCursorKind(in.Cursor, t.NamespaceID, workflow, id, "signal-operations") + if err != nil { + return err + } + q += ` AND (created_at in.Limit { + out.Items = out.Items[:in.Limit] + last := out.Items[len(out.Items)-1] + out.NextCursor, err = encodeHistoryCursor(historyCursor{Version: 1, Kind: "signal-operations", Namespace: t.NamespaceID, Task: workflow, Attempt: id, Created: &last.CreatedAt, ID: last.ID}) + } + return err + }) + if err != nil { + return signal.OperationPage{}, err + } + return out, nil +} +func (s *WorkflowSignalStore) GetOperation(ctx context.Context, t auth.Token, workflow, id, operation core.ID) (signal.OperationView, error) { + out := signal.OperationView{OperationSummary: signal.OperationSummary{WorkflowID: workflow}} + err := s.transaction(ctx, t, signal.AuditAction, func(tx *sql.Tx, p auth.Permissions) error { + if err := authorizeSignalAudit(ctx, tx, t.NamespaceID, workflow, id, p); err != nil { + return err + } + var before, after []byte + err := tx.QueryRowContext(ctx, `SELECT `+signalOperationColumns+",reason,COALESCE(evidence_ref,''),`before`,`after` FROM orbit_audit_events WHERE namespace_id=? AND resource_kind='workflow_signal' AND resource_id=? AND id=?", t.NamespaceID, id, operation).Scan(&out.ID, &out.SignalID, &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 + }) + if err != nil { + return signal.OperationView{}, err + } + return out, nil +} diff --git a/internal/transport/httpserver/workflow_history.go b/internal/transport/httpserver/workflow_history.go new file mode 100644 index 0000000..98dab99 --- /dev/null +++ b/internal/transport/httpserver/workflow_history.go @@ -0,0 +1,58 @@ +package httpserver + +import ( + core "git.yuntuo.hk/k2safe/orbit/model" + "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/weave/instance" + "git.yuntuo.hk/k2safe/orbit/weave/signal" + "net/http" +) + +func registerWorkflowHistory(mux *http.ServeMux, instances *instance.Service, signals *signal.Service) { + mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations", 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.ListActivations(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), in) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations/{activation}", 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.GetActivation(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), core.ID(r.PathValue("activation"))) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}/activations/{activation}/rounds", 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.ListPollRounds(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"), core.ID(r.PathValue("activation")), in) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/signals", 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 := signals.List(r.Context(), t, core.ID(r.PathValue("id")), in) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}/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 := signals.ListOperations(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal")), in) + return out, 200, err + })) + mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}/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 := signals.GetOperation(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal")), core.ID(r.PathValue("operation"))) + return out, 200, err + })) +} diff --git a/internal/transport/httpserver/workflows.go b/internal/transport/httpserver/workflows.go index 935b8d9..705c744 100644 --- a/internal/transport/httpserver/workflows.go +++ b/internal/transport/httpserver/workflows.go @@ -15,6 +15,7 @@ import ( // RegisterWorkflows 只适配协议,实际授权、并发版本、原键回执及审计由事务服务校验。 func RegisterWorkflows(server *http.Server, instances *instance.Service, controls *control.Service, signals *signal.Service, reviews *recovery.Service) { mux := server.Handler.(*http.ServeMux) + registerWorkflowHistory(mux, instances, signals) 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 diff --git a/sdk/go/examples/workflow/main.go b/sdk/go/examples/workflow/main.go index 80f0dbb..b89f78d 100644 --- a/sdk/go/examples/workflow/main.go +++ b/sdk/go/examples/workflow/main.go @@ -167,6 +167,29 @@ func run() error { return errors.New("incomplete workflow task sources") } fmt.Printf("workflow_sources_verified=%d fixed_version=%s\n", verified, accepted.Instance.Version) + history, err := client.ListWorkflowActivations(ctx, accepted.Instance.ID, "receipt", orbit.HistoryPageRequest{Limit: 20}) + if err != nil { + return err + } + if len(history.Items) != 1 { + return errors.New("unexpected receipt activation history") + } + historical, err := client.GetWorkflowActivation(ctx, accepted.Instance.ID, "receipt", history.Items[0].ID) + if err != nil { + return err + } + if historical.Wait == nil || historical.Wait.WinnerSignalID == nil || *historical.Wait.WinnerSignalID != sent.Signal.ID { + return errors.New("historical signal winner missing") + } + signals, err := client.ListWorkflowSignals(ctx, accepted.Instance.ID, orbit.HistoryPageRequest{Limit: 20}) + if err != nil { + return err + } + if len(signals.Items) != 1 || signals.Items[0].State != "CONSUMED" || signals.Items[0].ID != sent.Signal.ID { + return errors.New("signal history missing") + } + fmt.Println("workflow_history_verified=true signal_state=CONSUMED") + stop() err = <-done waited = true diff --git a/sdk/go/workflow_history.go b/sdk/go/workflow_history.go new file mode 100644 index 0000000..a2b8f26 --- /dev/null +++ b/sdk/go/workflow_history.go @@ -0,0 +1,173 @@ +package orbit + +import ( + "context" + "errors" +) + +func workflowActivationPath(id, key string, activation ...string) (string, error) { + if !validID(id) || !workflowVersionName(key) { + return "", errors.New("invalid workflow step identity") + } + path := "/v1/workflows/" + id + "/steps/" + key + "/activations" + for _, v := range activation { + if !validID(v) { + return "", errors.New("invalid activation id") + } + path += "/" + v + } + return path, nil +} +func workflowSignalPath(id string, signal ...string) (string, error) { + if !validID(id) { + return "", errors.New("invalid workflow id") + } + path := "/v1/workflows/" + id + "/signals" + for _, v := range signal { + if !validID(v) { + return "", errors.New("invalid signal id") + } + path += "/" + v + } + return path, nil +} +func historyResponseSize(n int, in HistoryPageRequest) bool { + limit := in.Limit + if limit == 0 { + limit = 20 + } + return n <= limit +} + +// ListWorkflowActivations 返回指定逻辑步骤的各次激活摘要,代次倒序;跨页状态可继续变化。 +func (c *Client) ListWorkflowActivations(ctx context.Context, id, key string, in HistoryPageRequest) (WorkflowActivationPage, error) { + var out WorkflowActivationPage + path, err := workflowActivationPath(id, key) + if err != nil { + return out, err + } + query, err := historyPageQuery(in) + if err != nil { + return out, err + } + if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil { + return WorkflowActivationPage{}, err + } + if out.InstanceID != id || !validID(out.StepID) || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) { + return WorkflowActivationPage{}, errors.New("invalid activation page") + } + for i, a := range out.Items { + if !validID(a.ID) || a.StepID != out.StepID || a.Sequence == 0 || a.Revision == 0 || i > 0 && a.Sequence >= out.Items[i-1].Sequence { + return WorkflowActivationPage{}, errors.New("invalid activation summary") + } + } + return out, nil +} + +// GetWorkflowActivation 明确读取给定历史激活,Step仍为逻辑步骤当前视图,不能用它替代历史Activation。 +func (c *Client) GetWorkflowActivation(ctx context.Context, id, key, activation string) (WorkflowActivationDetail, error) { + var out WorkflowActivationDetail + path, err := workflowActivationPath(id, key, activation) + if err != nil { + return out, err + } + if err = c.call(ctx, "GET", path, "", nil, &out); err != nil { + return WorkflowActivationDetail{}, err + } + if out.InstanceID != id || out.Step.Key != key || out.Activation.ID != activation || !validID(out.Step.ID) || out.Activation.StepID != out.Step.ID || out.Activation.Sequence == 0 || out.Revision == 0 { + return WorkflowActivationDetail{}, errors.New("activation detail identity mismatch") + } + return out, nil +} + +// ListWorkflowPollRounds 只读取一个激活内的查询轮次;人工恢复的同一业务轮可分属不同激活。 +func (c *Client) ListWorkflowPollRounds(ctx context.Context, id, key, activation string, in HistoryPageRequest) (WorkflowPollRoundPage, error) { + var out WorkflowPollRoundPage + path, err := workflowActivationPath(id, key, activation) + if err != nil { + return out, err + } + query, err := historyPageQuery(in) + if err != nil { + return out, err + } + if err = c.call(ctx, "GET", path+"/rounds"+query, "", nil, &out); err != nil { + return WorkflowPollRoundPage{}, err + } + if out.InstanceID != id || out.ActivationID != activation || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) { + return WorkflowPollRoundPage{}, errors.New("invalid poll round page") + } + for i, r := range out.Items { + if !validID(r.ID) || !validID(r.TaskID) || r.ActivationID != activation || r.Round == 0 || i > 0 && r.Round >= out.Items[i-1].Round { + return WorkflowPollRoundPage{}, errors.New("invalid poll round summary") + } + } + return out, nil +} + +// ListWorkflowSignals 不携带信号正文;单条原始内容用GetWorkflowSignal读取。 +func (c *Client) ListWorkflowSignals(ctx context.Context, id string, in HistoryPageRequest) (WorkflowSignalPage, error) { + var out WorkflowSignalPage + path, err := workflowSignalPath(id) + if err != nil { + return out, err + } + query, err := historyPageQuery(in) + if err != nil { + return out, err + } + if err = c.call(ctx, "GET", path+query, "", nil, &out); err != nil { + return WorkflowSignalPage{}, err + } + if out.WorkflowID != id || out.Revision == 0 || out.Items == nil || !historyResponseSize(len(out.Items), in) { + return WorkflowSignalPage{}, errors.New("invalid signal page") + } + for _, v := range out.Items { + if !validID(v.ID) || v.WorkflowID != id { + return WorkflowSignalPage{}, errors.New("signal summary identity mismatch") + } + } + return out, nil +} + +// ListWorkflowSignalOperations 需要独立workflows:audit:read权限,只返回信号资源的操作审计摘要。 +func (c *Client) ListWorkflowSignalOperations(ctx context.Context, id, signal string, in HistoryPageRequest) (WorkflowSignalOperationPage, error) { + var out WorkflowSignalOperationPage + path, err := workflowSignalPath(id, signal) + if err != nil { + return out, err + } + query, err := historyPageQuery(in) + if err != nil { + return out, err + } + if err = c.call(ctx, "GET", path+"/operations"+query, "", nil, &out); err != nil { + return WorkflowSignalOperationPage{}, err + } + if out.Items == nil || !historyResponseSize(len(out.Items), in) { + return WorkflowSignalOperationPage{}, errors.New("invalid signal operation page") + } + for _, v := range out.Items { + if !validID(v.ID) || v.WorkflowID != id || v.SignalID != signal { + return WorkflowSignalOperationPage{}, errors.New("signal operation identity mismatch") + } + } + return out, nil +} +func (c *Client) GetWorkflowSignalOperation(ctx context.Context, id, signal, operation string) (WorkflowSignalOperation, error) { + var out WorkflowSignalOperation + path, err := workflowSignalPath(id, signal) + if err != nil { + return out, err + } + if !validID(operation) { + return out, errors.New("invalid signal operation id") + } + if err = c.call(ctx, "GET", path+"/operations/"+operation, "", nil, &out); err != nil { + return WorkflowSignalOperation{}, err + } + if out.ID != operation || out.WorkflowID != id || out.SignalID != signal { + return WorkflowSignalOperation{}, errors.New("signal operation detail identity mismatch") + } + return out, nil +} diff --git a/sdk/go/workflow_history_test.go b/sdk/go/workflow_history_test.go new file mode 100644 index 0000000..cf35deb --- /dev/null +++ b/sdk/go/workflow_history_test.go @@ -0,0 +1,89 @@ +package orbit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +func TestWorkflowHistoryIdentityBoundsAndResponseValidation(t *testing.T) { + const id = "11111111111111111111111111111111" + const child = "22222222222222222222222222222222" + ctx := context.Background() + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + if r.Method != "GET" || r.Header.Get("Idempotency-Key") != "" || r.Header.Get("Authorization") != "Bearer test-token" { + t.Error("invalid read request") + } + if r.URL.Query().Get("cursor") != "opaque+cursor/=" || r.URL.Query().Get("limit") != "1" { + t.Error("query changed") + } + // 合法响应信封中的错误父资源,SDK不得将其作为请求资源的数据返回。 + json.NewEncoder(w).Encode(map[string]any{"request_id": id, "data": map[string]any{"instance_id": child, "workflow_id": child, "step_id": child, "revision": "1", "items": []any{}}}) + })) + defer server.Close() + c, err := NewClient(server.URL, Options{Token: "test-token"}) + if err != nil { + t.Fatal(err) + } + for _, bad := range []string{"../other", strings.Repeat("A", 32), "a?x=b"} { + if _, err = c.ListWorkflowActivations(ctx, bad, "work", HistoryPageRequest{}); err == nil { + t.Fatal("bad workflow accepted") + } + if _, err = c.GetWorkflowActivation(ctx, id, "work", bad); err == nil { + t.Fatal("bad activation accepted") + } + if _, err = c.ListWorkflowPollRounds(ctx, id, "work", bad, HistoryPageRequest{}); err == nil { + t.Fatal("bad round scope accepted") + } + if _, err = c.ListWorkflowSignals(ctx, bad, HistoryPageRequest{}); err == nil { + t.Fatal("bad signal parent accepted") + } + if _, err = c.ListWorkflowSignalOperations(ctx, id, bad, HistoryPageRequest{}); err == nil { + t.Fatal("bad signal accepted") + } + if _, err = c.GetWorkflowSignalOperation(ctx, id, child, bad); err == nil { + t.Fatal("bad operation accepted") + } + } + for _, key := range []string{"$root", "../other", "", strings.Repeat("x", 65)} { + if _, err = c.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{}); err == nil { + t.Fatal("bad key accepted") + } + } + for _, in := range []HistoryPageRequest{{Limit: -1}, {Limit: 101}, {Cursor: strings.Repeat("x", 1025)}} { + if _, err = c.ListWorkflowActivations(ctx, id, "work", in); err == nil { + t.Fatal("bad activation page") + } + if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, in); err == nil { + t.Fatal("bad round page") + } + if _, err = c.ListWorkflowSignals(ctx, id, in); err == nil { + t.Fatal("bad signal page") + } + if _, err = c.ListWorkflowSignalOperations(ctx, id, child, in); err == nil { + t.Fatal("bad operation page") + } + } + if calls.Load() != 0 { + t.Fatal("invalid arguments reached transport") + } + page := HistoryPageRequest{Limit: 1, Cursor: "opaque+cursor/="} + if _, err = c.ListWorkflowActivations(ctx, id, "work", page); err == nil { + t.Fatal("foreign activation page accepted") + } + if _, err = c.ListWorkflowPollRounds(ctx, id, "work", child, page); err == nil { + t.Fatal("foreign round page accepted") + } + if _, err = c.ListWorkflowSignals(ctx, id, page); err == nil { + t.Fatal("foreign signal page accepted") + } + if calls.Load() != 3 { + t.Fatal("unexpected automatic read retry") + } +} diff --git a/sdk/go/workflow_history_types.go b/sdk/go/workflow_history_types.go new file mode 100644 index 0000000..aeb431b --- /dev/null +++ b/sdk/go/workflow_history_types.go @@ -0,0 +1,92 @@ +package orbit + +import ( + "encoding/json" + "time" +) + +// WorkflowActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。 +type WorkflowActivationSummary struct { + ID string `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。 + StepID string `json:"step_id"` // StepID 本激活所属逻辑步骤。 + Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。 + State string `json:"state"` // State 此激活状态,不代表实例当前状态。 + Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。 + TaskID *string `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。 + UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。 + CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。 +} +type WorkflowActivationPage struct { + InstanceID string `json:"instance_id"` // InstanceID 已授权的所属实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。 + StepID string `json:"step_id"` // StepID 已核验的逻辑步骤。 + CurrentActivationID *string `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。 + Items []WorkflowActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。 +} +type WorkflowActivationDetail struct { + InstanceID string `json:"instance_id"` // InstanceID 所属实例。 + Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。 + Step WorkflowStepStatus `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。 + Activation WorkflowActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。 + Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。 + Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。 + Poll *WorkflowPollStatus `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。 + Wait *WorkflowWaitStatus `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。 +} +type WorkflowPollRoundSummary struct { + ID string `json:"id"` // ID 该任务唯一对应的轮次记录。 + ActivationID string `json:"activation_id"` // ActivationID 产生本次查询的激活。 + Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。 + TaskID string `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。 +} +type WorkflowPollRoundPage struct { + InstanceID string `json:"instance_id"` // InstanceID 所属实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。 + ActivationID string `json:"activation_id"` // ActivationID 明确指定的历史激活。 + Items []WorkflowPollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。 +} + +// WorkflowSignalSummary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。 +type WorkflowSignalSummary struct { + ID string `json:"id"` // ID 持久信号身份。 + 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 原指定代次,未核验旧数据可能为0。 + 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 原固定截止,不因读取或重报延长。 +} +type WorkflowSignalPage struct { + WorkflowID string `json:"workflow_id"` // WorkflowID 已授权实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。 + Items []WorkflowSignalSummary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。 +} +type WorkflowSignalOperationSummary struct { + ID string `json:"id"` // ID 不可改写的操作审计身份。 + WorkflowID string `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。 + SignalID string `json:"signal_id"` // SignalID 本次操作对应的信号资源。 + OperationID string `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。 + ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。 + Action string `json:"action"` // Action 操作种类,如workflow.signal。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。 +} +type WorkflowSignalOperation struct { + WorkflowSignalOperationSummary + Reason string `json:"reason"` // Reason 原操作原因。 + EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。 + Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。 + After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。 +} +type WorkflowSignalOperationPage struct { + Items []WorkflowSignalOperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。 +} diff --git a/sdk/go/workflow_instances_integration_test.go b/sdk/go/workflow_instances_integration_test.go index 309d19a..9320e2e 100644 --- a/sdk/go/workflow_instances_integration_test.go +++ b/sdk/go/workflow_instances_integration_test.go @@ -202,6 +202,13 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { t.Fatal("start receipt", err) } id := started.Instance.ID + + // 所有新增历史路由必须拒绝未声明参数,不能悄悄扩大读取范围。 + for _, suffix := range []string{"/steps/work/activations", "/steps/work/activations/" + id, "/steps/work/activations/" + id + "/rounds", "/signals", "/signals/" + id + "/operations", "/signals/" + id + "/operations/" + id} { + var unused any + err := api.call(ctx, "GET", "/v1/workflows/"+id+suffix+"?unknown=1", "", nil, &unused) + workflowAPIError(t, err, "INVALID_ARGUMENT") + } before, err := api.GetWorkflowSteps(ctx, id) if err != nil || len(before.Steps) != 4 { t.Fatal(err) @@ -256,6 +263,27 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { if err != nil || receipt.State != "CONSUMED" { t.Fatal(err) } + + signals, err := api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Limit: 1}) + if err != nil || len(signals.Items) != 1 || signals.Items[0].ID != accepted.Signal.ID || signals.Items[0].State != "CONSUMED" { + t.Fatal("signal history", err) + } + operations, err := api.ListWorkflowSignalOperations(ctx, id, accepted.Signal.ID, HistoryPageRequest{Limit: 1}) + if err != nil || len(operations.Items) != 1 { + t.Fatal("signal operations", err) + } + operation, err := api.GetWorkflowSignalOperation(ctx, id, accepted.Signal.ID, operations.Items[0].ID) + if err != nil || operation.Reason != signal.Reason || operation.Action != "workflow.signal" { + t.Fatal("signal operation detail", err) + } + historical, err := api.GetWorkflowStep(ctx, id, "receipt") + if err != nil { + t.Fatal(err) + } + waitHistory, err := api.GetWorkflowActivation(ctx, id, "receipt", *historical.Step.ActivationID) + if err != nil || waitHistory.Wait.WinnerSignalID == nil || *waitHistory.Wait.WinnerSignalID != accepted.Signal.ID { + t.Fatal("historical winner", 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) @@ -344,6 +372,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { if err != nil || current.Wait.DeadlineAt != old.Wait.DeadlineAt { t.Fatal("wait extended", err) } + + priorWait, err := api.GetWorkflowActivation(ctx, v.ID, "receipt", *old.Step.ActivationID) + if err != nil || priorWait.Activation.Sequence != 1 || priorWait.Step.ActivationSequence != 2 || priorWait.Wait.DeadlineAt != old.Wait.DeadlineAt || priorWait.Wait.ID != old.Wait.ID { + t.Fatal("wait history retargeted", err) + } + assertTwoActivationPagesSDK(t, ctx, api, v.ID, "receipt", *old.Step.ActivationID, *restored.Step.ActivationID) 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) @@ -362,6 +396,12 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { if err != nil || !repeated.Deduplicated || repeated.Task.ID != child.Task.ID { t.Fatal(err) } + + assertTwoActivationPagesSDK(t, ctx, api, v.ID, "work", *step.Step.ActivationID, *child.Step.ActivationID) + oldTask, err := api.GetWorkflowActivation(ctx, v.ID, "work", *step.Step.ActivationID) + if err != nil || oldTask.Activation.TaskID == nil || *oldTask.Activation.TaskID != *child.Task.RetryOf || oldTask.Activation.State != "FAILED" { + t.Fatal("old task activation", err) + } assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 0, *child.Task.RetryOf) assertWorkflowSourceSDK(t, ctx, api, v, *child.Task.RetryOf, 1, 0, "") continueWorkflowSDK(t, ctx, api, v.ID) @@ -414,6 +454,15 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { if err != nil || final.Poll.UsedRounds != 2 || final.Poll.DeadlineAt != step.Poll.DeadlineAt { t.Fatal("poll budget changed", err) } + roundPage, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1}) + if err != nil || len(roundPage.Items) != 1 || roundPage.Items[0].Round != 2 || roundPage.Items[0].TaskID != last.Task.ID || roundPage.NextCursor == "" { + t.Fatal("round page", err) + } + priorRound, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", *final.Step.ActivationID, HistoryPageRequest{Limit: 1, Cursor: roundPage.NextCursor}) + if err != nil || len(priorRound.Items) != 1 || priorRound.Items[0].Round != 1 || priorRound.Items[0].TaskID != source.ID || priorRound.NextCursor != "" { + t.Fatal("prior round", err) + } + }) t.Run("poll_recovery_source_keeps_business_round", func(t *testing.T) { spec := workflowSDKSpec(queue, handler) @@ -434,9 +483,31 @@ func TestIntegrationWorkflowInstancesSDK(t *testing.T) { continueWorkflowSDK(t, ctx, api, v.ID) finishWorkflowSDK(t, ctx, w, WorkerSucceeded, json.RawMessage(`"DONE"`)) waitWorkflowSDK(t, ctx, api, v.ID, "SUCCEEDED") + + assertTwoActivationPagesSDK(t, ctx, api, v.ID, "query", *step.Step.ActivationID, *child.Step.ActivationID) + for _, item := range []struct{ activation, task string }{{*step.Step.ActivationID, old.Task.ID}, {*child.Step.ActivationID, child.Task.ID}} { + rounds, err := api.ListWorkflowPollRounds(ctx, v.ID, "query", item.activation, HistoryPageRequest{}) + if err != nil || len(rounds.Items) != 1 || rounds.Items[0].Round != 1 || rounds.Items[0].TaskID != item.task { + t.Fatal("recovery same business round", err) + } + } assertWorkflowSourceSDK(t, ctx, api, v, child.Task.ID, 2, 1, old.Task.ID) }) if !t.Failed() { t.Log("public workflow start/query/control/signal, all six recovery commands, audit and real Worker execution verified without server imports") } } + +func assertTwoActivationPagesSDK(t *testing.T, ctx context.Context, api *Client, id, key, old, current string) { + t.Helper() + first, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1}) + if err != nil || len(first.Items) != 1 || first.Items[0].ID != current || first.Items[0].Sequence != 2 || first.NextCursor == "" { + t.Fatal("current activation page", err) + } + second, err := api.ListWorkflowActivations(ctx, id, key, HistoryPageRequest{Limit: 1, Cursor: first.NextCursor}) + if err != nil || len(second.Items) != 1 || second.Items[0].ID != old || second.Items[0].Sequence != 1 || second.NextCursor != "" { + t.Fatal("old activation page", err) + } + _, err = api.ListWorkflowSignals(ctx, id, HistoryPageRequest{Cursor: first.NextCursor}) + workflowAPIError(t, err, "INVALID_ARGUMENT") +} diff --git a/weave/instance/history.go b/weave/instance/history.go new file mode 100644 index 0000000..6be5cc9 --- /dev/null +++ b/weave/instance/history.go @@ -0,0 +1,93 @@ +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" + "git.yuntuo.hk/k2safe/orbit/pulse/service" +) + +// ActivationSummary 描述指定历史激活的当前记录,不携带大输入/结果或执行凭据。 +type ActivationSummary struct { + ID core.ID `json:"id"` // ID 激活身份,人工恢复时新建且保留原记录。 + StepID core.ID `json:"step_id"` // StepID 本激活所属逻辑步骤。 + Sequence uint32 `json:"sequence"` // Sequence 该步骤的激活代次,和POLL业务轮次分开。 + State string `json:"state"` // State 此激活状态,不代表实例当前状态。 + Revision uint64 `json:"revision,string"` // Revision 此激活记录的版本,保留整数精度。 + TaskID *core.ID `json:"task_id,omitempty"` // TaskID TASK的直接任务;POLL从轮次列表查询。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 激活创建时间,不代表恢复获得新截止。 + UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 此记录最后变更时间。 + CompletedAt *time.Time `json:"completed_at,omitempty"` // CompletedAt 此激活已保存的结束时间;未记录时省略。 +} +type ActivationPage struct { + InstanceID core.ID `json:"instance_id"` // InstanceID 已授权的所属实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页不保证同一快照。 + StepID core.ID `json:"step_id"` // StepID 已核验的逻辑步骤。 + CurrentActivationID *core.ID `json:"current_activation_id,omitempty"` // CurrentActivationID 本页读取时的当前激活,未激活时省略。 + Items []ActivationSummary `json:"items"` // Items 按代次倒序的有界摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、步骤和查询类型的后续位置。 +} +type ActivationDetail struct { + InstanceID core.ID `json:"instance_id"` // InstanceID 所属实例。 + Revision uint64 `json:"revision,string"` // Revision 读取详情时的实例版本。 + Step StepView `json:"step"` // Step 逻辑步骤的当前视图,不能替代Activation中的历史代次。 + Activation ActivationSummary `json:"activation"` // Activation 指定的历史激活,不自动改为当前激活。 + Input json.RawMessage `json:"input"` // Input 此激活原输入,显式null保留。 + Output json.RawMessage `json:"output,omitempty"` // Output 此激活当前保存的结果,缺失与显式null不同。 + Poll *PollView `json:"poll,omitempty"` // Poll 此激活的查询预算及最后任务,只用于POLL。 + Wait *WaitView `json:"wait,omitempty"` // Wait 此激活的等待记录,恢复不修改旧记录的截止。 +} +type PollRoundSummary struct { + ID core.ID `json:"id"` // ID 该任务唯一对应的轮次记录。 + ActivationID core.ID `json:"activation_id"` // ActivationID 产生本次查询的激活。 + Round uint32 `json:"round"` // Round 从首次激活累计的业务轮编号;恢复同轮不重置。 + TaskID core.ID `json:"task_id"` // TaskID 该轮Pulse任务,尝试和结果由任务接口读取。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 本次轮次记录创建时间。 +} +type PollRoundPage struct { + InstanceID core.ID `json:"instance_id"` // InstanceID 所属实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本。 + ActivationID core.ID `json:"activation_id"` // ActivationID 明确指定的历史激活。 + Items []PollRoundSummary `json:"items"` // Items 本激活内按业务轮倒序的摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、激活及查询类型的后续位置。 +} + +func historyPage(in service.HistoryPageInput) (service.HistoryPageInput, error) { + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 { + return in, fault.Invalid("invalid workflow history page") + } + return in, nil +} +func (s *Service) ListActivations(ctx context.Context, t auth.Token, id core.ID, key string, in service.HistoryPageInput) (ActivationPage, error) { + if id.Validate() != nil || !versionName(key) { + return ActivationPage{}, fault.Invalid("invalid workflow step identity") + } + in, err := historyPage(in) + if err != nil { + return ActivationPage{}, err + } + return s.store.ListActivations(ctx, t, id, key, in) +} +func (s *Service) GetActivation(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID) (ActivationDetail, error) { + if id.Validate() != nil || !versionName(key) || activation.Validate() != nil { + return ActivationDetail{}, fault.Invalid("invalid workflow activation identity") + } + return s.store.GetActivation(ctx, t, id, key, activation) +} +func (s *Service) ListPollRounds(ctx context.Context, t auth.Token, id core.ID, key string, activation core.ID, in service.HistoryPageInput) (PollRoundPage, error) { + if id.Validate() != nil || !versionName(key) || activation.Validate() != nil { + return PollRoundPage{}, fault.Invalid("invalid workflow activation identity") + } + in, err := historyPage(in) + if err != nil { + return PollRoundPage{}, err + } + return s.store.ListPollRounds(ctx, t, id, key, activation, in) +} diff --git a/weave/instance/service.go b/weave/instance/service.go index 37b0b62..e069d06 100644 --- a/weave/instance/service.go +++ b/weave/instance/service.go @@ -72,6 +72,9 @@ type StepsView struct { Steps []StepView `json:"steps"` // Steps 按键排序,最多128个,不含内部根检查点。 } type Store interface { + ListActivations(context.Context, auth.Token, core.ID, string, service.HistoryPageInput) (ActivationPage, error) + GetActivation(context.Context, auth.Token, core.ID, string, core.ID) (ActivationDetail, error) + ListPollRounds(context.Context, auth.Token, core.ID, string, core.ID, service.HistoryPageInput) (PollRoundPage, error) 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) diff --git a/weave/instance/service_test.go b/weave/instance/service_test.go index 1bb42a9..a72a2e7 100644 --- a/weave/instance/service_test.go +++ b/weave/instance/service_test.go @@ -71,3 +71,16 @@ func (s *captureStore) GetOperation(context.Context, auth.Token, core.ID, core.I s.calls++ return OperationView{}, nil } + +func (s *captureStore) ListActivations(context.Context, auth.Token, core.ID, string, service.HistoryPageInput) (ActivationPage, error) { + s.calls++ + return ActivationPage{}, nil +} +func (s *captureStore) GetActivation(context.Context, auth.Token, core.ID, string, core.ID) (ActivationDetail, error) { + s.calls++ + return ActivationDetail{}, nil +} +func (s *captureStore) ListPollRounds(context.Context, auth.Token, core.ID, string, core.ID, service.HistoryPageInput) (PollRoundPage, error) { + s.calls++ + return PollRoundPage{}, nil +} diff --git a/weave/signal/history.go b/weave/signal/history.go new file mode 100644 index 0000000..07109a6 --- /dev/null +++ b/weave/signal/history.go @@ -0,0 +1,91 @@ +package signal + +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" + "git.yuntuo.hk/k2safe/orbit/pulse/service" +) + +const AuditAction = "workflows:audit:read" + +// Summary 不聚合信号正文、关联键或证据;单条Get用于读取完整原始信号。 +type Summary struct { + ID core.ID `json:"id"` // ID 持久信号身份。 + WorkflowID core.ID `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 原指定代次,未核验旧数据可能为0。 + 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 原固定截止,不因读取或重报延长。 +} +type Page struct { + WorkflowID core.ID `json:"workflow_id"` // WorkflowID 已授权实例。 + Revision uint64 `json:"revision,string"` // Revision 本页读取时的实例版本;跨页状态可变化。 + Items []Summary `json:"items"` // Items 按受理时间及ID倒序的摘要,空页为[]。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定命名空间及实例的后续位置。 +} +type OperationSummary struct { + ID core.ID `json:"id"` // ID 不可改写的操作审计身份。 + WorkflowID core.ID `json:"workflow_id"` // WorkflowID 经信号归属验证的实例。 + SignalID core.ID `json:"signal_id"` // SignalID 本次操作对应的信号资源。 + OperationID core.ID `json:"operation_id"` // OperationID 服务端操作身份,不是原始幂等键。 + ActorID string `json:"actor_id"` // ActorID 已认证的实际操作主体。 + Action string `json:"action"` // Action 操作种类,如workflow.signal。 + CreatedAt time.Time `json:"created_at"` // CreatedAt 原操作创建时间。 +} +type OperationView struct { + OperationSummary + Reason string `json:"reason"` // Reason 原操作原因。 + EvidenceRef string `json:"evidence_ref,omitempty"` // EvidenceRef 原证据引用,不保存外部秘密。 + Before json.RawMessage `json:"before"` // Before 操作前摘要,首次受理可为null。 + After json.RawMessage `json:"after"` // After 原受理身份、摘要、时间和当时状态,不替代当前消费状态。 +} +type OperationPage struct { + Items []OperationSummary `json:"items"` // Items 按创建时间和ID倒序的有界审计摘要。 + NextCursor string `json:"next_cursor,omitempty"` // NextCursor 绑定实例、信号及查询类型的后续位置。 +} + +func historyPage(in service.HistoryPageInput) (service.HistoryPageInput, error) { + if in.Limit == 0 { + in.Limit = 20 + } + if in.Limit < 1 || in.Limit > 100 || len(in.Cursor) > 1024 { + return in, fault.Invalid("invalid workflow signal history page") + } + return in, nil +} +func (s *Service) List(ctx context.Context, t auth.Token, workflow core.ID, in service.HistoryPageInput) (Page, error) { + if workflow.Validate() != nil { + return Page{}, fault.Invalid("invalid workflow id") + } + in, err := historyPage(in) + if err != nil { + return Page{}, err + } + return s.store.List(ctx, t, workflow, in) +} +func (s *Service) ListOperations(ctx context.Context, t auth.Token, workflow, id core.ID, in service.HistoryPageInput) (OperationPage, error) { + if workflow.Validate() != nil || id.Validate() != nil { + return OperationPage{}, fault.Invalid("invalid workflow signal identity") + } + in, err := historyPage(in) + if err != nil { + return OperationPage{}, err + } + return s.store.ListOperations(ctx, t, workflow, id, in) +} +func (s *Service) GetOperation(ctx context.Context, t auth.Token, workflow, id, operation core.ID) (OperationView, error) { + if workflow.Validate() != nil || id.Validate() != nil || operation.Validate() != nil { + return OperationView{}, fault.Invalid("invalid signal operation identity") + } + return s.store.GetOperation(ctx, t, workflow, id, operation) +} diff --git a/weave/signal/service.go b/weave/signal/service.go index 4c9fc40..8b9fe1d 100644 --- a/weave/signal/service.go +++ b/weave/signal/service.go @@ -56,6 +56,9 @@ type Result struct { Deduplicated bool `json:"deduplicated"` // Deduplicated 原身份恢复,不重报新事件或重新计时。 } type Store interface { + List(context.Context, auth.Token, core.ID, service.HistoryPageInput) (Page, error) + ListOperations(context.Context, auth.Token, core.ID, core.ID, service.HistoryPageInput) (OperationPage, error) + GetOperation(context.Context, auth.Token, core.ID, core.ID, core.ID) (OperationView, error) Send(context.Context, auth.Token, core.ID, string, Input) (Result, error) Get(context.Context, auth.Token, core.ID, core.ID) (View, error) } diff --git a/weave/signal/service_test.go b/weave/signal/service_test.go index 8661ed0..ad54d8e 100644 --- a/weave/signal/service_test.go +++ b/weave/signal/service_test.go @@ -8,6 +8,7 @@ import ( core "git.yuntuo.hk/k2safe/orbit/model" "git.yuntuo.hk/k2safe/orbit/platform/auth" + "git.yuntuo.hk/k2safe/orbit/pulse/service" ) type captureStore struct { @@ -53,3 +54,16 @@ func TestSignalInputBoundary(t *testing.T) { t.Fatal("missing identity accepted") } } + +func (s *captureStore) List(context.Context, auth.Token, core.ID, service.HistoryPageInput) (Page, error) { + s.calls++ + return Page{}, nil +} +func (s *captureStore) ListOperations(context.Context, auth.Token, core.ID, core.ID, service.HistoryPageInput) (OperationPage, error) { + s.calls++ + return OperationPage{}, nil +} +func (s *captureStore) GetOperation(context.Context, auth.Token, core.ID, core.ID, core.ID) (OperationView, error) { + s.calls++ + return OperationView{}, nil +}