feat: expose workflow runtime through HTTP and Go SDK

This commit is contained in:
2026-09-18 06:51:08 +08:00
parent af1ae4cb21
commit b9e4191c6b
50 changed files with 2221 additions and 143 deletions

View File

@@ -233,7 +233,7 @@ func writeError(w http.ResponseWriter, id string, err error) {
if !errors.As(err, &known) {
known = fault.Unavailable
}
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409}[known.Code]
status := map[string]int{"INVALID_ARGUMENT": 400, "UNAUTHENTICATED": 401, "PERMISSION_DENIED": 403, "NOT_FOUND": 404, "STATE_CONFLICT": 409, "IDEMPOTENCY_CONFLICT": 409, "BODY_TOO_LARGE": 413, "UNSUPPORTED_MEDIA_TYPE": 415, "DELIVERY_LEASE_LOST": 409, "EVENT_HISTORY_EXPIRED": 410, "SUBSCRIPTION_LIMIT": 409, "WORKFLOW_DEFINITION_LIMIT": 409, "WORKFLOW_VERSION_LIMIT": 409, "WORKFLOW_INSTANCE_LIMIT": 409, "WORKFLOW_SIGNAL_LIMIT": 409}[known.Code]
if status == 0 {
status = 503
}
@@ -320,10 +320,23 @@ func exactFields(raw []byte, t reflect.Type) error {
}
names := map[string]reflect.Type{}
nullable := map[string]bool{}
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if name != "" && name != "-" {
var collect func(reflect.Type) error
collect = func(typ reflect.Type) error {
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
name := strings.Split(field.Tag.Get("json"), ",")[0]
if field.Anonymous && name == "" && field.Type.Kind() == reflect.Struct {
if err := collect(field.Type); err != nil {
return err
}
continue
}
if name == "" || name == "-" {
continue
}
if _, exists := names[name]; exists {
return errors.New("ambiguous embedded JSON field")
}
names[name] = field.Type
nullable[name] = field.Tag.Get("nullable") == "true"
if field.Tag.Get("required") == "true" {
@@ -332,7 +345,12 @@ func exactFields(raw []byte, t reflect.Type) error {
}
}
}
return nil
}
if err := collect(t); err != nil {
return err
}
for name, value := range object {
field, ok := names[name]
if !ok {

View File

@@ -0,0 +1,102 @@
package httpserver
import (
"context"
"net/http"
core "git.yuntuo.hk/k2safe/orbit/model"
"git.yuntuo.hk/k2safe/orbit/platform/auth"
"git.yuntuo.hk/k2safe/orbit/weave/control"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
"git.yuntuo.hk/k2safe/orbit/weave/signal"
)
// RegisterWorkflows 只适配协议,实际授权、并发版本、原键回执及审计由事务服务校验。
func RegisterWorkflows(server *http.Server, instances *instance.Service, controls *control.Service, signals *signal.Service, reviews *recovery.Service) {
mux := server.Handler.(*http.ServeMux)
mux.HandleFunc("POST /v1/workflows", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
var in instance.StartInput
if err := decodeBody(r, &in); err != nil {
return nil, 0, err
}
key, err := idempotencyKey(r)
if err != nil {
return nil, 0, err
}
out, err := instances.Start(r.Context(), t, key, in)
return out, createdStatus(out.Deduplicated), err
}))
mux.HandleFunc("GET /v1/workflows/{id}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := instances.Get(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
mux.HandleFunc("GET /v1/workflows/{id}/steps", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := instances.Steps(r.Context(), t, core.ID(r.PathValue("id")))
return out, 200, err
}))
mux.HandleFunc("GET /v1/workflows/{id}/steps/{key}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := instances.Step(r.Context(), t, core.ID(r.PathValue("id")), r.PathValue("key"))
return out, 200, err
}))
mux.HandleFunc("GET /v1/workflows/{id}/operations", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
in, err := historyQuery(r, true)
if err != nil {
return nil, 0, err
}
out, err := instances.ListOperations(r.Context(), t, core.ID(r.PathValue("id")), in)
return out, 200, err
}))
mux.HandleFunc("GET /v1/workflows/{id}/operations/{operation}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := instances.GetOperation(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("operation")))
return out, 200, err
}))
mux.HandleFunc("POST /v1/workflows/{id}/control", workflowMutation(controls.Apply, func(control.Result) int { return 200 }))
mux.HandleFunc("POST /v1/workflows/{id}/signals", workflowMutation(signals.Send, func(v signal.Result) int { return createdStatus(v.Deduplicated) }))
mux.HandleFunc("GET /v1/workflows/{id}/signals/{signal}", wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
out, err := signals.Get(r.Context(), t, core.ID(r.PathValue("id")), core.ID(r.PathValue("signal")))
return out, 200, err
}))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/task-review", workflowMutation(reviews.ReviewTask, recoveryStatus))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/step-resolve", workflowMutation(reviews.ResolveStep, recoveryStatus))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/continue", workflowMutation(reviews.Continue, recoveryStatus))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/step-retry", workflowMutation(reviews.RetryStep, recoveryStatus))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/poll-continue", workflowMutation(reviews.ContinuePoll, recoveryStatus))
mux.HandleFunc("POST /v1/workflows/{id}/recovery/wait-rearm", workflowMutation(reviews.RearmWait, recoveryStatus))
}
func recoveryStatus(recovery.Result) int { return 200 }
func workflowMutation[I, O any](call func(context.Context, auth.Token, core.ID, string, I) (O, error), status func(O) int) http.HandlerFunc {
return wrap(func(r *http.Request, t auth.Token) (any, int, error) {
if _, err := historyQuery(r, false); err != nil {
return nil, 0, err
}
var in I
if err := decodeBody(r, &in); err != nil {
return nil, 0, err
}
key, err := idempotencyKey(r)
if err != nil {
return nil, 0, err
}
out, err := call(r.Context(), t, core.ID(r.PathValue("id")), key, in)
return out, status(out), err
})
}

View File

@@ -0,0 +1,69 @@
package httpserver
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"git.yuntuo.hk/k2safe/orbit/internal/fault"
"git.yuntuo.hk/k2safe/orbit/weave/instance"
"git.yuntuo.hk/k2safe/orbit/weave/recovery"
)
func TestWorkflowRecoveryEmbeddedJSON(t *testing.T) {
valid := `{"expected_revision":"9007199254740993","reason":"核查","evidence_ref":"evidence:1","step_key":"query","activation_id":"11111111111111111111111111111111","expected_step_revision":"3","pending_confirmed":true}`
for _, tc := range []struct {
name, body string
valid bool
}{
{"flat embedded fields", valid, true},
{"unknown parent", strings.Replace(valid, `"reason"`, `"Reason"`, 1), false},
{"unknown step", strings.Replace(valid, `"step_key"`, `"StepKey"`, 1), false},
{"nested decision", `{"Decision":{"expected_revision":"1"},"pending_confirmed":true}`, false},
{"missing confirmation", strings.Replace(valid, `,"pending_confirmed":true`, "", 1), false},
{"null confirmation", strings.Replace(valid, `"pending_confirmed":true`, `"pending_confirmed":null`, 1), false},
{"numeric revision", strings.Replace(valid, `"9007199254740993"`, `9007199254740993`, 1), false},
{"duplicate inherited field", strings.Replace(valid, `"reason":"核查"`, `"reason":"核查","reason":"改写"`, 1), false},
} {
t.Run(tc.name, func(t *testing.T) {
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
var in recovery.ContinuePollInput
err := decodeBody(r, &in)
if (err == nil) != tc.valid {
t.Fatal(err)
}
if tc.valid && (in.ExpectedRevision != 9007199254740993 || in.ExpectedStepRevision != 3 || in.StepKey != "query") {
t.Fatal("embedded fields lost")
}
})
}
for _, raw := range []string{`{"execution_settled":true,"result":null}`, `{"execution_settled":true,"result":{"n":9007199254740993}}`} {
r := httptest.NewRequest("POST", "/", strings.NewReader(raw))
r.Header.Set("Content-Type", "application/json")
var in recovery.ResolveStepInput
if err := decodeBody(r, &in); err != nil {
t.Fatal(err)
}
if !json.Valid(in.Result) {
t.Fatal("result lost")
}
}
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"definition_id":"11111111111111111111111111111111","version":"v1","input":null}`))
r.Header.Set("Content-Type", "application/json")
var in instance.StartInput
if err := decodeBody(r, &in); err != nil || string(in.Input) != "null" {
t.Fatal("explicit null input", err)
}
}
func TestWorkflowLimitsAreExplicitConflicts(t *testing.T) {
for _, code := range []string{"WORKFLOW_INSTANCE_LIMIT", "WORKFLOW_SIGNAL_LIMIT"} {
w := httptest.NewRecorder()
writeError(w, "request", fault.New(code, "limit reached"))
if w.Code != 409 {
t.Fatal(code, w.Code)
}
}
}