feat: expose bounded workflow activation and signal history

This commit is contained in:
2026-09-18 07:56:18 +08:00
parent ae0d08584a
commit 17a8e0126c
35 changed files with 1756 additions and 59 deletions

93
weave/instance/history.go Normal file
View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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
}

91
weave/signal/history.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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
}