feat: add audited workflow review and bounded task recovery
This commit is contained in:
@@ -6,7 +6,7 @@ import (
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
)
|
||||
|
||||
// Activation 表示步骤的一次具体激活,人工恢复通过新激活保留历史。
|
||||
// Activation 表示步骤的一次具体激活;人工重试创建新激活,结果核查通过状态事件和审计保留前后结论。
|
||||
// 对应表 weave_activations;这是服务端持久化模型,不直接作为 API 响应。
|
||||
type Activation struct {
|
||||
// Record 包含命名空间、记录标识、并发版本和 UTC 时间。
|
||||
|
||||
162
weave/recovery/service.go
Normal file
162
weave/recovery/service.go
Normal file
@@ -0,0 +1,162 @@
|
||||
// Package recovery 定义有证据的人工核查与接续;不通过暂停开关清除未知执行或恢复预算。
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.yuntuo.hk/k2safe/orbit/internal/fault"
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/pulse/service"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/definition"
|
||||
"git.yuntuo.hk/k2safe/orbit/weave/instance"
|
||||
)
|
||||
|
||||
const Action = "workflows:recover"
|
||||
|
||||
// Decision 核查基于整个实例的最新证据版本;后台新事实会使旧决定失效,原键仍可恢复回执。
|
||||
type Decision struct {
|
||||
ExpectedRevision uint64 `json:"expected_revision,string"` // ExpectedRevision 当前实例版本,不能使用control_revision代替。
|
||||
Reason string `json:"reason"` // Reason 必填核查原因,最多1024个Unicode字符。
|
||||
EvidenceRef string `json:"evidence_ref"` // EvidenceRef 受控证据引用,最多512个无空白或控制字符的Unicode字符;平台不请求地址。
|
||||
}
|
||||
|
||||
type ReviewTaskInput struct {
|
||||
Decision
|
||||
TaskID core.ID `json:"task_id"` // TaskID 本实例任一历史激活拥有的待核查Pulse任务。
|
||||
ExpectedTaskRevision uint64 `json:"expected_task_revision,string"` // ExpectedTaskRevision 任务当前状态版本。
|
||||
ExecutionSettled bool `json:"execution_settled" required:"true"` // ExecutionSettled 显式确认该任务全部历史外部执行已结束且不会继续产生效果。
|
||||
Outcome pulse.TaskState `json:"outcome"` // Outcome SUCCEEDED或FAILED,表示该次请求的核查结论,不代替POLL业务结果。
|
||||
Result json.RawMessage `json:"result"` // Result 非null的核查结果,最多256 KiB,保留精确数字。
|
||||
}
|
||||
|
||||
type StepDecision struct {
|
||||
Decision
|
||||
StepKey string `json:"step_key"` // StepKey 固定定义中的当前步骤,不接受内部根步骤。
|
||||
ActivationID core.ID `json:"activation_id"` // ActivationID 调用者核查的当前激活,禁止旧激活作用于新工作。
|
||||
ExpectedStepRevision uint64 `json:"expected_step_revision,string"` // ExpectedStepRevision 步骤版本,与实例版本共同校验。
|
||||
}
|
||||
|
||||
type ResolveStepInput struct {
|
||||
StepDecision
|
||||
ExecutionSettled bool `json:"execution_settled" required:"true"` // ExecutionSettled 确认本步骤关联外部执行与业务结果已核实。
|
||||
Outcome string `json:"outcome"` // Outcome SUCCEEDED或FAILED;成功必须符合固定节点输出契约。
|
||||
Result json.RawMessage `json:"result" nullable:"true"` // Result 核查后的逻辑输出;TIMER成功输出为显式null,失败结论保留在审计摘要。
|
||||
}
|
||||
|
||||
type ContinueInput struct {
|
||||
Decision
|
||||
ReviewResolved bool `json:"review_resolved" required:"true"` // ReviewResolved 确认全部核查事项已处理;服务端仍检查未决步骤、历史任务与Inbox。
|
||||
}
|
||||
|
||||
type RetryStepInput struct {
|
||||
StepDecision
|
||||
SafeToRetry bool `json:"safe_to_retry" required:"true"` // SafeToRetry 确认旧执行已结束,并按原业务身份再次执行安全;不能只依据未收到响应。
|
||||
}
|
||||
|
||||
type Result struct {
|
||||
Instance instance.View `json:"instance"` // Instance 操作提交时的冻结实例回执;核查步骤不自动解除实例核查。
|
||||
AuditID core.ID `json:"audit_id"` // AuditID 本次流程管理操作的不可变审计标识。
|
||||
Step *instance.StepView `json:"step,omitempty"` // Step 被核查或重新激活步骤的回执。
|
||||
Task *service.TaskView `json:"task,omitempty"` // Task 被核查或新创建的执行任务。
|
||||
TaskAuditID *core.ID `json:"task_audit_id,omitempty"` // TaskAuditID 独立的执行结果核查审计,和流程审计同事务。
|
||||
Deduplicated bool `json:"deduplicated"` // Deduplicated 原操作回执恢复,不再次修改状态或预算。
|
||||
}
|
||||
|
||||
type Store interface {
|
||||
ReviewTask(context.Context, auth.Token, core.ID, string, ReviewTaskInput) (Result, error)
|
||||
ResolveStep(context.Context, auth.Token, core.ID, string, ResolveStepInput) (Result, error)
|
||||
Continue(context.Context, auth.Token, core.ID, string, ContinueInput) (Result, error)
|
||||
RetryStep(context.Context, auth.Token, core.ID, string, RetryStepInput) (Result, error)
|
||||
}
|
||||
type Service struct{ store Store }
|
||||
|
||||
func New(store Store) *Service { return &Service{store: store} }
|
||||
|
||||
func validateDecision(id core.ID, key string, in Decision) error {
|
||||
if id.Validate() != nil || in.ExpectedRevision == 0 {
|
||||
return fault.Invalid("workflow id and positive expected_revision required")
|
||||
}
|
||||
if err := service.ValidateKey(key); err != nil {
|
||||
return err
|
||||
}
|
||||
if !utf8.ValidString(in.Reason) || strings.TrimSpace(in.Reason) == "" || utf8.RuneCountInString(in.Reason) > 1024 || strings.ContainsRune(in.Reason, 0) {
|
||||
return fault.Invalid("recovery reason required, maximum 1024 characters")
|
||||
}
|
||||
if !utf8.ValidString(in.EvidenceRef) || len(in.EvidenceRef) == 0 || utf8.RuneCountInString(in.EvidenceRef) > 512 {
|
||||
return fault.Invalid("valid evidence_ref required, maximum 512 characters")
|
||||
}
|
||||
for _, r := range in.EvidenceRef {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return fault.Invalid("evidence_ref cannot contain whitespace or control characters")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateStep(in StepDecision) error {
|
||||
if in.ActivationID.Validate() != nil || in.ExpectedStepRevision == 0 || len(in.StepKey) == 0 || len(in.StepKey) > 64 {
|
||||
return fault.Invalid("current step identity and revision required")
|
||||
}
|
||||
for i, r := range in.StepKey {
|
||||
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) {
|
||||
return fault.Invalid("invalid step key")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func validateResult(raw json.RawMessage) error {
|
||||
return definition.ValidateData(definition.Type{Kind: "JSON"}, raw)
|
||||
}
|
||||
func (s *Service) ReviewTask(ctx context.Context, t auth.Token, id core.ID, key string, in ReviewTaskInput) (Result, error) {
|
||||
if err := validateDecision(id, key, in.Decision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if in.TaskID.Validate() != nil || in.ExpectedTaskRevision == 0 || !in.ExecutionSettled || in.Outcome != pulse.TaskSucceeded && in.Outcome != pulse.TaskFailed || strings.TrimSpace(string(in.Result)) == "null" {
|
||||
return Result{}, fault.Invalid("task identity, execution_settled and known outcome required")
|
||||
}
|
||||
if err := validateResult(in.Result); err != nil {
|
||||
return Result{}, fault.Invalid("valid non-null review result required, maximum 256 KiB")
|
||||
}
|
||||
return s.store.ReviewTask(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) ResolveStep(ctx context.Context, t auth.Token, id core.ID, key string, in ResolveStepInput) (Result, error) {
|
||||
if err := validateDecision(id, key, in.Decision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := validateStep(in.StepDecision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !in.ExecutionSettled || in.Outcome != "SUCCEEDED" && in.Outcome != "FAILED" {
|
||||
return Result{}, fault.Invalid("execution_settled and known step outcome required")
|
||||
}
|
||||
if err := validateResult(in.Result); err != nil {
|
||||
return Result{}, fault.Invalid("valid review result required, maximum 256 KiB")
|
||||
}
|
||||
return s.store.ResolveStep(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) Continue(ctx context.Context, t auth.Token, id core.ID, key string, in ContinueInput) (Result, error) {
|
||||
if err := validateDecision(id, key, in.Decision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !in.ReviewResolved {
|
||||
return Result{}, fault.Invalid("review_resolved=true required")
|
||||
}
|
||||
return s.store.Continue(ctx, t, id, key, in)
|
||||
}
|
||||
func (s *Service) RetryStep(ctx context.Context, t auth.Token, id core.ID, key string, in RetryStepInput) (Result, error) {
|
||||
if err := validateDecision(id, key, in.Decision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if err := validateStep(in.StepDecision); err != nil {
|
||||
return Result{}, err
|
||||
}
|
||||
if !in.SafeToRetry {
|
||||
return Result{}, fault.Invalid("safe_to_retry=true required")
|
||||
}
|
||||
return s.store.RetryStep(ctx, t, id, key, in)
|
||||
}
|
||||
77
weave/recovery/service_test.go
Normal file
77
weave/recovery/service_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package recovery
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
core "git.yuntuo.hk/k2safe/orbit/model"
|
||||
"git.yuntuo.hk/k2safe/orbit/platform/auth"
|
||||
)
|
||||
|
||||
type captureStore struct{ calls int }
|
||||
|
||||
func (s *captureStore) ReviewTask(context.Context, auth.Token, core.ID, string, ReviewTaskInput) (Result, error) {
|
||||
s.calls++
|
||||
return Result{}, nil
|
||||
}
|
||||
func (s *captureStore) ResolveStep(context.Context, auth.Token, core.ID, string, ResolveStepInput) (Result, error) {
|
||||
s.calls++
|
||||
return Result{}, nil
|
||||
}
|
||||
func (s *captureStore) Continue(context.Context, auth.Token, core.ID, string, ContinueInput) (Result, error) {
|
||||
s.calls++
|
||||
return Result{}, nil
|
||||
}
|
||||
func (s *captureStore) RetryStep(context.Context, auth.Token, core.ID, string, RetryStepInput) (Result, error) {
|
||||
s.calls++
|
||||
return Result{}, nil
|
||||
}
|
||||
func TestRecoveryValidation(t *testing.T) {
|
||||
id := core.ID(strings.Repeat("a", 32))
|
||||
d := Decision{ExpectedRevision: 2, Reason: "核实外部结果", EvidenceRef: "evidence://case/1"}
|
||||
step := StepDecision{Decision: d, StepKey: "stage-1.v2", ActivationID: id, ExpectedStepRevision: 3}
|
||||
store := &captureStore{}
|
||||
s := New(store)
|
||||
ctx := context.Background()
|
||||
token := auth.Token{}
|
||||
if _, err := s.ReviewTask(ctx, token, id, "operation", ReviewTaskInput{Decision: d, TaskID: id, ExpectedTaskRevision: 4, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":9007199254740993}`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ResolveStep(ctx, token, id, "operation", ResolveStepInput{StepDecision: step, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`null`)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.Continue(ctx, token, id, "operation", ContinueInput{Decision: d, ReviewResolved: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.RetryStep(ctx, token, id, "operation", RetryStepInput{StepDecision: step, SafeToRetry: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, change := range []func(*Decision){func(v *Decision) { v.ExpectedRevision = 0 }, func(v *Decision) { v.Reason = " " }, func(v *Decision) { v.Reason = string([]byte{255}) }, func(v *Decision) { v.EvidenceRef = "secret bad" }, func(v *Decision) { v.EvidenceRef = "" }} {
|
||||
bad := d
|
||||
change(&bad)
|
||||
if _, err := s.Continue(ctx, token, id, "operation", ContinueInput{Decision: bad, ReviewResolved: true}); err == nil {
|
||||
t.Fatal("invalid decision accepted")
|
||||
}
|
||||
}
|
||||
for _, bad := range []ReviewTaskInput{
|
||||
{Decision: d, TaskID: id, ExpectedTaskRevision: 4, Outcome: "SUCCEEDED", Result: json.RawMessage(`{}`)},
|
||||
{Decision: d, TaskID: id, ExpectedTaskRevision: 4, ExecutionSettled: true, Outcome: "UNKNOWN", Result: json.RawMessage(`{}`)},
|
||||
{Decision: d, TaskID: id, ExpectedTaskRevision: 4, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`null`)},
|
||||
{Decision: d, TaskID: id, ExpectedTaskRevision: 4, ExecutionSettled: true, Outcome: "SUCCEEDED", Result: json.RawMessage(`{"n":1,"n":2}`)},
|
||||
} {
|
||||
if _, err := s.ReviewTask(ctx, token, id, "operation", bad); err == nil {
|
||||
t.Fatal("unsafe execution review accepted")
|
||||
}
|
||||
}
|
||||
if _, err := s.Continue(ctx, token, id, "operation", ContinueInput{Decision: d}); err == nil {
|
||||
t.Fatal("unresolved review continued")
|
||||
}
|
||||
if _, err := s.RetryStep(ctx, token, id, "operation", RetryStepInput{StepDecision: step}); err == nil {
|
||||
t.Fatal("unsafe retry accepted")
|
||||
}
|
||||
if store.calls != 4 {
|
||||
t.Fatal("invalid inputs reached storage", store.calls)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user