diff --git a/internal/intentgate/gate.go b/internal/intentgate/gate.go index a54dec2..b7c7141 100644 --- a/internal/intentgate/gate.go +++ b/internal/intentgate/gate.go @@ -12,11 +12,26 @@ import ( agentintent "agent-intent/intent" ) +// SlotQuestion 是命中意图后仍缺的必填槽位,以及建议的提问。 +type SlotQuestion struct { + Key string + Question string +} + +// MatchResult 是一次识别。Branch 与 Match 相同。 +type MatchResult struct { + Branch string + ID string + Slots map[string]string + Missing []SlotQuestion +} + type Gate struct { - mu sync.RWMutex - reg *agentintent.Registry - rec *agentintent.FunnelRecognizer - specs int + mu sync.RWMutex + reg *agentintent.Registry + rec *agentintent.FunnelRecognizer + specs int + specsByID map[string]agentintent.IntentSpec } func New() *Gate { @@ -27,7 +42,7 @@ func New() *Gate { func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error { if len(overlay) == 0 { g.mu.Lock() - g.reg, g.specs = nil, 0 + g.reg, g.specs, g.specsByID = nil, 0, nil g.mu.Unlock() return nil } @@ -47,8 +62,12 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error { if err != nil { return fmt.Errorf("intentgate: build: %w", err) } + byID := make(map[string]agentintent.IntentSpec, len(specs)) + for _, s := range specs { + byID[s.ID] = s + } g.mu.Lock() - g.reg, g.specs = reg, len(specs) + g.reg, g.specs, g.specsByID = reg, len(specs), byID g.mu.Unlock() return nil } @@ -56,28 +75,50 @@ func (g *Gate) Rebuild(ctx context.Context, overlay []json.RawMessage) error { // Match 识别自由输入;返回 (branch, topID)。 // branch:matched | ambiguous | fallback | ""(未配置/系统级错误 → 回退通道)。 func (g *Gate) Match(ctx context.Context, input string) (string, string) { + res := g.Recognize(ctx, input) + return res.Branch, res.ID +} + +// Recognize 在 Match 的结果上带上槽位和缺失提问。 +func (g *Gate) Recognize(ctx context.Context, input string) MatchResult { g.mu.RLock() - reg, rec := g.reg, g.rec + reg, rec, byID := g.reg, g.rec, g.specsByID g.mu.RUnlock() if reg == nil { - return "", "" + return MatchResult{} } res, err := rec.Recognize(ctx, input, reg.Snapshot()) if err != nil { - return "", "" - } - topID := "" - if res.Top != nil { - topID = res.Top.ID + return MatchResult{} } + out := MatchResult{Branch: "fallback"} switch res.Branch { case agentintent.BranchMatched: - return "matched", topID + out.Branch = "matched" case agentintent.BranchAmbiguous: - return "ambiguous", topID - default: - return "fallback", topID + out.Branch = "ambiguous" } + if res.Top == nil { + return out + } + out.ID = res.Top.ID + if len(res.Top.Slots) > 0 { + out.Slots = map[string]string{} + for k, v := range res.Top.Slots { + out.Slots[k] = v + } + } + spec := byID[out.ID] + for _, key := range res.Top.MissingRequiredSlots { + q := key + for _, sl := range spec.Slots { + if sl.Key == key && sl.Question != "" { + q = sl.Question + } + } + out.Missing = append(out.Missing, SlotQuestion{Key: key, Question: q}) + } + return out } // Enabled 报告是否装载了意图(诊断/设置展示用)。 diff --git a/internal/intentgate/gate_test.go b/internal/intentgate/gate_test.go index 0c0c917..813f41b 100644 --- a/internal/intentgate/gate_test.go +++ b/internal/intentgate/gate_test.go @@ -65,3 +65,29 @@ func TestGateFailClosed(t *testing.T) { t.Error("empty rebuild should disable gate") } } + +func TestRecognizeKeepsSlots(t *testing.T) { + g := New() + spec := raw(t, map[string]any{ + "id": "demo.brief", "domain": "demo", "version": 1, + "matcher": map[string]any{"includes_any": []string{"简报"}}, + "slots": []map[string]any{{ + "key": "topic", "required": true, "pattern": `主题[::]\s*(\S+)`, + "question": "主题是什么?", + }}, + }) + if err := g.Rebuild(context.Background(), []json.RawMessage{spec}); err != nil { + t.Fatal(err) + } + miss := g.Recognize(context.Background(), "写一份简报") + if miss.Branch != "matched" || miss.ID != "demo.brief" || len(miss.Missing) != 1 || miss.Missing[0].Question != "主题是什么?" { + t.Fatalf("%+v", miss) + } + hit := g.Recognize(context.Background(), "主题:核能 写一份简报") + if hit.Slots["topic"] != "核能" || len(hit.Missing) != 0 { + t.Fatalf("%+v", hit) + } + if branch, id := g.Match(context.Background(), "写一份简报"); branch != "matched" || id != "demo.brief" { + t.Fatalf("Match wrapper = %s %s", branch, id) + } +} diff --git a/internal/runmgr/entry_test.go b/internal/runmgr/entry_test.go index 2da2857..33e52c5 100644 --- a/internal/runmgr/entry_test.go +++ b/internal/runmgr/entry_test.go @@ -4,6 +4,7 @@ import ( "testing" "agent-system-backend/internal/runtime" + "agent-system-backend/internal/workflow" core "agent-system-core" ) @@ -13,16 +14,19 @@ func (n nameRunner) Name() string { return n.name } func (n nameRunner) Execute(rc runtime.RunCtx) error { return nil } func TestSelectConversationRunner(t *testing.T) { - m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}} - if got := selectConversationRunner(m, core.EffectiveSettings{}); got.Name() != "direct" { + m := &Manager{runner: nameRunner{name: "direct"}, mock: nameRunner{name: "mock"}, squad: nameRunner{name: "squad"}, wfRunner: nameRunner{name: "workflow"}} + if got := selectConversationRunner(m, core.EffectiveSettings{}, nil); got.Name() != "direct" { t.Fatalf("default = %s", got.Name()) } squadEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad"}} - if got := selectConversationRunner(m, squadEff); got.Name() != "squad" { + if got := selectConversationRunner(m, squadEff, nil); got.Name() != "squad" { t.Fatalf("squad entry = %s", got.Name()) } mockEff := core.EffectiveSettings{"runtime": map[string]any{"entry": "squad", "mock": true}} - if got := selectConversationRunner(m, mockEff); got.Name() != "mock" { + if got := selectConversationRunner(m, mockEff, nil); got.Name() != "mock" { t.Fatalf("mock wins = %s", got.Name()) } + if got := selectConversationRunner(m, mockEff, &workflow.Launch{}); got.Name() != "workflow" { + t.Fatalf("workflow wins = %s", got.Name()) + } } diff --git a/internal/runmgr/runmgr.go b/internal/runmgr/runmgr.go index 93aca22..4c8e056 100644 --- a/internal/runmgr/runmgr.go +++ b/internal/runmgr/runmgr.go @@ -20,6 +20,7 @@ import ( "agent-system-backend/internal/settings" "agent-system-backend/internal/store" "agent-system-backend/internal/tools" + "agent-system-backend/internal/workflow" core "agent-system-core" ) @@ -34,6 +35,7 @@ type Manager struct { mock runtime.Runner // 会话脚本回放(演示/测试与 mock 模式) mockSub runtime.Runner // 子代理脚本回放 squad runtime.Runner // runtime.entry=squad 时的对话入口 + wfRunner runtime.Runner // 意图或工具启动的工作流;nil 时用 runtime.WorkflowRunner gate IntentGate cards *cardsvc.Service // 可选意图闸门(nil = 未启用,行为回退) // OnUsage 在 run 结束时报告 token(日预算记账;nil = 不记)。 @@ -147,6 +149,15 @@ type runCtx struct { subTask string subHint string subAgent string + launch *workflow.Launch +} + +// Workflow 返回这次 run 启动时拷贝的工作流。普通回合没有。 +func (c *runCtx) Workflow() (workflow.Launch, bool) { + if c.launch == nil { + return workflow.Launch{}, false + } + return *c.launch, true } func (c *runCtx) Context() context.Context { return c.ctx } @@ -693,8 +704,18 @@ func (t *toolCtx) PeerConversationMessages(conversationID string, limit int) ([] // SetSquadRunner 注入 runtime.entry=squad 时使用的对话入口。 func (m *Manager) SetSquadRunner(r runtime.Runner) { m.squad = r } -// selectConversationRunner 按设置选择入口。mock 优先;squad 仅在注入了入口时生效。 -func selectConversationRunner(m *Manager, eff core.EffectiveSettings) runtime.Runner { +// SetWorkflowRunner 注入工作流执行器。测试用来替换默认实现。 +func (m *Manager) SetWorkflowRunner(r runtime.Runner) { m.wfRunner = r } + +// selectConversationRunner 按设置选择入口。带工作流定义时走工作流执行器; +// 否则 mock 优先,squad 仅在注入了入口时生效。 +func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *workflow.Launch) runtime.Runner { + if launch != nil { + if m.wfRunner != nil { + return m.wfRunner + } + return runtime.WorkflowRunner{} + } if eff.GetBool("runtime.mock", false) { return m.mock } @@ -711,7 +732,25 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core. if err := m.claimConversation(conv.ID); err != nil { return core.Run{}, err } - return m.startClaimedRun(conv, userMessage, attachments, inputs) + return m.startClaimedRun(conv, userMessage, attachments, inputs, nil) +} + +// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝, +// 之后插件卸载不会改到已经在跑的这份。 +func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) { + if err := m.claimConversation(conv.ID); err != nil { + return core.Run{}, err + } + copiedSlots := map[string]string{} + for k, v := range slots { + copiedSlots[k] = v + } + launch := &workflow.Launch{ + Def: workflow.Clone(def), + Slots: copiedSlots, + Missing: append([]workflow.SlotQuestion(nil), missing...), + } + return m.startClaimedRun(conv, userMessage, nil, nil, launch) } func (m *Manager) claimConversation(conversationID string) error { @@ -727,7 +766,7 @@ func (m *Manager) claimConversation(conversationID string) error { return nil } -func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) { +func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) { prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID) eff := m.settings.Effective(conv.Owner, conv.ProjectID) run := core.Run{ @@ -744,9 +783,9 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa m.active[run.ID] = h m.mu.Unlock() - runner := selectConversationRunner(m, eff) + runner := selectConversationRunner(m, eff, launch) inputPaths, _ := m.stageInputs(conv, eff, inputs) - rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths} + rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch} go m.execute(rc, runner) return run, nil } @@ -1235,7 +1274,7 @@ func (m *Manager) startNoticeRun(convID string, items []bgNotice) error { m.releaseConversation(convID) return err } - if _, err := m.startClaimedRun(conv, msg, nil, nil); err != nil { + if _, err := m.startClaimedRun(conv, msg, nil, nil, nil); err != nil { m.releaseConversation(convID) return err } diff --git a/internal/runmgr/workflow_route_test.go b/internal/runmgr/workflow_route_test.go new file mode 100644 index 0000000..d0e8340 --- /dev/null +++ b/internal/runmgr/workflow_route_test.go @@ -0,0 +1,82 @@ +package runmgr + +import ( + "path/filepath" + "testing" + "time" + + "agent-system-backend/internal/bus" + "agent-system-backend/internal/profiles" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/internal/tools" + "agent-system-backend/internal/workflow" + core "agent-system-core" +) + +type launchCapture struct { + ch chan workflow.Launch +} + +func (c launchCapture) Name() string { return "workflow-capture" } + +func (c launchCapture) Execute(rc runtime.RunCtx) error { + src, ok := rc.(interface { + Workflow() (workflow.Launch, bool) + }) + if !ok { + return nil + } + launch, ok := src.Workflow() + if ok { + c.ch <- launch + } + return nil +} + +func TestStartWorkflowRunCopiesDefinition(t *testing.T) { + st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "wf.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { st.Close() }) + if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil { + t.Fatal(err) + } + reg := tools.NewRegistry() + prof := profiles.New(st) + if err := prof.EnsureDefault(); err != nil { + t.Fatal(err) + } + direct := &gateRunner{parts: []core.Part{{Type: core.PartText, Text: "direct"}}} + cap := launchCapture{ch: make(chan workflow.Launch, 1)} + mgr := New(st, bus.New(), reg, prof, settings.NewService(st), direct, noopRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{}) + mgr.SetWorkflowRunner(cap) + + proj, err := st.CreateProject(store.OwnerDefault, "p") + if err != nil { + t.Fatal(err) + } + conv, err := st.CreateConversation(store.OwnerDefault, proj.ID, "c", "") + if err != nil { + t.Fatal(err) + } + msg := core.Message{ConversationID: conv.ID, Role: core.RoleUser, Parts: []core.Part{{Type: core.PartText, Text: "写简报"}}} + def := core.WorkflowDef{ID: "demo.brief", Title: "简报", Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}}} + if _, err := mgr.StartWorkflowRun(conv, msg, def, map[string]string{"topic": "核能"}, []workflow.SlotQuestion{{Key: "audience", Question: "给谁看?"}}); err != nil { + t.Fatal(err) + } + def.Title = "被改掉" + select { + case launch := <-cap.ch: + if launch.Def.ID != "demo.brief" || launch.Def.Title != "简报" || launch.Slots["topic"] != "核能" { + t.Fatalf("%+v", launch) + } + if len(launch.Missing) != 1 || launch.Missing[0].Question != "给谁看?" { + t.Fatalf("missing %+v", launch.Missing) + } + case <-time.After(time.Second): + t.Fatal("workflow runner was not selected") + } +} diff --git a/internal/runtime/workflow.go b/internal/runtime/workflow.go new file mode 100644 index 0000000..eaf7d1b --- /dev/null +++ b/internal/runtime/workflow.go @@ -0,0 +1,31 @@ +package runtime + +import ( + "fmt" + + "agent-system-backend/internal/workflow" +) + +// WorkflowRunner 执行启动时拷贝下来的工作流定义。 +type WorkflowRunner struct{} + +func (WorkflowRunner) Name() string { return "workflow" } + +func (WorkflowRunner) Execute(rc RunCtx) error { + return runWorkflow(rc) +} + +type workflowSource interface { + Workflow() (workflow.Launch, bool) +} + +func runWorkflow(rc RunCtx) error { + src, ok := rc.(workflowSource) + if !ok { + return fmt.Errorf("workflow: 当前运行没有工作流定义") + } + if _, ok := src.Workflow(); !ok { + return fmt.Errorf("workflow: 当前运行没有工作流定义") + } + return fmt.Errorf("workflow: 执行器尚未接上") +} diff --git a/internal/server/server.go b/internal/server/server.go index 52851c1..927f17a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -22,6 +22,7 @@ import ( "agent-system-backend/internal/cardsvc" "agent-system-backend/internal/cmdbus" "agent-system-backend/internal/ingest" + "agent-system-backend/internal/intentgate" "agent-system-backend/internal/plugins" "agent-system-backend/internal/profiles" "agent-system-backend/internal/prompts" @@ -70,6 +71,18 @@ type NLIntentGate interface { Match(ctx context.Context, input string) (string, string) } +type intentRecognizer interface { + Recognize(ctx context.Context, input string) intentgate.MatchResult +} + +func (s *Server) recognize(ctx context.Context, text string) intentgate.MatchResult { + if rg, ok := s.Gate.(intentRecognizer); ok { + return rg.Recognize(ctx, text) + } + branch, id := s.Gate.Match(ctx, text) + return intentgate.MatchResult{Branch: branch, ID: id} +} + // WireBuiltinCommands 注册内置命令(`/` 菜单全清单的数据源;处理器为宿主用例适配)。 // 响应保持既有 {command, ...} 契约(frontend store.sendMessage 按此消费)。 func (s *Server) WireBuiltinCommands() { @@ -562,14 +575,15 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeStoreErr(w, err) return } - // chat 入口同源(D27):意图闸门命中且命令总线承接 → 统一分发(同审计),不起 run。 - if s.Bus != nil && s.Gate != nil && !strings.HasPrefix(text, "/") { - if branch, topID := s.Gate.Match(r.Context(), text); branch == "matched" && s.Bus.HasCommand(topID) { + // 先命令总线,再绑定工作流,最后才是普通回合。 + if s.Gate != nil && !strings.HasPrefix(text, "/") { + recognized := s.recognize(r.Context(), text) + if recognized.Branch == "matched" && s.Bus != nil && s.Bus.HasCommand(recognized.ID) { resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{ - ID: topID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner, + ID: recognized.ID, Params: map[string]any{"text": text}, Entrance: "chat", Actor: owner, Owner: owner, ConversationID: convID, AnchorMessageID: msg.ID, }) - out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": topID} + out := map[string]any{"message": msg, "receipt": resp.Receipt, "command": recognized.ID} if resp.Card != nil { out["card"] = resp.Card } @@ -581,6 +595,17 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, out) return } + if recognized.Branch == "matched" && s.Workflows != nil { + if def, ok := s.Workflows.ByIntent(recognized.ID); ok { + missing := make([]workflow.SlotQuestion, len(recognized.Missing)) + for i, q := range recognized.Missing { + missing[i] = workflow.SlotQuestion{Key: q.Key, Question: q.Question} + } + run, err := s.mgr.StartWorkflowRun(conv, msg, def, recognized.Slots, missing) + writeJSONOr(w, map[string]any{"message": msg, "run": run}, err) + return + } + } } run, err := s.mgr.StartConversationRun(conv, msg, parsedDocs, inputs) writeJSONOr(w, map[string]any{"message": msg, "run": run}, err) diff --git a/internal/server/workflow_route_test.go b/internal/server/workflow_route_test.go new file mode 100644 index 0000000..2a7be82 --- /dev/null +++ b/internal/server/workflow_route_test.go @@ -0,0 +1,111 @@ +package server + +import ( + "context" + "encoding/json" + "testing" + "time" + + agentcommand "agent-command" + "agent-system-backend/internal/cmdbus" + "agent-system-backend/internal/intentgate" + "agent-system-backend/internal/runtime" + "agent-system-backend/internal/settings" + "agent-system-backend/internal/store" + "agent-system-backend/internal/workflow" + core "agent-system-core" +) + +type routeCapture struct { + ch chan workflow.Launch +} + +func (c routeCapture) Name() string { return "route-capture" } + +func (c routeCapture) Execute(rc runtime.RunCtx) error { + src, _ := rc.(interface { + Workflow() (workflow.Launch, bool) + }) + if src == nil { + return nil + } + if launch, ok := src.Workflow(); ok { + c.ch <- launch + } + return nil +} + +func TestCommandBusBeatsWorkflow(t *testing.T) { + srv, st := newTestServer(t) + if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil { + t.Fatal(err) + } + ch := make(chan workflow.Launch, 1) + srv.mgr.SetWorkflowRunner(routeCapture{ch}) + gate := intentgate.New() + if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil { + t.Fatal(err) + } + srv.Gate = gate + reg := workflow.NewRegistry() + reg.Set([]core.WorkflowDef{{ + ID: "demo.brief", Title: "简报", IntentID: "demo.brief", + Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}}, + }}, nil) + srv.Workflows = reg + if err := srv.Bus.RegisterBuiltin(agentcommand.CommandSpec{ID: "demo.brief", Title: "简报命令"}, "", func(context.Context, cmdbus.Request) (any, error) { + return map[string]any{"ok": true}, nil + }); err != nil { + t.Fatal(err) + } + _, convID := setupConversation(t, srv.Handler()) + code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"}) + if code != 200 || out["command"] != "demo.brief" { + t.Fatalf("status %d body %v", code, out) + } + if _, ok := out["run"]; ok { + t.Fatal("a command hit must not start a workflow run") + } + select { + case <-ch: + t.Fatal("workflow runner started for a command") + default: + } +} + +func TestIntentStartsWorkflowRun(t *testing.T) { + srv, st := newTestServer(t) + if err := st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": false}}); err != nil { + t.Fatal(err) + } + ch := make(chan workflow.Launch, 1) + srv.mgr.SetWorkflowRunner(routeCapture{ch}) + gate := intentgate.New() + if err := gate.Rebuild(context.Background(), []json.RawMessage{briefIntent()}); err != nil { + t.Fatal(err) + } + srv.Gate = gate + reg := workflow.NewRegistry() + reg.Set([]core.WorkflowDef{{ + ID: "demo.brief", Title: "简报", IntentID: "demo.brief", + Steps: []core.WorkflowStep{{ID: "s", Kind: "hitl"}}, + }}, nil) + srv.Workflows = reg + _, convID := setupConversation(t, srv.Handler()) + code, out := doJSON(t, srv.Handler(), "POST", "/api/conversations/"+convID+"/messages", map[string]any{"text": "写一份简报"}) + if code != 200 || out["run"] == nil { + t.Fatalf("status %d body %v", code, out) + } + select { + case launch := <-ch: + if launch.Def.ID != "demo.brief" || len(launch.Missing) != 1 || launch.Missing[0].Question != "主题是什么?" { + t.Fatalf("%+v", launch) + } + case <-time.After(time.Second): + t.Fatal("workflow runner was not selected") + } +} + +func briefIntent() json.RawMessage { + return json.RawMessage(`{"id":"demo.brief","domain":"demo","version":1,"matcher":{"includes_any":["简报"]},"slots":[{"key":"topic","required":true,"pattern":"主题[::]\\s*(\\S+)","question":"主题是什么?"}]}`) +} diff --git a/internal/workflow/registry.go b/internal/workflow/registry.go index 5641d3e..8da01a1 100644 --- a/internal/workflow/registry.go +++ b/internal/workflow/registry.go @@ -77,6 +77,22 @@ func (r *Registry) Script(workflowID, path string) (string, bool) { return body, ok } +// SlotQuestion 是启动工作流时还没填上的必填槽位。 +type SlotQuestion struct { + Key string + Question string +} + +// Launch 是一次工作流 run 启动时的副本。之后插件卸载不能改到它。 +type Launch struct { + Def core.WorkflowDef + Slots map[string]string + Missing []SlotQuestion +} + +// Clone 拷贝定义,调用方之后的修改不影响副本。 +func Clone(def core.WorkflowDef) core.WorkflowDef { return cloneDef(def) } + func cloneDef(def core.WorkflowDef) core.WorkflowDef { raw, err := json.Marshal(def) if err != nil {