feat: publish immutable workflow definitions with typed SDK

This commit is contained in:
2026-09-18 00:28:00 +08:00
parent a664ec9370
commit 520e98d9d1
33 changed files with 2603 additions and 11 deletions

519
weave/definition/check.go Normal file
View File

@@ -0,0 +1,519 @@
package definition
import (
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"sort"
"unicode/utf8"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
"git.yuntuo.hk/k2safe/orbit/internal/jsonwire"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/pulse/service"
)
const MaxSpecificationBytes = 256 << 10
const maxTimeoutMS = 30 * 86400000
type checker struct {
result Checked
keys map[string]bool
queues map[core.ID]bool
timeout uint64
}
type scope map[string]Type
// Check 验证完整结构、词法数据依赖和资源边界;不修改调用方定义,也不访问数据库。
// 队列存在性、当前权限和不可变版本身份由发布事务另行核验。
func Check(in Specification) (Checked, error) {
if in.SchemaVersion != 1 || in.TimeoutMS == 0 || in.TimeoutMS > maxTimeoutMS || (in.FailurePolicy != "REVIEW" && in.FailurePolicy != "FAIL_AFTER_SETTLED") {
return Checked{}, invalid("specification", "schema_version=1, finite timeout and explicit failure policy required")
}
if err := checkType(in.InputType, 0); err != nil {
return Checked{}, err
}
c := checker{keys: map[string]bool{}, queues: map[core.ID]bool{}, timeout: in.TimeoutMS}
initial := scope{"input": in.InputType, "workflow": {Kind: "OBJECT", Fields: map[string]Type{"id": {Kind: "STRING"}, "business_key": {Kind: "STRING"}}}}
width, err := c.block(in.Body, initial, 0)
if err != nil {
return Checked{}, err
}
if width > 32 || c.result.MaxTaskAttempts > 10000 {
return Checked{}, invalid("specification", "parallel or total attempt budget exceeded")
}
raw, err := jsonwire.Marshal(in)
if err != nil || len(raw) > MaxSpecificationBytes {
return Checked{}, invalid("specification", "encoded definition exceeds 256 KiB or is invalid")
}
canonical, err := jsonvalue.Canonical(raw)
if err != nil {
return Checked{}, invalid("specification", "definition JSON exceeds supported depth or value limits")
}
c.result.Digest = core.Digest(sha256.Sum256(append([]byte("orbit.weave.spec.v1\x00"), canonical...)))
c.result.MaxConcurrency = width
for q := range c.queues {
c.result.Queues = append(c.result.Queues, q)
}
sort.Slice(c.result.Queues, func(i, j int) bool { return c.result.Queues[i] < c.result.Queues[j] })
return c.result, nil
}
func invalid(where, message string) error { return fmt.Errorf("%s: %s", where, message) }
func name(s string, limit int) bool {
if len(s) == 0 || len(s) > limit {
return false
}
for i, r := range s {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || i > 0 && (r == '_' || r == '.' || r == '-')) {
return false
}
}
return true
}
func cloneScope(in scope) scope {
out := make(scope, len(in)+8)
for key, value := range in {
out[key] = value
}
return out
}
func (c *checker) block(b Block, parent scope, depth int) (uint32, error) {
if depth > 4 || len(b.Steps) > 128 {
return 0, invalid("body", "block depth or step limit exceeded")
}
if err := checkType(b.OutputType, 0); err != nil {
return 0, err
}
env := cloneScope(parent)
var width uint32
for _, step := range b.Steps {
if !name(step.Key, 64) || step.Key == "input" || step.Key == "workflow" || c.keys[step.Key] {
return 0, invalid("step", "key must be unique and nonreserved")
}
c.keys[step.Key] = true
c.result.Steps++
if c.result.Steps > 128 {
return 0, invalid("body", "at most 128 static steps allowed")
}
result, concurrency, err := c.step(step, env, depth)
if err != nil {
return 0, invalid(step.Key, err.Error())
}
env[step.Key] = result
if concurrency > width {
width = concurrency
}
}
if err := checkValue(b.Output, b.OutputType, env, 0); err != nil {
return 0, invalid("output", err.Error())
}
return width, nil
}
func (c *checker) step(s Step, env scope, depth int) (Type, uint32, error) {
n := 0
for _, present := range []bool{s.Task != nil, s.Branch != nil, s.Parallel != nil, s.Signal != nil, s.Timer != nil, s.Poll != nil} {
if present {
n++
}
}
if n != 1 {
return Type{}, 0, invalid("step", "exactly one kind configuration required")
}
switch s.Kind {
case "TASK":
if s.Task != nil {
if err := c.task(*s.Task, env); err != nil {
return Type{}, 0, err
}
c.result.MaxTaskAttempts += s.Task.Retry.MaxAttempts
return s.Task.ResultType, 1, nil
}
case "SIGNAL":
if s.Signal != nil {
return s.Signal.PayloadType, 0, c.signal(*s.Signal, env)
}
case "TIMER":
if s.Timer != nil && s.Timer.DelayMS > 0 && s.Timer.DelayMS <= c.timeout {
return Type{Kind: "NULL"}, 0, nil
}
case "BRANCH":
if s.Branch != nil {
b := s.Branch
if b.Default == nil || len(b.Cases) < 1 || len(b.Cases) > 16 {
return Type{}, 0, invalid("branch", "1 to 16 cases and explicit default required")
}
width, err := c.block(*b.Default, env, depth+1)
if err != nil {
return Type{}, 0, err
}
for _, path := range b.Cases {
if err := checkPredicate(path.When, env, 0); err != nil {
return Type{}, 0, err
}
w, err := c.block(path.Body, env, depth+1)
if err != nil {
return Type{}, 0, err
}
if !sameType(path.Body.OutputType, b.Default.OutputType) {
return Type{}, 0, invalid("branch", "all path output types must match")
}
if w > width {
width = w
}
}
return b.Default.OutputType, width, nil
}
case "PARALLEL":
if s.Parallel != nil {
if len(s.Parallel.Branches) < 2 || len(s.Parallel.Branches) > 8 {
return Type{}, 0, invalid("parallel", "2 to 8 named branches required")
}
result := Type{Kind: "OBJECT", Fields: map[string]Type{}}
var width uint32
for _, branch := range s.Parallel.Branches {
if !name(branch.Name, 64) {
return Type{}, 0, invalid("parallel", "invalid branch name")
}
if _, exists := result.Fields[branch.Name]; exists {
return Type{}, 0, invalid("parallel", "duplicate branch name")
}
w, err := c.block(branch.Body, env, depth+1)
if err != nil {
return Type{}, 0, err
}
width += w
result.Fields[branch.Name] = branch.Body.OutputType
}
if width > 32 {
return Type{}, 0, invalid("parallel", "at most 32 concurrent business tasks allowed")
}
return result, width, nil
}
case "POLL":
if s.Poll != nil {
p := s.Poll
if p.MaxRounds == 0 || p.MaxRounds > 1000 || p.TimeoutMS == 0 || p.TimeoutMS > c.timeout || p.IntervalMS < 1000 || p.IntervalMS >= p.TimeoutMS || p.Task.TimeoutMS > p.TimeoutMS {
return Type{}, 0, invalid("poll", "finite rounds, interval and compatible time budgets required")
}
if err := c.task(p.Task, env); err != nil {
return Type{}, 0, err
}
status, err := pathType(p.Task.ResultType, p.StatusPath)
if err != nil || status.Kind != "STRING" || len(status.Enum) == 0 || len(p.Pending) == 0 || len(p.Succeeded) == 0 {
return Type{}, 0, invalid("poll", "status must be a declared string enum with pending and success cases")
}
seen := map[string]bool{}
for _, group := range [][]string{p.Pending, p.Succeeded, p.Failed, p.Review} {
for _, v := range group {
if !contains(status.Enum, v) || seen[v] {
return Type{}, 0, invalid("poll", "status groups must form an exact disjoint partition")
}
seen[v] = true
}
}
if len(seen) != len(status.Enum) {
return Type{}, 0, invalid("poll", "unmapped status enum value")
}
if p.FinalSignal != nil {
if err := c.signal(*p.FinalSignal, env); err != nil {
return Type{}, 0, err
}
if p.FinalSignal.TimeoutMS != p.TimeoutMS || !sameType(p.FinalSignal.PayloadType, p.Task.ResultType) {
return Type{}, 0, invalid("poll", "final signal must share result type and poll deadline")
}
}
c.result.MaxTaskAttempts += p.MaxRounds * p.Task.Retry.MaxAttempts
return p.Task.ResultType, 1, nil
}
}
return Type{}, 0, invalid("step", "unknown kind, mismatched configuration or invalid timer")
}
func (c *checker) task(t Task, env scope) error {
if t.TimeoutMS == 0 || t.TimeoutMS > c.timeout || t.InputType.Kind == "NULL" || t.ResultType.Kind == "NULL" {
return invalid("task", "finite timeout and non-null payload/result types required")
}
// 复用Pulse的处理器/策略边界,但定义必须显式提供生效值,不能在后续发布时解释不同默认值。
v, err := service.NormalizeSubmit(service.SubmitInput{QueueID: t.QueueID, Handler: t.Handler, HandlerVersion: t.HandlerVersion, PayloadVersion: t.PayloadVersion, Payload: json.RawMessage(`{}`), TaskTimeoutMS: t.TimeoutMS, RecoverySafety: t.RecoverySafety, Retry: t.Retry, Priority: t.Priority})
if err != nil || v.Retry != t.Retry || v.RecoverySafety != t.RecoverySafety {
return invalid("task", "invalid or incomplete frozen Pulse policy")
}
if err := checkType(t.InputType, 0); err != nil {
return err
}
if err := checkType(t.ResultType, 0); err != nil {
return err
}
if err := checkValue(t.Input, t.InputType, env, 0); err != nil {
return err
}
if t.Input.Kind == "LITERAL" && bytes.Equal(bytes.TrimSpace(t.Input.Literal), []byte("null")) {
return invalid("task", "null task input is not accepted by Pulse")
}
for _, key := range []*Value{t.BusinessKey, t.ConcurrencyKey} {
if key != nil {
if err := checkValue(*key, Type{Kind: "STRING"}, env, 0); err != nil {
return err
}
}
}
c.queues[t.QueueID] = true
return nil
}
func (c *checker) signal(s Signal, env scope) error {
if !name(s.Name, 128) || !name(s.PayloadVersion, 32) || s.TimeoutMS == 0 || s.TimeoutMS > c.timeout {
return invalid("signal", "name, payload version and finite timeout required")
}
if err := checkType(s.PayloadType, 0); err != nil {
return err
}
if err := checkValue(s.Correlation, Type{Kind: "STRING"}, env, 0); err != nil {
return err
}
if s.Correlation.Kind == "LITERAL" {
var value string
if json.Unmarshal(s.Correlation.Literal, &value) != nil || len(value) == 0 || len(value) > 512 {
return invalid("signal", "literal correlation must be a nonempty string up to 512 bytes")
}
}
return nil
}
func checkType(t Type, depth int) error {
if depth > 8 || len(t.Fields) > 64 || len(t.Enum) > 32 {
return invalid("type", "schema depth or width limit exceeded")
}
if t.Kind != "OBJECT" && len(t.Fields) != 0 || t.Kind != "ARRAY" && t.Items != nil || t.Kind != "STRING" && len(t.Enum) != 0 {
return invalid("type", "fields, items or enum do not match kind")
}
switch t.Kind {
case "JSON", "NUMBER", "BOOLEAN", "NULL":
case "STRING":
seen := map[string]bool{}
for _, value := range t.Enum {
if !utf8.ValidString(value) || len(value) > 128 || seen[value] {
return invalid("type", "invalid or duplicate string enum value")
}
seen[value] = true
}
case "OBJECT":
for key, field := range t.Fields {
if !name(key, 64) {
return invalid("type", "invalid object field name")
}
if err := checkType(field, depth+1); err != nil {
return err
}
}
case "ARRAY":
if t.Items == nil {
return invalid("type", "array items type required")
}
return checkType(*t.Items, depth+1)
default:
return invalid("type", "unknown kind")
}
return nil
}
func sameType(a, b Type) bool { return assignable(a, b) && assignable(b, a) }
func assignable(actual, expected Type) bool {
if expected.Kind == "JSON" {
return true
}
if actual.Kind != expected.Kind {
return false
}
switch expected.Kind {
case "OBJECT":
if len(actual.Fields) != len(expected.Fields) {
return false
}
for key, t := range expected.Fields {
v, ok := actual.Fields[key]
if !ok || !assignable(v, t) {
return false
}
}
case "ARRAY":
return actual.Items != nil && expected.Items != nil && assignable(*actual.Items, *expected.Items)
case "STRING":
if len(expected.Enum) == 0 {
return true
}
if len(actual.Enum) == 0 {
return false
}
for _, v := range actual.Enum {
if !contains(expected.Enum, v) {
return false
}
}
}
return true
}
func contains(values []string, value string) bool {
for _, v := range values {
if v == value {
return true
}
}
return false
}
func pathType(t Type, path []string) (Type, error) {
if len(path) > 8 {
return Type{}, invalid("reference", "path exceeds 8 fields")
}
for _, key := range path {
if t.Kind != "OBJECT" {
return Type{}, invalid("reference", "path crosses a non-object or untyped JSON value")
}
var ok bool
t, ok = t.Fields[key]
if !ok {
return Type{}, invalid("reference", "field not declared")
}
}
return t, nil
}
func referenceType(r Reference, env scope) (Type, error) {
t, ok := env[r.Source]
if !ok {
return Type{}, invalid("reference", "source is missing, forward, conditional or outside this block")
}
return pathType(t, r.Path)
}
func checkValue(v Value, expected Type, env scope, depth int) error {
if depth > 8 {
return invalid("value", "expression depth exceeds 8")
}
if v.Kind != "LITERAL" && len(v.Literal) != 0 || v.Kind != "REFERENCE" && v.Reference != nil || v.Kind != "OBJECT" && len(v.Fields) != 0 || v.Kind != "ARRAY" && len(v.Items) != 0 {
return invalid("value", "configuration does not match kind")
}
switch v.Kind {
case "LITERAL":
return ValidateData(expected, v.Literal)
case "REFERENCE":
if v.Reference == nil {
return invalid("reference", "source required")
}
actual, err := referenceType(*v.Reference, env)
if err != nil {
return err
}
if !assignable(actual, expected) {
return invalid("reference", "source and target types are incompatible")
}
case "OBJECT":
if expected.Kind != "OBJECT" && expected.Kind != "JSON" || len(v.Fields) > 64 {
return invalid("value", "object expression has incompatible type or too many fields")
}
if expected.Kind == "OBJECT" && len(v.Fields) != len(expected.Fields) {
return invalid("value", "object expression must define every field exactly")
}
for key, child := range v.Fields {
if !name(key, 64) {
return invalid("value", "invalid object field")
}
t := Type{Kind: "JSON"}
if expected.Kind == "OBJECT" {
var ok bool
t, ok = expected.Fields[key]
if !ok {
return invalid("value", "extra object field")
}
}
if err := checkValue(child, t, env, depth+1); err != nil {
return err
}
}
case "ARRAY":
if expected.Kind != "ARRAY" && expected.Kind != "JSON" || len(v.Items) > 256 {
return invalid("value", "array expression has incompatible type or too many elements")
}
t := Type{Kind: "JSON"}
if expected.Kind == "ARRAY" {
t = *expected.Items
}
for _, child := range v.Items {
if err := checkValue(child, t, env, depth+1); err != nil {
return err
}
}
default:
return invalid("value", "unknown kind")
}
return nil
}
func scalarType(v Value, env scope) (string, error) {
if err := checkValue(v, Type{Kind: "JSON"}, env, 0); err != nil {
return "", err
}
var kind string
switch v.Kind {
case "REFERENCE":
t, err := referenceType(*v.Reference, env)
if err != nil {
return "", err
}
kind = t.Kind
case "LITERAL":
var raw any
d := json.NewDecoder(bytes.NewReader(v.Literal))
d.UseNumber()
if err := d.Decode(&raw); err != nil {
return "", err
}
switch raw.(type) {
case nil:
kind = "NULL"
case string:
kind = "STRING"
case bool:
kind = "BOOLEAN"
case json.Number:
kind = "NUMBER"
}
}
if kind != "STRING" && kind != "NUMBER" && kind != "BOOLEAN" && kind != "NULL" {
return "", invalid("predicate", "only typed scalar comparisons allowed")
}
return kind, nil
}
func checkPredicate(p Predicate, env scope, depth int) error {
if depth > 8 {
return invalid("predicate", "expression depth exceeds 8")
}
switch p.Op {
case "AND", "OR", "NOT":
if p.Left != nil || p.Right != nil || len(p.Arguments) > 16 || (p.Op == "NOT" && len(p.Arguments) != 1) || (p.Op != "NOT" && len(p.Arguments) < 2) {
return invalid("predicate", "invalid logical arguments")
}
for _, child := range p.Arguments {
if err := checkPredicate(child, env, depth+1); err != nil {
return err
}
}
case "EQ", "NE", "LT", "LE", "GT", "GE":
if p.Left == nil || p.Right == nil || len(p.Arguments) != 0 {
return invalid("predicate", "exactly two comparison operands required")
}
left, err := scalarType(*p.Left, env)
if err != nil {
return err
}
right, err := scalarType(*p.Right, env)
if err != nil {
return err
}
if left != right || (p.Op != "EQ" && p.Op != "NE" && left != "STRING" && left != "NUMBER") {
return invalid("predicate", "incompatible comparison operands")
}
default:
return invalid("predicate", "unknown operator")
}
return nil
}

View File

@@ -0,0 +1,211 @@
package definition
import (
"bytes"
"encoding/json"
"strings"
"testing"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
func literal(raw string) Value { return Value{Kind: "LITERAL", Literal: json.RawMessage(raw)} }
func ref(source string, path ...string) Value {
return Value{Kind: "REFERENCE", Reference: &Reference{Source: source, Path: path}}
}
func scalar(kind string) Type { return Type{Kind: kind} }
func block(result Type, value Value, steps ...Step) Block {
return Block{Steps: steps, OutputType: result, Output: value}
}
func testTask() Task {
return Task{QueueID: "11111111111111111111111111111111", Handler: "object.prepare", HandlerVersion: "v1", PayloadVersion: "v1", InputType: scalar("STRING"), Input: ref("input", "operation"), ResultType: scalar("STRING"), TimeoutMS: 10000, Retry: pulse.RetryPolicy{MaxAttempts: 2, AttemptTimeoutMS: 3000, InitialDelayMS: 1000, MaxDelayMS: 2000}, RecoverySafety: pulse.RecoveryReconcileOnly}
}
func testSpecification() Specification {
t := testTask()
return Specification{SchemaVersion: 1, TimeoutMS: 60000, FailurePolicy: "REVIEW", InputType: Type{Kind: "OBJECT", Fields: map[string]Type{"operation": scalar("STRING"), "enabled": scalar("BOOLEAN")}}, Body: block(scalar("STRING"), ref("prepare"), Step{Key: "prepare", Kind: "TASK", Task: &t})}
}
func TestStructuredDefinitionScopesAndBudgets(t *testing.T) {
base := testSpecification()
left, right := ref("input", "enabled"), literal("true")
task := testTask()
branch := Branch{Cases: []BranchCase{{When: Predicate{Op: "EQ", Left: &left, Right: &right}, Body: block(scalar("STRING"), ref("chosen"), Step{Key: "chosen", Kind: "TASK", Task: &task})}}, Default: ptrBlock(block(scalar("STRING"), literal(`"skipped"`)))}
parallel := Parallel{Branches: []ParallelBranch{{Name: "first", Body: block(scalar("STRING"), ref("choice"))}, {Name: "second", Body: block(scalar("NULL"), ref("timer"), Step{Key: "timer", Kind: "TIMER", Timer: &Timer{DelayMS: 1}})}}}
base.Body = block(Type{Kind: "OBJECT", Fields: map[string]Type{"first": scalar("STRING"), "second": scalar("NULL")}}, ref("joined"), Step{Key: "choice", Kind: "BRANCH", Branch: &branch}, Step{Key: "joined", Kind: "PARALLEL", Parallel: &parallel})
got, err := Check(base)
if err != nil || got.Steps != 4 || got.MaxConcurrency != 1 || got.MaxTaskAttempts != 2 || len(got.Queues) != 1 {
t.Fatal("structured definition", got, err)
}
for _, test := range []struct {
name string
change func(*Specification)
}{
{"forward_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("later") }},
{"self_reference", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("prepare") }},
{"missing_property", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "missing") }},
{"wrong_type", func(s *Specification) { s.Body.Steps[0].Task.Input = ref("input", "enabled") }},
{"default_policy_not_frozen", func(s *Specification) { s.Body.Steps[0].Task.Retry.MaxAttempts = 0 }},
{"unknown_kind", func(s *Specification) { s.Body.Steps[0].Kind = "SCRIPT" }},
{"extra_kind_body", func(s *Specification) { s.Body.Steps[0].Timer = &Timer{DelayMS: 1} }},
{"reserved_step_name", func(s *Specification) { s.Body.Steps[0].Key = "input" }},
{"type_cycle", func(s *Specification) { x := scalar("ARRAY"); x.Items = &x; s.InputType = x }},
{"invalid_unicode", func(s *Specification) { s.Body.Steps[0].Task.Input = literal(`"\ud800"`) }},
{"duplicate_literal_key", func(s *Specification) {
s.Body.Steps[0].Task.InputType = scalar("JSON")
s.Body.Steps[0].Task.Input = literal(`{"a":1,"a":2}`)
}},
{"null_task_literal", func(s *Specification) {
s.Body.Steps[0].Task.InputType = scalar("JSON")
s.Body.Steps[0].Task.Input = literal(" null ")
}},
{"empty_task_deadline", func(s *Specification) { s.Body.Steps[0].Task.TimeoutMS = 0 }},
} {
t.Run(test.name, func(t *testing.T) {
s := testSpecification()
test.change(&s)
if _, err := Check(s); err == nil {
t.Fatal("invalid definition accepted")
}
})
}
branch.Cases[0].Body.Output = ref("joined", "first")
if _, err := Check(base); err == nil {
t.Fatal("future join result was visible inside a branch")
}
branch.Cases[0].Body.Output = ref("chosen")
base.Body.Output = ref("chosen")
if _, err := Check(base); err == nil {
t.Fatal("conditional child leaked outside composite")
}
base.Body.Output = ref("joined")
parallel.Branches[1].Body.Output = ref("chosen")
if _, err := Check(base); err == nil {
t.Fatal("sibling/unselected result leaked")
}
parallel.Branches[1].Body.Output = ref("timer")
branch.Default = nil
if _, err := Check(base); err == nil {
t.Fatal("nonexhaustive branch accepted")
}
}
func ptrBlock(b Block) *Block { return &b }
func TestDefinitionParallelBudgetAndDuplicateIdentity(t *testing.T) {
makeParallel := func(prefix string) Step {
p := Parallel{}
for _, suffix := range []string{"a", "b", "c", "d", "e", "f", "g", "h"} {
task := testTask()
key := prefix + suffix
p.Branches = append(p.Branches, ParallelBranch{Name: suffix, Body: block(scalar("STRING"), ref(key), Step{Key: key, Kind: "TASK", Task: &task})})
}
return Step{Key: prefix, Kind: "PARALLEL", Parallel: &p}
}
s := testSpecification()
p := makeParallel("wide")
s.Body = block(scalar("JSON"), ref("wide"), p)
got, err := Check(s)
if err != nil || got.MaxConcurrency != 8 || got.Steps != 9 || got.MaxTaskAttempts != 16 {
t.Fatal(got, err)
}
p.Parallel.Branches[1].Body.Steps[0].Key = "widea"
if _, err := Check(s); err == nil {
t.Fatal("duplicate step identities accepted")
}
outer := Parallel{}
for _, prefix := range []string{"a", "b", "c", "d", "e"} {
child := makeParallel(prefix)
outer.Branches = append(outer.Branches, ParallelBranch{Name: prefix, Body: block(scalar("JSON"), ref(prefix), child)})
}
s.Body = block(scalar("JSON"), ref("outer"), Step{Key: "outer", Kind: "PARALLEL", Parallel: &outer})
if _, err := Check(s); err == nil {
t.Fatal("nested concurrency over 32 accepted")
}
s = testSpecification()
for i := 0; i < 128; i++ {
task := testTask()
s.Body.Steps = append(s.Body.Steps, Step{Key: "extra" + strings.Repeat("x", i+1), Kind: "TASK", Task: &task})
}
if _, err := Check(s); err == nil {
t.Fatal("overlong block accepted")
}
}
func TestPollPartitionsAndIndependentBudgets(t *testing.T) {
s := testSpecification()
task := testTask()
task.ResultType = Type{Kind: "OBJECT", Fields: map[string]Type{"state": {Kind: "STRING", Enum: []string{"processing", "done", "failed", "review"}}, "reference": scalar("STRING")}}
p := Poll{Task: task, MaxRounds: 3, TimeoutMS: 30000, IntervalMS: 1000, StatusPath: []string{"state"}, Pending: []string{"processing"}, Succeeded: []string{"done"}, Failed: []string{"failed"}, Review: []string{"review"}, FinalSignal: &Signal{Name: "completion", PayloadVersion: "v1", Correlation: ref("input", "operation"), PayloadType: task.ResultType, TimeoutMS: 30000}}
s.Body = block(task.ResultType, ref("poll"), Step{Key: "poll", Kind: "POLL", Poll: &p})
got, err := Check(s)
if err != nil || got.MaxTaskAttempts != 6 || got.Steps != 1 {
t.Fatal(got, err)
}
for _, change := range []func(*Poll){
func(p *Poll) { p.MaxRounds = 0 }, func(p *Poll) { p.MaxRounds = 1001 }, func(p *Poll) { p.IntervalMS = 0 }, func(p *Poll) { p.IntervalMS = p.TimeoutMS },
func(p *Poll) { p.Review = nil }, func(p *Poll) { p.Failed = []string{"processing"} }, func(p *Poll) { p.StatusPath = []string{"reference"} },
func(p *Poll) { p.MaxRounds = 1000; p.Task.Retry.MaxAttempts = 100 }, func(p *Poll) { p.Task.TimeoutMS = p.TimeoutMS + 1 },
func(p *Poll) { v := *p.FinalSignal; v.TimeoutMS++; p.FinalSignal = &v },
} {
copy := p
change(&copy)
s.Body.Steps[0].Poll = &copy
if _, err := Check(s); err == nil {
t.Fatal("unbounded or ambiguous poll accepted")
}
}
}
func TestDefinitionDigestAndPreciseData(t *testing.T) {
s := testSpecification()
s.Body.Steps[0].Task.InputType = scalar("JSON")
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740993,"decimal":12345678901234567890.1234567890123456789}`)
a, err := Check(s)
if err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(s)
s.Body.Steps[0].Task.Input = literal(`{"decimal":12345678901234567890.123456789012345678900,"n":9007199254740993.0}`)
b, err := Check(s)
if err != nil || a.Digest != b.Digest {
t.Fatal("semantic numeric digest changed", err)
}
s.Body.Steps[0].Task.Input = literal(`{"n":9007199254740992,"decimal":12345678901234567890.1234567890123456789}`)
c, err := Check(s)
if err != nil || a.Digest == c.Digest {
t.Fatal("large integer rounded", err)
}
var old Specification
if json.Unmarshal(before, &old) != nil {
t.Fatal("snapshot")
}
if _, err := Check(old); err != nil {
t.Fatal(err)
}
after, _ := json.Marshal(old)
if !bytes.Equal(before, after) {
t.Fatal("check mutated definition")
}
for _, raw := range []string{`"bad"`, `null`, `{"extra":1}`, `{"operation":"x","enabled":true,"extra":1}`, `{"operation":"x","enabled":1}`, `{"operation":"x","operation":"y","enabled":true}`} {
if ValidateData(old.InputType, json.RawMessage(raw)) == nil {
t.Fatal("bad instance input accepted")
}
}
if err := ValidateData(old.InputType, json.RawMessage(`{"operation":"x","enabled":true}`)); err != nil {
t.Fatal(err)
}
s = Specification{SchemaVersion: 1, InputType: scalar("NULL"), TimeoutMS: 1, FailurePolicy: "REVIEW", Body: block(scalar("NULL"), literal("null"))}
if _, err := Check(s); err != nil {
t.Fatal("empty typed block must be valid", err)
}
}
func FuzzDefinitionDataNeverPanics(f *testing.F) {
for _, raw := range []string{`{"operation":"x","enabled":true}`, `null`, `{"n":9007199254740993}`, `{"n":1,"n":2}`} {
f.Add([]byte(raw))
}
f.Fuzz(func(t *testing.T, raw []byte) {
if len(raw) > 4096 {
return
}
_ = ValidateData(testSpecification().InputType, raw)
})
}

75
weave/definition/data.go Normal file
View File

@@ -0,0 +1,75 @@
package definition
import (
"bytes"
"encoding/json"
"git.yuntuo.hk/k2safe/orbit/internal/jsonvalue"
)
// ValidateData 在实例输入、任务成功结果、信号和块输出边界复用同一类型契约。
// 使用精确JSON数值文本;大小、重复字段、Unicode和嵌套限制先于类型检查。
func ValidateData(schema Type, raw json.RawMessage) error {
if err := checkType(schema, 0); err != nil {
return err
}
if len(raw) == 0 || len(raw) > 256<<10 {
return invalid("data", "JSON value required, maximum 256 KiB")
}
if _, err := jsonvalue.Canonical(raw); err != nil {
return invalid("data", "invalid JSON, duplicate fields or unsupported value limits")
}
var value any
d := json.NewDecoder(bytes.NewReader(raw))
d.UseNumber()
if err := d.Decode(&value); err != nil {
return invalid("data", "invalid JSON value")
}
return matchData(schema, value)
}
func matchData(schema Type, value any) error {
valid := false
switch schema.Kind {
case "JSON":
return nil
case "NULL":
valid = value == nil
case "STRING":
v, ok := value.(string)
valid = ok && (len(schema.Enum) == 0 || contains(schema.Enum, v))
case "NUMBER":
_, valid = value.(json.Number)
case "BOOLEAN":
_, valid = value.(bool)
case "OBJECT":
v, ok := value.(map[string]any)
if !ok || len(v) != len(schema.Fields) {
return invalid("data", "object fields do not match declared type")
}
for key, t := range schema.Fields {
child, ok := v[key]
if !ok {
return invalid("data", "required object field missing")
}
if err := matchData(t, child); err != nil {
return err
}
}
return nil
case "ARRAY":
v, ok := value.([]any)
if !ok || len(v) > 4096 {
return invalid("data", "array required, maximum 4096 elements")
}
for _, child := range v {
if err := matchData(*schema.Items, child); err != nil {
return err
}
}
return nil
}
if !valid {
return invalid("data", "value does not match declared type")
}
return nil
}

116
weave/definition/service.go Normal file
View File

@@ -0,0 +1,116 @@
package definition
import (
"context"
"strings"
"time"
"unicode/utf8"
"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 ReadAction = "workflows:definitions:read"
const PublishAction = "workflows:definitions:publish"
const MaxDefinitions = 100
const MaxVersions = 100
// CreateInput 创建命名空间内的稳定定义身份,不发布版本或启动实例。
type CreateInput struct {
Name string `json:"name"` // Name 命名空间内唯一ASCII名称,最长128字符。
Description string `json:"description"` // Description 用途说明,最多1024个Unicode字符。
}
// PublishInput 发布不可变版本;同版本内容冲突不能通过更换幂等键覆盖。
type PublishInput struct {
Version string `json:"version"` // Version 研发指定的ASCII版本名,最长64字符,不包含路径分隔符。
Specification Specification `json:"specification"` // Specification 完整且显式的冻结规则。
Reason string `json:"reason"` // Reason 非空发布原因,最多1024个字符,写入审计。
}
// Summary 仅含命名空间级元数据;读取完整版本还须授权所有引用队列。
type Summary struct {
ID core.ID `json:"id"` // ID 定义稳定标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 定义隔离范围。
Name string `json:"name"` // Name 创建时冻结的唯一名称。
Description string `json:"description"` // Description 创建时冻结的用途说明。
Revision uint64 `json:"revision,string"` // Revision 从1开始,每个新发布版本增加1,不能替代业务版本名。
CreatedAt time.Time `json:"created_at"` // CreatedAt UTC数据库创建时间。
UpdatedAt time.Time `json:"updated_at"` // UpdatedAt 最近新版本发布的UTC数据库时间。
}
// Analysis 是静态预算上界;不表示业务任务已执行或已分配配额。
type Analysis struct {
Steps uint32 `json:"steps"` // Steps 全定义静态步骤数。
MaxConcurrency uint32 `json:"max_concurrency"` // MaxConcurrency 并行业务任务上界。
MaxTaskAttempts uint32 `json:"max_task_attempts"` // MaxTaskAttempts 所有路径的保守尝试预算总和。
Queues []core.ID `json:"queues"` // Queues 排序去重队列,始终为数组。
}
// VersionView 返回原始发布内容和身份,不使用可变的latest别名。
type VersionView struct {
ID core.ID `json:"id"` // ID 不可变版本记录标识。
NamespaceID core.ID `json:"namespace_id"` // NamespaceID 隔离范围。
DefinitionID core.ID `json:"definition_id"` // DefinitionID 父定义标识,由应用事务校验。
Version string `json:"version"` // Version 发布版本名。
Specification Specification `json:"specification"` // Specification 已冻结规则,数字精度不经float64转换。
ContentDigest string `json:"content_digest"` // ContentDigest 带域分隔的SHA-256,64位小写十六进制。
PublishedBy core.ID `json:"published_by"` // PublishedBy 原发布凭据标识,不包含凭据秘密。
PublishedAt time.Time `json:"published_at"` // PublishedAt 原发布时间,重复发布不更新。
Analysis Analysis `json:"analysis"` // Analysis 根据结构版本1规则计算的静态检查结果。
}
type CreateResult struct {
Definition Summary `json:"definition"` // Definition 创建事务的原始快照。
Deduplicated bool `json:"deduplicated"` // Deduplicated 是否返回原回执。
}
type PublishResult struct {
Version VersionView `json:"version"` // Version 原版本快照,不包含当前父定义状态。
Deduplicated bool `json:"deduplicated"` // Deduplicated 原键或同版本同内容重复请求。
}
type Store interface {
Create(context.Context, auth.Token, string, CreateInput) (CreateResult, error)
Get(context.Context, auth.Token, core.ID) (Summary, error)
Publish(context.Context, auth.Token, core.ID, string, PublishInput) (PublishResult, error)
Version(context.Context, auth.Token, core.ID, string) (VersionView, error)
}
type Service struct{ store Store }
func New(store Store) *Service { return &Service{store: store} }
func (s *Service) Create(ctx context.Context, t auth.Token, key string, in CreateInput) (CreateResult, error) {
if err := service.ValidateKey(key); err != nil {
return CreateResult{}, err
}
if !name(in.Name, 128) || !textValid(in.Description, false) {
return CreateResult{}, fault.Invalid("invalid workflow definition name or description")
}
return s.store.Create(ctx, t, key, in)
}
func textValid(s string, required bool) bool {
return utf8.ValidString(s) && utf8.RuneCountInString(s) <= 1024 && !strings.ContainsRune(s, 0) && (!required || strings.TrimSpace(s) != "")
}
func (s *Service) Get(ctx context.Context, t auth.Token, id core.ID) (Summary, error) {
if id.Validate() != nil {
return Summary{}, fault.Invalid("invalid definition id")
}
return s.store.Get(ctx, t, id)
}
func (s *Service) Publish(ctx context.Context, t auth.Token, id core.ID, key string, in PublishInput) (PublishResult, error) {
if err := service.ValidateKey(key); err != nil {
return PublishResult{}, err
}
if id.Validate() != nil || !name(in.Version, 64) || !textValid(in.Reason, true) {
return PublishResult{}, fault.Invalid("valid definition id, version and reason required")
}
if _, err := Check(in.Specification); err != nil {
return PublishResult{}, fault.Invalid(err.Error())
}
return s.store.Publish(ctx, t, id, key, in)
}
func (s *Service) Version(ctx context.Context, t auth.Token, id core.ID, version string) (VersionView, error) {
if id.Validate() != nil || !name(version, 64) {
return VersionView{}, fault.Invalid("invalid definition id or version")
}
return s.store.Version(ctx, t, id, version)
}

130
weave/definition/types.go Normal file
View File

@@ -0,0 +1,130 @@
// Package definition 描述可发布的有限流程;只解释类型和数据引用,不执行上传的业务代码。
package definition
import (
"encoding/json"
core "git.yuntuo.hk/k2safe/orbit/model"
pulse "git.yuntuo.hk/k2safe/orbit/pulse/model"
)
// Specification 是结构版本1的完整定义。版本发布后禁止原地修改。
type Specification struct {
SchemaVersion uint32 `json:"schema_version"` // SchemaVersion 当前必须为1,和业务发布版本分开。
InputType Type `json:"input_type"` // InputType 实例输入契约,OBJECT字段全部必填且不允许额外字段。
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 从实例受理起计的有限总时限,1毫秒至30天,暂停不续期。
FailurePolicy string `json:"failure_policy"` // FailurePolicy REVIEW或FAIL_AFTER_SETTLED;未知效果始终先核查。
Body Block `json:"body"` // Body 顶层顺序块;组合节点内部同样使用有限顺序块。
}
// Type 是有限的类型描述;JSON表示只能整体传递的任意JSON,不能据此访问未知字段。
type Type struct {
Kind string `json:"kind"` // Kind STRING、NUMBER、BOOLEAN、NULL、OBJECT、ARRAY或JSON。
Fields map[string]Type `json:"fields,omitempty"` // Fields 仅OBJECT使用,最多64项;字段名称为ASCII标识。
Items *Type `json:"items,omitempty"` // Items 仅ARRAY使用,数组元素采用同一契约。
Enum []string `json:"enum,omitempty"` // Enum 仅STRING使用,可选的1至32个互异值,按精确文本比较。
}
// Value 明确区分常量、引用和构造对象/数组,禁止字符串插值和任意脚本。
type Value struct {
Kind string `json:"kind"` // Kind LITERAL、REFERENCE、OBJECT或ARRAY。
Literal json.RawMessage `json:"literal,omitempty" nullable:"true"` // Literal 仅LITERAL使用,保留精确数字,可以显式为null。
Reference *Reference `json:"reference,omitempty"` // Reference 仅REFERENCE使用,必须指向当前路径可用的输入或结果。
Fields map[string]Value `json:"fields,omitempty"` // Fields 仅OBJECT使用,字段值递归解析,最多64项。
Items []Value `json:"items,omitempty"` // Items 仅ARRAY使用,最多256项;业务大数组可整体引用。
}
type Reference struct {
Source string `json:"source"` // Source input、workflow或在当前词法作用域已完成的步骤key。
Path []string `json:"path,omitempty"` // Path 最多8层已声明OBJECT字段;空路径引用整个值,不支持动态路径或数组下标。
}
// Block 的输出是组合节点向外暴露的唯一数据出口;内部步骤不能被外部直接引用。
type Block struct {
Steps []Step `json:"steps,omitempty"` // Steps 按顺序执行;空块可直接映射输入,所有分支仍需显式输出。
OutputType Type `json:"output_type"` // OutputType 本块输出契约。
Output Value `json:"output"` // Output 全部步骤完成后解析的输出表达式。
}
type Step struct {
Key string `json:"key"` // Key 全定义唯一ASCII标识,1至64字符,不能为input或workflow。
Kind string `json:"kind"` // Kind TASK、BRANCH、PARALLEL、SIGNAL、TIMER或POLL;必须只提供对应配置。
Task *Task `json:"task,omitempty"` // Task 普通业务工作,交由独立Worker处理。
Branch *Branch `json:"branch,omitempty"` // Branch 按顺序选择第一个匹配路径,必须有默认路径。
Parallel *Parallel `json:"parallel,omitempty"` // Parallel 静态有限分支,全部成功后汇合。
Signal *Signal `json:"signal,omitempty"` // Signal 等待一次按步骤和业务操作匹配的外部输入。
Timer *Timer `json:"timer,omitempty"` // Timer 持久化时间等待,不占业务Worker槽位。
Poll *Poll `json:"poll,omitempty"` // Poll 有界查询,轮次预算与每轮Pulse尝试预算独立。
}
type Task struct {
QueueID core.ID `json:"queue_id"` // QueueID 发布及启动时都需按命名空间和当前权限校验。
Handler string `json:"handler"` // Handler 已命名的业务处理器;定义不携带可执行源码。
HandlerVersion string `json:"handler_version"` // HandlerVersion 固定的处理器版本。
PayloadVersion string `json:"payload_version"` // PayloadVersion 固定的输入契约版本。
InputType Type `json:"input_type"` // InputType 输入映射必须满足的处理器参数契约。
Input Value `json:"input"` // Input 在激活时根据已保存数据解析,不使用当前时间或随机数。
ResultType Type `json:"result_type"` // ResultType 权威成功结果契约,不匹配时进入核查而不推进。
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 单任务总时限,正值且不超过流程时限,运行时还受实例剩余时限限制。
Retry pulse.RetryPolicy `json:"retry"` // Retry 显式冻结有限策略,包含首次尝试,最多100次。
RecoverySafety pulse.RecoverySafety `json:"recovery_safety"` // RecoverySafety 显式RECONCILE_ONLY或IDEMPOTENT。
Priority int16 `json:"priority,omitempty"` // Priority 冻结优先级0至9。
BusinessKey *Value `json:"business_key,omitempty"` // BusinessKey 可选字符串映射,只用于业务追踪,不代表幂等。
ConcurrencyKey *Value `json:"concurrency_key,omitempty"` // ConcurrencyKey 可选字符串映射,采用Pulse共享资源许可规则。
}
type Predicate struct {
Op string `json:"op"` // Op EQ/NE/LT/LE/GT/GE或AND/OR/NOT;只比较已声明的标量。
Left *Value `json:"left,omitempty"` // Left 比较操作左值,逻辑操作不得携带。
Right *Value `json:"right,omitempty"` // Right 比较操作右值,必须与左值同种标量。
Arguments []Predicate `json:"arguments,omitempty"` // Arguments AND/OR为2至16项,NOT为1项,深度最多8层。
}
type Branch struct {
Cases []BranchCase `json:"cases"` // Cases 1至16个按顺序匹配的条件路径。
Default *Block `json:"default"` // Default 必填,穷尽未匹配情况,不能静默跳过。
}
type BranchCase struct {
When Predicate `json:"when"` // When 在分支激活时根据前置持久化数据计算。
Body Block `json:"body"` // Body 选中路径;所有路径输出类型必须一致。
}
type Parallel struct {
Branches []ParallelBranch `json:"branches"` // Branches 2至8个静态分支;结果为各分支名称对应输出的OBJECT。
}
type ParallelBranch struct {
Name string `json:"name"` // Name 本并行节点内唯一ASCII字段标识。
Body Block `json:"body"` // Body 分支内部独立顺序块;不能读取兄弟分支尚未汇合的数据。
}
type Signal struct {
Name string `json:"name"` // Name 外部信号类型,需同时匹配实例、步骤激活和业务关联。
PayloadVersion string `json:"payload_version"` // PayloadVersion 外部输入契约版本。
Correlation Value `json:"correlation"` // Correlation 必须解析为非空业务操作字符串,运行时冻结在等待记录。
PayloadType Type `json:"payload_type"` // PayloadType 信号内容及该节点成功输出的类型。
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的有限等待时限,不超过流程时限;暂停不续期。
}
type Timer struct {
DelayMS uint64 `json:"delay_ms"` // DelayMS 激活起的正等待时长,不超过流程时限;输出为NULL。
}
type Poll struct {
Task Task `json:"task"` // Task 同一查询处理器及固定输入;查询不得再次调用原业务提交接口。
MaxRounds uint32 `json:"max_rounds"` // MaxRounds 1至1000,轮次含首次;每轮一个任务,单轮内部重试不新增轮次。
TimeoutMS uint64 `json:"timeout_ms"` // TimeoutMS 激活起的总查询时限,不超过流程时限。
IntervalMS uint64 `json:"interval_ms"` // IntervalMS 从上一轮处理中结果受理后等待,至少1000毫秒且小于查询总时限;不补发错过的轮次。
StatusPath []string `json:"status_path,omitempty"` // StatusPath 成功查询结果内的STRING枚举字段路径,空表示整个结果就是枚举。
Pending []string `json:"pending"` // Pending 表示仍在处理的枚举值,至少1项。
Succeeded []string `json:"succeeded"` // Succeeded 表示业务确认成功的枚举值,至少1项。
Failed []string `json:"failed,omitempty"` // Failed 表示业务确认失败的枚举值。
Review []string `json:"review,omitempty"` // Review 表示需要核查的枚举值;四组必须无交集并覆盖整个状态枚举。
FinalSignal *Signal `json:"final_signal,omitempty"` // FinalSignal 可选最终回执,其内容类型与查询结果一致,只接受非Pending状态。
}
// Checked 是发布验证的计算结果;资源上限按所有静态路径保守计数,不代表运行已发生。
type Checked struct {
Steps uint32 // Steps 静态节点总数,不按轮次展开,最多128。
MaxConcurrency uint32 // MaxConcurrency 结构可产生的并行工作上界,最多32。
MaxTaskAttempts uint32 // MaxTaskAttempts 各静态任务尝试数与查询轮次乘积总和,最多10000。
Queues []core.ID // Queues 排序且去重的引用队列,发布/启动必须检查权限和归属。
Digest core.Digest // Digest 规范化JSON的SHA-256,用于不可变版本内容比较。
}