From 6720ceafe75ffd44a806c356c453e0ab8cb78178 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 08:07:14 -0700 Subject: [PATCH 1/7] =?UTF-8?q?workflowmodel:=20structural=20Diff,=20Confi?= =?UTF-8?q?rm=20inferred=E2=86=92local,=20Profile.LabelDefs,=20store.NextV?= =?UTF-8?q?ersion?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure, deterministic model diff (columns/states/transitions/labels/rules/meta), Confirm that clears inferred confidence and localises provenance, the label vocabulary a profile reconciles, and the store's next free integer version. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --- src/pkg/workflowmodel/confirm.go | 120 ++++++++++ src/pkg/workflowmodel/diff.go | 266 ++++++++++++++++++++++ src/pkg/workflowmodel/diff_test.go | 200 ++++++++++++++++ src/pkg/workflowmodel/store/store.go | 31 +++ src/pkg/workflowmodel/store/store_test.go | 23 ++ 5 files changed, 640 insertions(+) create mode 100644 src/pkg/workflowmodel/confirm.go create mode 100644 src/pkg/workflowmodel/diff.go create mode 100644 src/pkg/workflowmodel/diff_test.go diff --git a/src/pkg/workflowmodel/confirm.go b/src/pkg/workflowmodel/confirm.go new file mode 100644 index 0000000000..1fdddbb240 --- /dev/null +++ b/src/pkg/workflowmodel/confirm.go @@ -0,0 +1,120 @@ +package workflowmodel + +import "encoding/json" + +// AnnotationConfirmedBy records who confirmed an ingested model's inferred +// elements (Confirm). It lives in metadata.annotations so the spec hash +// stays a function of the spec alone. +const AnnotationConfirmedBy = "workflow.hivecommons.dev/confirmed-by" + +// Confirm returns a deep copy of m in which every inferred element +// (states at any depth, events, gates) is marked confirmed, provenance is +// local and the version is cleared so the store assigns the next one. The +// source is kept: the model still says where it was ingested from. m is +// not modified. +func Confirm(m *Model, by string) *Model { + if m == nil { + return nil + } + out := Clone(m) + var walk func(states []State) + walk = func(states []State) { + for i := range states { + states[i].Lifecycle.Confidence = ConfidenceConfirmed + walk(states[i].Regions) + } + } + walk(out.Spec.States) + for i := range out.Spec.Events { + out.Spec.Events[i].Confidence = ConfidenceConfirmed + } + for i := range out.Spec.Gates { + out.Spec.Gates[i].Confidence = ConfidenceConfirmed + } + out.Spec.Meta.Provenance = ProvenanceLocal + out.Spec.Meta.Version = "" + if by != "" { + if out.Metadata.Annotations == nil { + out.Metadata.Annotations = map[string]string{} + } + out.Metadata.Annotations[AnnotationConfirmedBy] = by + } + return out +} + +// Clone returns a deep copy of m via its JSON form. +func Clone(m *Model) *Model { + if m == nil { + return nil + } + b, _ := json.Marshal(m) + var out Model + _ = json.Unmarshal(b, &out) + return &out +} + +// Inferred counts the elements a human has not confirmed yet. +func Inferred(m *Model) int { + if m == nil { + return 0 + } + n := 0 + var walk func(states []State) + walk = func(states []State) { + for _, s := range states { + if s.Lifecycle.Confidence == ConfidenceInferred { + n++ + } + walk(s.Regions) + } + } + walk(m.Spec.States) + for _, e := range m.Spec.Events { + if e.Confidence == ConfidenceInferred { + n++ + } + } + for _, g := range m.Spec.Gates { + if g.Confidence == ConfidenceInferred { + n++ + } + } + return n +} + +// LabelDef is one label a Profile asks the engine to reconcile into a +// bound repository. +type LabelDef struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description,omitempty"` + State string `json:"state,omitempty"` +} + +// LabelDefs is the label vocabulary the profile reconciles: its label +// entries with defaultColor filled in where an entry names none and the +// state's label as description where an entry has none. Entries are +// returned in declaration order; a model with no labels block regenerates +// nothing and keeps using the built-in family by stage number. +func (p *Profile) LabelDefs(defaultColor string) []LabelDef { + if p == nil { + return nil + } + out := make([]LabelDef, 0, len(p.Labels.Entries)) + for _, e := range p.Labels.Entries { + if e.Name == "" { + continue + } + d := LabelDef{Name: e.Name, Color: e.Color, Description: e.Description, State: e.State} + if d.Color == "" { + d.Color = defaultColor + } + if d.Description == "" && e.State != "" { + if s, ok := p.States[e.State]; ok && s != nil { + d.Description = s.Label + } + } + out = append(out, d) + } + return out +} diff --git a/src/pkg/workflowmodel/diff.go b/src/pkg/workflowmodel/diff.go new file mode 100644 index 0000000000..9a21eddb0a --- /dev/null +++ b/src/pkg/workflowmodel/diff.go @@ -0,0 +1,266 @@ +package workflowmodel + +import ( + "fmt" + "sort" + "strconv" + "strings" +) + +// ModelDiff is the structural difference between two models +// (hivecommons/hive#17620, RFC phase 4). It is computed from the spec +// only, never from metadata, and is deterministic: the same pair always +// produces the same diff, with every list sorted by key. +type ModelDiff struct { + // Columns are keyed by column id. + Columns ChangeSet `json:"columns"` + // States are keyed by state id; regions are flattened like Compile. + States ChangeSet `json:"states"` + // Transitions are keyed "from>to" ("from>to@on" when a pair occurs + // more than once). + Transitions ChangeSet `json:"transitions"` + // Labels are keyed by label name. + Labels ChangeSet `json:"labels"` + // Rules are keyed by rule id, or "rules[i]" for an unnamed rule. + Rules ChangeSet `json:"rules"` + // Meta lists the spec.meta fields that differ. + Meta []FieldChange `json:"meta,omitempty"` + // Summary is a one-line human description ("+2 states, −1 + // transition, 3 changed"; "no changes" when Empty). + Summary string `json:"summary"` + Empty bool `json:"empty"` +} + +// ChangeSet is the diff of one keyed collection. +type ChangeSet struct { + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Changed []ItemChange `json:"changed,omitempty"` +} + +// Len is the number of items added, removed or changed. +func (c ChangeSet) Len() int { return len(c.Added) + len(c.Removed) + len(c.Changed) } + +// ItemChange is one item present in both models whose fields differ. +type ItemChange struct { + Key string `json:"key"` + Fields []FieldChange `json:"fields"` +} + +// FieldChange is one scalar field that differs. Lists are rendered as +// comma-joined strings so the diff stays flat and JSON-friendly. +type FieldChange struct { + Field string `json:"field"` + From string `json:"from"` + To string `json:"to"` +} + +// Diff compares a and b structurally. Either may be nil, which is treated +// as an empty model. +func Diff(a, b *Model) ModelDiff { + if a == nil { + a = &Model{} + } + if b == nil { + b = &Model{} + } + d := ModelDiff{ + Columns: diffItems(columnItems(a), columnItems(b)), + States: diffItems(stateItems(a), stateItems(b)), + Transitions: diffItems(transitionItems(a), transitionItems(b)), + Labels: diffItems(labelItems(a), labelItems(b)), + Rules: diffItems(ruleItems(a), ruleItems(b)), + Meta: fieldChanges(metaFields(a), metaFields(b)), + } + d.Empty = d.Columns.Len()+d.States.Len()+d.Transitions.Len()+d.Labels.Len()+d.Rules.Len()+len(d.Meta) == 0 + d.Summary = d.summary() + return d +} + +func (d ModelDiff) summary() string { + if d.Empty { + return "no changes" + } + var parts []string + changed := len(d.Meta) + for _, c := range []struct { + noun string + set ChangeSet + }{ + {"column", d.Columns}, {"state", d.States}, {"transition", d.Transitions}, {"label", d.Labels}, {"rule", d.Rules}, + } { + if n := len(c.set.Added); n > 0 { + parts = append(parts, "+"+strconv.Itoa(n)+" "+plural(c.noun, n)) + } + if n := len(c.set.Removed); n > 0 { + parts = append(parts, "−"+strconv.Itoa(n)+" "+plural(c.noun, n)) + } + changed += len(c.set.Changed) + } + if changed > 0 { + parts = append(parts, strconv.Itoa(changed)+" changed") + } + return strings.Join(parts, ", ") +} + +func plural(noun string, n int) string { + if n == 1 { + return noun + } + return noun + "s" +} + +// item is one keyed element with its comparable fields in a fixed order. +type item struct { + key string + fields []field +} + +type field struct{ name, value string } + +func diffItems(a, b []item) ChangeSet { + var cs ChangeSet + am := map[string]item{} + for _, it := range a { + am[it.key] = it + } + bm := map[string]item{} + for _, it := range b { + bm[it.key] = it + } + for k := range am { + if _, ok := bm[k]; !ok { + cs.Removed = append(cs.Removed, k) + } + } + for k, bi := range bm { + ai, ok := am[k] + if !ok { + cs.Added = append(cs.Added, k) + continue + } + if fc := fieldChanges(ai.fields, bi.fields); len(fc) > 0 { + cs.Changed = append(cs.Changed, ItemChange{Key: k, Fields: fc}) + } + } + sort.Strings(cs.Added) + sort.Strings(cs.Removed) + sort.Slice(cs.Changed, func(i, j int) bool { return cs.Changed[i].Key < cs.Changed[j].Key }) + return cs +} + +// fieldChanges compares two field lists by name; a field present on one +// side only is reported against the empty string. +func fieldChanges(a, b []field) []FieldChange { + av := map[string]string{} + for _, f := range a { + av[f.name] = f.value + } + var out []FieldChange + seen := map[string]bool{} + for _, f := range b { + seen[f.name] = true + if av[f.name] != f.value { + out = append(out, FieldChange{Field: f.name, From: av[f.name], To: f.value}) + } + } + for _, f := range a { + if !seen[f.name] && f.value != "" { + out = append(out, FieldChange{Field: f.name, From: f.value, To: ""}) + } + } + return out +} + +func join(ss []string) string { return strings.Join(ss, ",") } + +func joinKinds(ks []ItemKind) string { + out := make([]string, len(ks)) + for i, k := range ks { + out[i] = string(k) + } + return join(out) +} + +func columnItems(m *Model) []item { + var out []item + for _, c := range m.Spec.Columns { + out = append(out, item{key: c.ID, fields: []field{ + {"label", c.Label}, {"hint", c.Hint}, {"states", join(c.States)}, {"terminal", strconv.FormatBool(c.Terminal)}, + }}) + } + return out +} + +func stateItems(m *Model) []item { + var out []item + var walk func(states []State, parent string) + walk = func(states []State, parent string) { + for _, s := range states { + out = append(out, item{key: s.ID, fields: []field{ + {"label", s.Label}, {"owner", string(s.Owner)}, {"type", string(s.Type)}, {"parent", parent}, + {"guardRef", s.GuardRef}, {"pipelines", join(s.Pipelines)}, {"origin", string(s.Origin)}, + {"column", s.Lifecycle.Column}, {"number", strconv.Itoa(s.Lifecycle.Number)}, + {"kinds", joinKinds(s.Lifecycle.Kinds)}, {"waiting", join(s.Lifecycle.Waiting)}, + {"replies", join(s.Lifecycle.Replies)}, {"confidence", string(s.Lifecycle.Confidence)}, + }}) + walk(s.Regions, s.ID) + } + } + walk(m.Spec.States, "") + return out +} + +func transitionItems(m *Model) []item { + pairs := map[string]int{} + for _, t := range m.Spec.Transitions { + pairs[t.From+">"+t.To]++ + } + var out []item + for _, t := range m.Spec.Transitions { + key := t.From + ">" + t.To + if pairs[key] > 1 { + key += "@" + t.On + } + out = append(out, item{key: key, fields: []field{ + {"on", t.On}, {"guardRef", t.GuardRef}, {"gateRef", t.GateRef}, {"note", t.Note}, + }}) + } + return out +} + +func labelItems(m *Model) []item { + var out []item + for _, l := range m.Spec.Labels.Entries { + out = append(out, item{key: l.Name, fields: []field{ + {"color", l.Color}, {"description", l.Description}, {"state", l.State}, + }}) + } + return out +} + +func ruleItems(m *Model) []item { + var out []item + for i, r := range m.Spec.Derivation.Rules { + key := r.ID + if key == "" { + key = fmt.Sprintf("rules[%d]", i) + } + preds := make([]string, len(r.When)) + for j, p := range r.When { + preds[j] = p.Fact + " " + string(p.Op) + " " + p.Value + } + out = append(out, item{key: key, fields: []field{ + {"kind", string(r.Kind)}, {"state", r.State}, {"when", strings.Join(preds, "; ")}, {"reason", r.Reason}, + }}) + } + return out +} + +func metaFields(m *Model) []field { + mt := m.Spec.Meta + return []field{ + {"version", mt.Version}, {"author", mt.Author}, {"description", mt.Description}, {"license", mt.License}, + {"source", string(mt.Source)}, {"provenance", string(mt.Provenance)}, {"mode", string(m.Spec.Derivation.Mode)}, + } +} diff --git a/src/pkg/workflowmodel/diff_test.go b/src/pkg/workflowmodel/diff_test.go new file mode 100644 index 0000000000..479a50b4ca --- /dev/null +++ b/src/pkg/workflowmodel/diff_test.go @@ -0,0 +1,200 @@ +package workflowmodel + +import ( + "encoding/json" + "reflect" + "strconv" + "strings" + "testing" +) + +func TestDiffIdenticalIsEmpty(t *testing.T) { + a := techdocs(t) + d := Diff(a, Clone(a)) + if !d.Empty || d.Summary != "no changes" { + t.Fatalf("diff of identical models: %+v", d) + } + if nd := Diff(nil, nil); !nd.Empty { + t.Fatalf("nil diff not empty: %+v", nd) + } + if _, err := json.Marshal(d); err != nil { + t.Fatal(err) + } +} + +func TestDiffStatesAddedRemovedChanged(t *testing.T) { + a := techdocs(t) + b := Clone(a) + // change one, remove one, add one (with a region to exercise flattening) + b.Spec.States[0].Label = "Requested (new)" + b.Spec.States[0].Lifecycle.Number = 11 + b.Spec.States[0].Lifecycle.Replies = []string{"triage"} + removed := b.Spec.States[len(b.Spec.States)-1].ID + b.Spec.States = b.Spec.States[:len(b.Spec.States)-1] + b.Spec.States = append(b.Spec.States, State{ID: "parallel", Owner: OwnerHuman, Type: StateParallel, Regions: []State{{ID: "inner", Owner: OwnerHuman, Lifecycle: StateLifecycle{Confidence: ConfidenceInferred}}}}) + + d := Diff(a, b) + if d.Empty { + t.Fatal("expected changes") + } + if !reflect.DeepEqual(d.States.Added, []string{"inner", "parallel"}) { + t.Fatalf("added %v", d.States.Added) + } + if !reflect.DeepEqual(d.States.Removed, []string{removed}) { + t.Fatalf("removed %v", d.States.Removed) + } + if len(d.States.Changed) != 1 || d.States.Changed[0].Key != a.Spec.States[0].ID { + t.Fatalf("changed %+v", d.States.Changed) + } + fields := map[string]FieldChange{} + for _, f := range d.States.Changed[0].Fields { + fields[f.Field] = f + } + if fields["label"].To != "Requested (new)" || fields["number"] != (FieldChange{"number", "10", "11"}) || fields["replies"] != (FieldChange{"replies", "", "triage"}) { + t.Fatalf("fields %+v", fields) + } + if len(fields) != 3 { + t.Fatalf("unexpected field changes: %+v", fields) + } + if d.Summary != "+2 states, −1 state, 1 changed" { + t.Fatalf("summary %q", d.Summary) + } + // reverse direction swaps added/removed + r := Diff(b, a) + if !reflect.DeepEqual(r.States.Removed, []string{"inner", "parallel"}) || !reflect.DeepEqual(r.States.Added, []string{removed}) { + t.Fatalf("reverse %+v", r.States) + } +} + +func TestDiffTransitionsLabelsColumnsRulesMeta(t *testing.T) { + a := techdocs(t) + b := Clone(a) + b.Spec.Transitions[0].Note = "edited" + b.Spec.Transitions[0].GuardRef = "g1" + b.Spec.Transitions = b.Spec.Transitions[1:] + b.Spec.Transitions = append(b.Spec.Transitions, Transition{From: "approved", To: "withdrawn", On: "closed"}, Transition{From: "approved", To: "withdrawn", On: "rejected"}) + b.Spec.Labels.Entries = append(b.Spec.Labels.Entries, LabelEntry{Name: "stage/010-requested", State: "requested"}) + b.Spec.Columns[0].Label = "Inbox" + b.Spec.Columns = append(b.Spec.Columns, Column{ID: "extra", States: []string{"requested"}}) + b.Spec.Derivation.Rules = append(b.Spec.Derivation.Rules, Rule{State: "requested", When: []Predicate{{Fact: "open", Op: OpTrue}}}, Rule{ID: "named", State: "withdrawn"}) + b.Spec.Meta.Version = "2" + b.Spec.Meta.Provenance = ProvenanceLocal + + d := Diff(a, b) + removedKey := a.Spec.Transitions[0].From + ">" + a.Spec.Transitions[0].To + if !reflect.DeepEqual(d.Transitions.Removed, []string{removedKey}) { + t.Fatalf("transitions removed %v", d.Transitions.Removed) + } + if !reflect.DeepEqual(d.Transitions.Added, []string{"approved>withdrawn@closed", "approved>withdrawn@rejected"}) { + t.Fatalf("transitions added %v", d.Transitions.Added) + } + if d.Transitions.Len() != 3 { + t.Fatalf("transitions %+v", d.Transitions) + } + if !reflect.DeepEqual(d.Labels.Added, []string{"stage/010-requested"}) || d.Labels.Len() != 1 { + t.Fatalf("labels %+v", d.Labels) + } + if !reflect.DeepEqual(d.Columns.Added, []string{"extra"}) || len(d.Columns.Changed) != 1 || d.Columns.Changed[0].Fields[0] != (FieldChange{"label", a.Spec.Columns[0].Label, "Inbox"}) { + t.Fatalf("columns %+v", d.Columns) + } + wantRules := []string{"named", "rules[" + strconv.Itoa(len(a.Spec.Derivation.Rules)) + "]"} + if !reflect.DeepEqual(d.Rules.Added, wantRules) { + t.Fatalf("rules %v want %v", d.Rules.Added, wantRules) + } + meta := map[string]FieldChange{} + for _, f := range d.Meta { + meta[f.Field] = f + } + if meta["version"] != (FieldChange{"version", "1", "2"}) || meta["provenance"].To != string(ProvenanceLocal) || len(meta) != 2 { + t.Fatalf("meta %+v", d.Meta) + } + for _, want := range []string{"+1 column", "−1 transition", "+2 transitions", "+1 label", "+2 rules", "changed"} { + if !strings.Contains(d.Summary, want) { + t.Fatalf("summary %q lacks %q", d.Summary, want) + } + } + // a transition whose fields change is reported as changed + c := Clone(a) + c.Spec.Transitions[0].Note = "why" + cd := Diff(a, c) + if len(cd.Transitions.Changed) != 1 || cd.Transitions.Changed[0].Fields[0] != (FieldChange{"note", a.Spec.Transitions[0].Note, "why"}) { + t.Fatalf("transition change %+v", cd.Transitions) + } + if cd.Summary != "1 changed" { + t.Fatalf("summary %q", cd.Summary) + } +} + +func TestConfirmClearsInferredAndLocalises(t *testing.T) { + a := techdocs(t) + a.Spec.Meta.Provenance = ProvenanceIngestedUnreviewed + a.Spec.Meta.Source = SourceGitHubActions + a.Spec.States[0].Lifecycle.Confidence = ConfidenceInferred + a.Spec.States = append(a.Spec.States, State{ID: "par", Owner: OwnerHuman, Type: StateParallel, Regions: []State{{ID: "in", Owner: OwnerHuman, Lifecycle: StateLifecycle{Confidence: ConfidenceInferred}}}}) + a.Spec.Events = append(a.Spec.Events, Event{ID: "ev", Fact: Predicate{Fact: "open", Op: OpTrue}, Confidence: ConfidenceInferred}) + a.Spec.Gates = append(a.Spec.Gates, Gate{ID: "gt", ReleasedBy: "maintainer", Confidence: ConfidenceInferred}) + if Inferred(a) != 4 { + t.Fatalf("inferred %d", Inferred(a)) + } + before := a.Hash() + + c := Confirm(a, "andy") + if Inferred(c) != 0 { + t.Fatalf("confirmed model still has %d inferred", Inferred(c)) + } + if c.Spec.Meta.Provenance != ProvenanceLocal || c.Spec.Meta.Version != "" || c.Spec.Meta.Source != SourceGitHubActions { + t.Fatalf("meta %+v", c.Spec.Meta) + } + if c.Metadata.Annotations[AnnotationConfirmedBy] != "andy" { + t.Fatalf("annotations %v", c.Metadata.Annotations) + } + if c.Metadata.Name != a.Metadata.Name || len(c.Spec.States) != len(a.Spec.States) { + t.Fatal("confirm lost structure") + } + // original untouched + if a.Hash() != before || Inferred(a) != 4 || a.Spec.Meta.Provenance != ProvenanceIngestedUnreviewed || a.Metadata.Annotations != nil { + t.Fatal("Confirm mutated its input") + } + // no annotation without a user; nil in → nil out + if Confirm(a, "").Metadata.Annotations != nil || Confirm(nil, "x") != nil || Clone(nil) != nil || Inferred(nil) != 0 { + t.Fatal("edge cases") + } +} + +func TestProfileLabelDefs(t *testing.T) { + m := techdocs(t) + m.Spec.Labels = LabelSet{Prefix: "stage", Entries: []LabelEntry{ + {Name: "stage/010-requested", State: "requested"}, + {Name: "stage/020-analysing", Color: "123456", Description: "explicit", State: "analysing"}, + {Name: "", State: "ghost"}, + {Name: "orphan", State: "nope"}, + }} + p, _, err := Compile(m) + if err != nil { + t.Fatal(err) + } + got := p.LabelDefs("abcdef") + want := []LabelDef{ + {Name: "stage/010-requested", Color: "abcdef", Description: m.Spec.States[0].Label, State: "requested"}, + {Name: "stage/020-analysing", Color: "123456", Description: "explicit", State: "analysing"}, + {Name: "orphan", Color: "abcdef", State: "nope"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v\nwant %+v", got, want) + } + if (*Profile)(nil).LabelDefs("x") != nil { + t.Fatal("nil profile") + } + if n := len(techdocsProfile(t).LabelDefs("x")); n != 0 { + t.Fatalf("model without labels block yields %d defs", n) + } +} + +func techdocsProfile(t *testing.T) *Profile { + t.Helper() + p, _, err := Compile(techdocs(t)) + if err != nil { + t.Fatal(err) + } + return p +} diff --git a/src/pkg/workflowmodel/store/store.go b/src/pkg/workflowmodel/store/store.go index b81d46dba7..dfaf50a531 100644 --- a/src/pkg/workflowmodel/store/store.go +++ b/src/pkg/workflowmodel/store/store.go @@ -20,6 +20,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "sync" "time" @@ -413,3 +414,33 @@ func writeAtomic(path string, b []byte) error { } return os.Rename(tmp, path) } + +// NextVersion returns the next free integer version of name: one more +// than the largest integer version saved so far, or "1" for a name with no +// integer versions. Non-integer versions (semver, tags) are ignored for +// the count but can never be returned, so the result is always unused. +func (s *Store) NextVersion(name string) (string, error) { + if !nameRe.MatchString(name) { + return "", fmt.Errorf("%w: %q", ErrBadName, name) + } + all, err := s.List() + if err != nil { + return "", err + } + max := 0 + taken := map[string]bool{} + for _, e := range all { + if e.Name != name { + continue + } + taken[e.Version] = true + if n, err := strconv.Atoi(e.Version); err == nil && n > max { + max = n + } + } + for v := max + 1; ; v++ { + if s := strconv.Itoa(v); !taken[s] { + return s, nil + } + } +} diff --git a/src/pkg/workflowmodel/store/store_test.go b/src/pkg/workflowmodel/store/store_test.go index 01d178341d..21b011da6b 100644 --- a/src/pkg/workflowmodel/store/store_test.go +++ b/src/pkg/workflowmodel/store/store_test.go @@ -257,3 +257,26 @@ func TestRoot(t *testing.T) { t.Fatal("env must win") } } + +func TestNextVersion(t *testing.T) { + s := Open(t.TempDir()) + m := techdocs(t) + if v, err := s.NextVersion(m.Metadata.Name); err != nil || v != "1" { + t.Fatalf("empty: %q %v", v, err) + } + if _, err := s.NextVersion("Bad Name"); !errors.Is(err, ErrBadName) { + t.Fatal(err) + } + for _, v := range []string{"1", "3", "2.0-rc1"} { + m.Spec.Meta.Version = v + if _, err := s.Save(m, "andy", t0); err != nil { + t.Fatal(err) + } + } + if v, err := s.NextVersion(m.Metadata.Name); err != nil || v != "4" { + t.Fatalf("after 1,3,2.0-rc1: %q %v", v, err) + } + if v, err := s.NextVersion(hivedefault.Name); err != nil || v != "2" { + t.Fatalf("builtin: %q %v", v, err) + } +} From ac2940078c69e9cdd816f4a495242965e4dc1d69 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 08:10:18 -0700 Subject: [PATCH 2/7] dashboard: workflow versions, diff, confirm and labels-preview routes GET /api/workflows/{name}/versions, GET /api/workflows/{name}/diff?from&to (fromName/toName for cross-model), POST .../{version}/confirm (owner, audited workflow_confirm, 409 for hive-default) and GET .../{version}/labels. Documented in dashboard/openapi.json. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --- dashboard/openapi.json | 398 ++++++++++++++++++ src/pkg/dashboard/api_workflows.go | 1 + src/pkg/dashboard/api_workflows_versions.go | 186 ++++++++ .../dashboard/api_workflows_versions_test.go | 219 ++++++++++ 4 files changed, 804 insertions(+) create mode 100644 src/pkg/dashboard/api_workflows_versions.go create mode 100644 src/pkg/dashboard/api_workflows_versions_test.go diff --git a/dashboard/openapi.json b/dashboard/openapi.json index d4fae455f6..4c1db74093 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -35655,6 +35655,249 @@ } } }, + "/api/workflows/{name}/versions": { + "get": { + "tags": [ + "Workflows" + ], + "summary": "List every saved version of a workflow model", + "description": "Newest save first. hive-default answers its single virtual, read-only entry.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Versions", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "versions": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEntry" + } + } + } + } + } + } + }, + "404": { + "description": "Unknown model" + }, + "503": { + "description": "No workflow store" + } + } + } + }, + "/api/workflows/{name}/diff": { + "get": { + "tags": [ + "Workflows" + ], + "summary": "Structural diff between two saved workflow model versions", + "description": "Compares spec columns, states (regions flattened), transitions (keyed from>to), labels, derivation rules and meta. from/to are versions of {name}; fromName/toName override the model on either side so a local model can be compared against hive-default.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "from", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "to", + "in": "query", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "fromName", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "toName", + "in": "query", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Diff", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "from": { + "$ref": "#/components/schemas/WorkflowEntry" + }, + "to": { + "$ref": "#/components/schemas/WorkflowEntry" + }, + "diff": { + "$ref": "#/components/schemas/WorkflowModelDiff" + } + } + } + } + } + }, + "400": { + "description": "from or to missing, or bad name/version" + }, + "404": { + "description": "Either side not found" + }, + "503": { + "description": "No workflow store" + } + } + } + }, + "/api/workflows/{name}/{version}/confirm": { + "post": { + "tags": [ + "Workflows" + ], + "summary": "Confirm an ingested model's inferred elements as a new local version", + "description": "Owner only. Copies {name}@{version} with every inferred state, event and gate marked confirmed, provenance set to local and metadata.annotations[workflow.hivecommons.dev/confirmed-by] set to the caller, then saves it as the next free integer version. The source version is untouched. Audited as workflow_confirm.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "201": { + "description": "Confirmed copy stored", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "entry": { + "$ref": "#/components/schemas/WorkflowEntry" + }, + "confirmed": { + "type": "integer", + "description": "Inferred elements that were confirmed" + }, + "from": { + "type": "string", + "description": "Source name@version" + } + } + } + } + } + }, + "400": { + "description": "Bad name or version" + }, + "403": { + "description": "Not an owner" + }, + "404": { + "description": "Not found" + }, + "409": { + "description": "hive-default is built-in and read-only" + }, + "503": { + "description": "No workflow store" + } + } + } + }, + "/api/workflows/{name}/{version}/labels": { + "get": { + "tags": [ + "Workflows" + ], + "summary": "Preview the labels and commands a workflow model regenerates when bound", + "description": "The label vocabulary the engine reconciles into a bound repository (the model's labels block, stage colour filled in where unset, descriptions clamped), the human-gate verbs each state offers, and the built-in stage label each numbered state maps onto.", + "parameters": [ + { + "name": "name", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "version", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Preview", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowLabelsPreview" + } + } + } + }, + "400": { + "description": "Model does not compile; body carries diagnostics" + }, + "404": { + "description": "Not found" + }, + "503": { + "description": "No workflow store" + } + } + } + }, "/api/workflows/ingest": { "post": { "tags": [ @@ -36131,6 +36374,161 @@ "description": "Compiled, derivation-ready form of a model (hash, states, columns, ordered rules). Never trusted from the wire: the spoke recompiles on save and on bind.", "additionalProperties": true }, + "WorkflowFieldChange": { + "type": "object", + "properties": { + "field": { + "type": "string" + }, + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + }, + "WorkflowChangeSet": { + "type": "object", + "properties": { + "added": { + "type": "array", + "items": { + "type": "string" + } + }, + "removed": { + "type": "array", + "items": { + "type": "string" + } + }, + "changed": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string" + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowFieldChange" + } + } + } + } + } + } + }, + "WorkflowModelDiff": { + "type": "object", + "description": "Structural difference between two models; deterministic, lists sorted by key.", + "properties": { + "columns": { + "$ref": "#/components/schemas/WorkflowChangeSet" + }, + "states": { + "$ref": "#/components/schemas/WorkflowChangeSet" + }, + "transitions": { + "$ref": "#/components/schemas/WorkflowChangeSet" + }, + "labels": { + "$ref": "#/components/schemas/WorkflowChangeSet" + }, + "rules": { + "$ref": "#/components/schemas/WorkflowChangeSet" + }, + "meta": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowFieldChange" + } + }, + "summary": { + "type": "string" + }, + "empty": { + "type": "boolean" + } + } + }, + "WorkflowLabelDef": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "color": { + "type": "string" + }, + "description": { + "type": "string" + }, + "state": { + "type": "string" + } + } + }, + "WorkflowLabelsPreview": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "version": { + "type": "string" + }, + "hash": { + "type": "string" + }, + "prefix": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowLabelDef" + } + }, + "commands": { + "type": "array", + "items": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "verbs": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + }, + "stages": { + "type": "array", + "items": { + "type": "object", + "properties": { + "state": { + "type": "string" + }, + "number": { + "type": "integer" + }, + "label": { + "type": "string" + } + } + } + } + } + }, "WorkflowIngestResult": { "type": "object", "properties": { diff --git a/src/pkg/dashboard/api_workflows.go b/src/pkg/dashboard/api_workflows.go index c1065c0952..c503cc7c42 100644 --- a/src/pkg/dashboard/api_workflows.go +++ b/src/pkg/dashboard/api_workflows.go @@ -26,6 +26,7 @@ func (s *Server) registerWorkflowRoutes() { s.mux.HandleFunc("GET /api/workflows/{name}/{version}", s.handleWorkflowGet) s.mux.HandleFunc("PUT /api/workflows/{name}/{version}", s.handleWorkflowSave) s.mux.HandleFunc("DELETE /api/workflows/{name}/{version}", s.handleWorkflowDelete) + s.registerWorkflowVersionRoutes() } func (s *Server) workflowStore(w http.ResponseWriter) *wfstore.Store { diff --git a/src/pkg/dashboard/api_workflows_versions.go b/src/pkg/dashboard/api_workflows_versions.go new file mode 100644 index 0000000000..a902ea7646 --- /dev/null +++ b/src/pkg/dashboard/api_workflows_versions.go @@ -0,0 +1,186 @@ +package dashboard + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + wfstore "github.com/hivecommons/hive/pkg/workflowmodel/store" +) + +// Workflow-model versions, diff, confirm and label preview +// (hivecommons/hive#17620, RFC phase 4, backend). These sit beside the +// library routes in api_workflows.go: reads are open, confirm is +// owner-gated and audited as workflow_confirm. +func (s *Server) registerWorkflowVersionRoutes() { + s.mux.HandleFunc("GET /api/workflows/{name}/versions", s.handleWorkflowVersions) + s.mux.HandleFunc("GET /api/workflows/{name}/diff", s.handleWorkflowDiff) + s.mux.HandleFunc("POST /api/workflows/{name}/{version}/confirm", s.handleWorkflowConfirm) + s.mux.HandleFunc("GET /api/workflows/{name}/{version}/labels", s.handleWorkflowLabels) +} + +// handleWorkflowVersions lists every saved version of a model, newest +// save first; hive-default answers its single virtual entry. +func (s *Server) handleWorkflowVersions(w http.ResponseWriter, r *http.Request) { + st := s.workflowStore(w) + if st == nil { + return + } + entries, err := st.Versions(r.PathValue("name")) + if err != nil { + workflowStoreError(w, err) + return + } + jsonResponse(w, map[string]interface{}{"versions": entries}) +} + +// handleWorkflowDiff compares two saved versions. from/to are versions of +// {name}; fromName/toName override the model name on either side so a +// local model can be compared against hive-default. +func (s *Server) handleWorkflowDiff(w http.ResponseWriter, r *http.Request) { + st := s.workflowStore(w) + if st == nil { + return + } + q := r.URL.Query() + from, to := q.Get("from"), q.Get("to") + if from == "" || to == "" { + jsonError(w, "from and to query parameters are required", http.StatusBadRequest) + return + } + name := r.PathValue("name") + fromName, toName := q.Get("fromName"), q.Get("toName") + if fromName == "" { + fromName = name + } + if toName == "" { + toName = name + } + a, ae, err := st.Get(fromName, from) + if err != nil { + workflowStoreError(w, err) + return + } + b, be, err := st.Get(toName, to) + if err != nil { + workflowStoreError(w, err) + return + } + jsonResponse(w, map[string]interface{}{"from": ae, "to": be, "diff": workflowmodel.Diff(a, b)}) +} + +// handleWorkflowConfirm saves a copy of {name}@{version} with every +// inferred element confirmed and provenance local, as the next integer +// version. hive-default is read-only and answers 409. +func (s *Server) handleWorkflowConfirm(w http.ResponseWriter, r *http.Request) { + if !requireOwnerRole(w, r) { + return + } + st := s.workflowStore(w) + if st == nil { + return + } + name, version := r.PathValue("name"), r.PathValue("version") + if wfstore.IsBuiltin(name) { + jsonError(w, fmt.Sprintf("%s is built-in and read-only; save a copy under another name to edit it", name), http.StatusConflict) + return + } + m, _, err := st.Get(name, version) + if err != nil { + workflowStoreError(w, err) + return + } + inferred := workflowmodel.Inferred(m) + next, err := st.NextVersion(name) + if err != nil { + workflowStoreError(w, err) + return + } + c := workflowmodel.Confirm(m, requestUser(r)) + c.Spec.Meta.Version = next + e, err := st.Save(c, requestUser(r), time.Now().UTC()) + if err != nil { + workflowStoreError(w, err) + return + } + s.auditFromRequest(r, "workflow_confirm", fmt.Sprintf("%s@%s -> %s confirmed=%d compiled=%v", name, version, e.Key(), inferred, e.Compiled), "") + jsonStatusResponse(w, http.StatusCreated, map[string]interface{}{"entry": e, "confirmed": inferred, "from": name + "@" + version}) +} + +// workflowStateCommands is the human-gate verb list one state offers. +type workflowStateCommands struct { + State string `json:"state"` + Verbs []string `json:"verbs"` +} + +// workflowStageLabel is the built-in stage label a numbered state maps +// onto when the model declares no label of its own. +type workflowStageLabel struct { + State string `json:"state"` + Number int `json:"number"` + Label string `json:"label"` +} + +// handleWorkflowLabels previews what binding {name}@{version} regenerates: +// the labels the engine reconciles into a bound repository and the +// commands (human-gate verbs) each state offers. A draft that does not +// compile answers 400 with its diagnostics. +func (s *Server) handleWorkflowLabels(w http.ResponseWriter, r *http.Request) { + st := s.workflowStore(w) + if st == nil { + return + } + name, version := r.PathValue("name"), r.PathValue("version") + p, err := st.Profile(name, version) + if err != nil { + if errors.Is(err, wfstore.ErrNotCompiled) { + _, e, gerr := st.Get(name, version) + if gerr == nil { + jsonStatusResponse(w, http.StatusBadRequest, map[string]interface{}{"error": err.Error(), "diagnostics": e.Diagnostics}) + return + } + } + workflowStoreError(w, err) + return + } + jsonResponse(w, workflowLabelsPreview(p)) +} + +func workflowLabelsPreview(p *workflowmodel.Profile) map[string]interface{} { + labels := p.LabelDefs(stagelabels.StageColor) + if labels == nil { + labels = []workflowmodel.LabelDef{} + } + for i := range labels { + labels[i].Description = stagelabels.ClampLabelDescription(labels[i].Description) + } + commands := []workflowStateCommands{} + stages := []workflowStageLabel{} + for _, col := range p.Columns { + for _, sid := range col.States { + if verbs := p.Replies[sid]; len(verbs) > 0 { + commands = append(commands, workflowStateCommands{State: sid, Verbs: append([]string(nil), verbs...)}) + } + st, ok := p.States[sid] + if !ok || st == nil || st.Lifecycle.Number == 0 { + continue + } + for _, def := range stagelabels.Table { + if def.Number == st.Lifecycle.Number { + stages = append(stages, workflowStageLabel{State: sid, Number: def.Number, Label: def.Stage.Label()}) + break + } + } + } + } + return map[string]interface{}{ + "name": p.Name, "version": p.Version, "hash": p.Hash, + "prefix": p.Labels.Prefix, + "labels": labels, + "commands": commands, + "stages": stages, + } +} diff --git a/src/pkg/dashboard/api_workflows_versions_test.go b/src/pkg/dashboard/api_workflows_versions_test.go new file mode 100644 index 0000000000..6b0f72061d --- /dev/null +++ b/src/pkg/dashboard/api_workflows_versions_test.go @@ -0,0 +1,219 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" + wfstore "github.com/hivecommons/hive/pkg/workflowmodel/store" +) + +func TestWorkflowVersionRoutesNeedStore(t *testing.T) { + s := workflowTestServer(t, false) + for _, c := range []struct{ method, path string }{ + {http.MethodGet, "/api/workflows/x/versions"}, + {http.MethodGet, "/api/workflows/x/diff?from=1&to=2"}, + {http.MethodPost, "/api/workflows/x/1/confirm"}, + {http.MethodGet, "/api/workflows/x/1/labels"}, + } { + if rec := wfReq(s, c.method, c.path, nil, true); rec.Code != http.StatusServiceUnavailable { + t.Fatalf("%s %s: %d %s", c.method, c.path, rec.Code, rec.Body) + } + } + s = workflowTestServer(t, true) + if rec := wfReq(s, http.MethodPost, "/api/workflows/x/1/confirm", nil, false); rec.Code != http.StatusForbidden { + t.Fatalf("confirm owner gate: %d", rec.Code) + } +} + +func TestWorkflowVersionsAndDiff(t *testing.T) { + s := workflowTestServer(t, true) + m := techdocsModel(t) + if rec := wfReq(s, http.MethodPost, "/api/workflows", m, true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + m2 := techdocsModel(t) + m2.Spec.Meta.Version = "2" + m2.Spec.States[0].Label = "Requested!" + m2.Spec.States = append(m2.Spec.States, workflowmodel.State{ID: "extra", Label: "Extra", Owner: workflowmodel.OwnerHuman}) + if rec := wfReq(s, http.MethodPost, "/api/workflows", m2, true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + + rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/versions", nil, false) + var vs struct{ Versions []wfstore.Entry } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &vs) != nil || len(vs.Versions) != 2 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if rec := wfReq(s, http.MethodGet, "/api/workflows/ghost/versions", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("unknown: %d", rec.Code) + } + rec = wfReq(s, http.MethodGet, "/api/workflows/hive-default/versions", nil, false) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &vs) != nil || len(vs.Versions) != 1 || !vs.Versions[0].ReadOnly { + t.Fatalf("builtin: %d %s", rec.Code, rec.Body) + } + + for _, bad := range []string{"", "?from=1", "?to=2"} { + if rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff"+bad, nil, false); rec.Code != http.StatusBadRequest { + t.Fatalf("diff %q: %d", bad, rec.Code) + } + } + if rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff?from=1&to=9", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("diff missing to: %d", rec.Code) + } + if rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff?from=9&to=1", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("diff missing from: %d", rec.Code) + } + rec = wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff?from=1&to=2", nil, false) + var d struct { + From, To wfstore.Entry + Diff workflowmodel.ModelDiff + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &d) != nil { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if d.From.Version != "1" || d.To.Version != "2" || d.Diff.Empty || len(d.Diff.States.Added) != 1 || len(d.Diff.States.Changed) != 1 || d.Diff.Summary != "+1 state, 2 changed" { + t.Fatalf("diff %+v", d.Diff) + } + // same version both sides is empty; cross-model diff against hive-default works + rec = wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff?from=1&to=1", nil, false) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &d) != nil || !d.Diff.Empty { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + rec = wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/diff?from=1&toName=hive-default&to="+hivedefault.Version, nil, false) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &d) != nil || d.To.Name != hivedefault.Name || len(d.Diff.States.Added) == 0 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } +} + +func TestWorkflowConfirm(t *testing.T) { + s := workflowTestServer(t, true) + m := techdocsModel(t) + m.Spec.Meta.Provenance = workflowmodel.ProvenanceIngestedUnreviewed + m.Spec.States[0].Lifecycle.Confidence = workflowmodel.ConfidenceInferred + m.Spec.Events[0].Confidence = workflowmodel.ConfidenceInferred + if rec := wfReq(s, http.MethodPost, "/api/workflows", m, true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + if rec := wfReq(s, http.MethodPost, "/api/workflows/hive-default/1/confirm", nil, true); rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "read-only") { + t.Fatalf("builtin: %d %s", rec.Code, rec.Body) + } + if rec := wfReq(s, http.MethodPost, "/api/workflows/cncf-techdocs/9/confirm", nil, true); rec.Code != http.StatusNotFound { + t.Fatalf("missing: %d", rec.Code) + } + if rec := wfReq(s, http.MethodPost, "/api/workflows/Bad%20Name/1/confirm", nil, true); rec.Code != http.StatusBadRequest { + t.Fatalf("bad name: %d", rec.Code) + } + + rec := wfReq(s, http.MethodPost, "/api/workflows/cncf-techdocs/1/confirm", nil, true) + var out struct { + Entry wfstore.Entry + Confirmed int + From string + } + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &out) != nil { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if out.Entry.Version != "2" || out.Entry.Provenance != workflowmodel.ProvenanceLocal || out.Confirmed != 2 || out.From != "cncf-techdocs@1" || !out.Entry.Compiled || out.Entry.SavedBy != "andy" { + t.Fatalf("%+v", out) + } + for _, dg := range out.Entry.Diagnostics { + if dg.Code == workflowmodel.CodeInferred { + t.Fatalf("confirmed version still warns inferred: %+v", out.Entry.Diagnostics) + } + } + saved, _, err := s.deps.WorkflowStore.Get("cncf-techdocs", "2") + if err != nil || workflowmodel.Inferred(saved) != 0 || saved.Metadata.Annotations[workflowmodel.AnnotationConfirmedBy] != "andy" { + t.Fatalf("%v %+v", err, saved.Metadata) + } + // the original is untouched + orig, _, err := s.deps.WorkflowStore.Get("cncf-techdocs", "1") + if err != nil || workflowmodel.Inferred(orig) != 2 { + t.Fatalf("%v inferred=%d", err, workflowmodel.Inferred(orig)) + } + // confirming again allocates the next version + rec = wfReq(s, http.MethodPost, "/api/workflows/cncf-techdocs/2/confirm", nil, true) + if rec.Code != http.StatusCreated || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.Entry.Version != "3" || out.Confirmed != 0 { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if !auditHasAction(t, s, "workflow_confirm") { + t.Fatal("no workflow_confirm audit entry") + } +} + +func auditHasAction(t *testing.T, s *Server, action string) bool { + t.Helper() + rec := wfReq(s, http.MethodGet, "/api/audit?limit=50", nil, true) + if rec.Code != 200 { + t.Fatalf("audit: %d %s", rec.Code, rec.Body) + } + return strings.Contains(rec.Body.String(), `"`+action+`"`) +} + +func TestWorkflowLabelsPreview(t *testing.T) { + s := workflowTestServer(t, true) + m := techdocsModel(t) + m.Spec.Labels = workflowmodel.LabelSet{Prefix: "docs", Entries: []workflowmodel.LabelEntry{ + {Name: "docs/010-requested", State: "requested"}, + {Name: "docs/020-analysing", Color: "123456", Description: strings.Repeat("x", 200), State: "analysing"}, + }} + if rec := wfReq(s, http.MethodPost, "/api/workflows", m, true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/1/labels", nil, false) + var out struct { + Name, Version, Prefix string + Labels []workflowmodel.LabelDef + Commands []struct { + State string + Verbs []string + } + Stages []struct { + State string + Label string + } + } + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &out) != nil { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + if out.Name != "cncf-techdocs" || out.Prefix != "docs" || len(out.Labels) != 2 { + t.Fatalf("%+v", out) + } + if out.Labels[0].Color != stagelabels.StageColor || out.Labels[0].Description != m.Spec.States[0].Label || out.Labels[1].Color != "123456" || len(out.Labels[1].Description) >= 200 { + t.Fatalf("labels %+v", out.Labels) + } + verbs := map[string][]string{} + for _, c := range out.Commands { + verbs[c.State] = c.Verbs + } + if len(verbs["analysing"]) != 1 || verbs["analysing"][0] != "hand back" || len(verbs["requested"]) != 0 { + t.Fatalf("commands %+v", out.Commands) + } + if len(out.Stages) == 0 || out.Stages[0].Label == "" { + t.Fatalf("stages %+v", out.Stages) + } + + // hive-default previews the built-in family + rec = wfReq(s, http.MethodGet, "/api/workflows/hive-default/"+hivedefault.Version+"/labels", nil, false) + if rec.Code != 200 || json.Unmarshal(rec.Body.Bytes(), &out) != nil || len(out.Labels) == 0 || out.Labels[0].Color != stagelabels.StageColor { + t.Fatalf("builtin: %d %s", rec.Code, rec.Body) + } + if rec := wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/9/labels", nil, false); rec.Code != http.StatusNotFound { + t.Fatalf("missing: %d", rec.Code) + } + // a draft that does not compile answers 400 with its diagnostics + draft := techdocsModel(t) + draft.Spec.Meta.Version = "draft" + draft.Spec.Gates[0].ReleasedBy = "" + if rec := wfReq(s, http.MethodPost, "/api/workflows", draft, true); rec.Code != http.StatusCreated { + t.Fatal(rec.Body.String()) + } + rec = wfReq(s, http.MethodGet, "/api/workflows/cncf-techdocs/draft/labels", nil, false) + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), `"diagnostics"`) { + t.Fatalf("draft: %d %s", rec.Code, rec.Body) + } +} From a7fccc9192b1e7aba2646a694d0417bd6d90613b Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 08:13:02 -0700 Subject: [PATCH 3/7] automerge: provision a bound workflow model's labels, re-provision on rebind ensureStageLabels asks the stage engine's WorkflowFor lookup for the profile bound to the repository and creates or recolours its LabelDefs after the built-in family, memoised per repo and profile hash so a binding change takes effect on the next pass. Unbound repositories take the pre-existing path unchanged. Design doc section and changelog fragment. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson --- ...ded-workflowmodel-versions-diff-confirm.md | 1 + src/docs/design/workflow-models.md | 73 +++++++++ .../automerge/automerge_stage_labels.go | 73 +++++++-- .../automerge_stage_labels_workflow_test.go | 150 ++++++++++++++++++ 4 files changed, 287 insertions(+), 10 deletions(-) create mode 100644 changelog.d/added-workflowmodel-versions-diff-confirm.md create mode 100644 src/pkg/github/automerge/automerge_stage_labels_workflow_test.go diff --git a/changelog.d/added-workflowmodel-versions-diff-confirm.md b/changelog.d/added-workflowmodel-versions-diff-confirm.md new file mode 100644 index 0000000000..c1b23e4869 --- /dev/null +++ b/changelog.d/added-workflowmodel-versions-diff-confirm.md @@ -0,0 +1 @@ +- Workflow models (RFC phase 4, backend): `GET /api/workflows/{name}/versions`, a deterministic structural diff between two versions (`GET …/{name}/diff?from&to`, optionally cross-model against hive-default), `POST …/{name}/{version}/confirm` to confirm an ingested model's inferred elements as a new local version (owner, audited `workflow_confirm`), `GET …/{name}/{version}/labels` to preview the labels and commands a model regenerates, and the stage-label provisioner now creates or recolours a bound model's own label vocabulary and re-provisions when the binding changes ([#17620](https://github.com/hivecommons/hive/issues/17620)) diff --git a/src/docs/design/workflow-models.md b/src/docs/design/workflow-models.md index c98e896da6..bb3f73268f 100644 --- a/src/docs/design/workflow-models.md +++ b/src/docs/design/workflow-models.md @@ -155,3 +155,76 @@ result at the next integer version under `name` (default: the repository slug). `cmd/workflow-survey` now compiles every corpus repository's lifecycle model (`lifecycle.json` alongside `model.json`) and reports pass/refusal counts by diagnostic code. + +## Versions, diff, confirm and label regeneration (RFC phase 4, backend) + +Phase 4's definition of done is "the TechDocs model can be changed from the diagram and the change +regenerates labels and commands". The backend half gives the editor the primitives for that; the +diagram UI lands separately on top of it. + +### Structural diff — `workflowmodel.Diff` + +`Diff(a, b *Model) ModelDiff` is pure and deterministic: spec only (metadata never counts), +every list sorted by key, the same pair always yields the same JSON. It compares five keyed +collections plus `spec.meta`: + +| Set | Key | Fields compared | +|---|---|---| +| `columns` | column id | label, hint, states, terminal | +| `states` | state id (regions flattened, with `parent`) | label, owner, type, parent, guardRef, pipelines, origin, column, number, kinds, waiting, replies, confidence | +| `transitions` | `from>to` (`from>to@on` when the pair occurs more than once) | on, guardRef, gateRef, note | +| `labels` | label name | color, description, state | +| `rules` | rule id, else `rules[i]` | kind, state, when (rendered predicates), reason | +| `meta` | — | version, author, description, license, source, provenance, derivation mode | + +Each set is `{added, removed, changed:[{key, fields:[{field, from, to}]}]}`; `summary` is one +line (`+2 states, −1 transition, 3 changed`; `no changes` when `empty`). Lists are rendered as +comma-joined strings so a field change is always a flat string pair. + +### Confirm inferred → local — `workflowmodel.Confirm` + +`Confirm(m, by)` returns a deep copy with every `confidence: inferred` element (states at any +depth, events, gates) marked confirmed, `meta.provenance = local`, `meta.version` cleared so the +store assigns the next integer version, and `metadata.annotations["workflow.hivecommons.dev/confirmed-by"]` +set to the caller. `meta.source` is kept: the model still says where it was ingested from. The +input is never modified; `Inferred(m)` counts what is left to confirm. The store gained +`NextVersion(name)` (largest integer version + 1, never a taken one) for this. + +### API + +| Route | Role | Answer | +|---|---|---| +| `GET /api/workflows/{name}/versions` | any | `{versions: []Entry}` newest save first; hive-default returns its one virtual entry; 404 unknown | +| `GET /api/workflows/{name}/diff?from=V1&to=V2[&fromName=&toName=]` | any | `{from: Entry, to: Entry, diff: ModelDiff}`; 400 when `from`/`to` missing, 404 when either side is unknown. `fromName`/`toName` let a local model be diffed against `hive-default` | +| `POST /api/workflows/{name}/{version}/confirm` | owner | 201 `{entry, confirmed, from}` — the confirmed copy saved as the next version; 409 for hive-default (read-only); audited `workflow_confirm` | +| `GET /api/workflows/{name}/{version}/labels` | any | `{name, version, hash, prefix, labels: []LabelDef{name,color,description,state}, commands: [{state, verbs}], stages: [{state, number, label}]}` — the regeneration preview; 400 with diagnostics for a draft that does not compile | + +`labels` is `Profile.LabelDefs(stagelabels.StageColor)`: the model's `spec.labels.entries` with +the stage colour filled in where an entry names none, the state's label as description where an +entry has none, and descriptions clamped to GitHub's limit. `commands` is `Profile.Replies` (the +human-gate verbs per state) in column order. `stages` is the built-in stage label each numbered +state maps onto today, since the engine still writes the built-in family by stage number. + +### Label regeneration on bind + +`automerge.Engine.ensureStageLabels` provisions the built-in family once per repository per +process. It now also asks `stageengine.Options.WorkflowFor` (the P2 lookup cmd/hive wires from +the bindings and the store) for the profile bound to the repository; for a bound, non-hive-default +profile it creates or recolours that profile's `LabelDefs` in the same pass, after the built-in +family, reusing the one label list call. The memo is per repository *and* profile hash +(`StageTracker.ensuredWorkflow`), so a rebinding, an unbind, or a new version of the bound model +changes the hash and the next pass re-lists and provisions only what is missing. No reload hook is +needed: the dashboard's binding edit mutates the live config, `WorkflowFor` reads it on every call, +and the hash comparison does the rest. An unbound repository (or one bound to hive-default) takes +exactly the pre-P4 path: one list, the built-in family, no model labels. + +### Known gaps (phase 4 backend) + +- Labels are only ever created or recoloured, never deleted: unbinding or removing an entry leaves + the label in the repository (same policy as the built-in family). Removal needs an explicit + operator action, not a sweep side effect. +- The engine derives through the built-in stage table by number; a model's own `labels.entries` + are provisioned but not yet written onto items. Writing model labels is the engine half of + phase 4 and follows the diagram editor. +- A model without a `labels` block regenerates nothing (TechDocs as shipped); the preview still + shows its commands and the built-in stages its numbered states land on. diff --git a/src/pkg/github/automerge/automerge_stage_labels.go b/src/pkg/github/automerge/automerge_stage_labels.go index 3ec17754e7..1c067eb514 100644 --- a/src/pkg/github/automerge/automerge_stage_labels.go +++ b/src/pkg/github/automerge/automerge_stage_labels.go @@ -35,6 +35,8 @@ import ( "github.com/hivecommons/hive/pkg/stageengine" "github.com/hivecommons/hive/pkg/stageengine/prstage" "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" ) // StageLabelPolicy is the live `governor.labels.stage` policy. @@ -261,7 +263,11 @@ type StageTracker struct { obs map[string]map[string]stageObservation // key → lane → observation details map[string]stageDetail // key → detail noted by trySweep* ensured map[string]bool // repo → labels provisioned this process - checks map[string]humanChecksEntry // key → head check digest (human routing) + // ensuredWorkflow is the hash of the bound workflow model whose + // labels were provisioned per repo; a rebinding changes the hash + // and the next pass re-provisions (hivecommons/hive#17620, P4). + ensuredWorkflow map[string]string + checks map[string]humanChecksEntry // key → head check digest (human routing) // superseded remembers when the hive-authored superseded check last // ran per key+head, so a PR that legitimately sits at 085 is not // re-read every pass. @@ -270,7 +276,7 @@ type StageTracker struct { // NewStageTracker returns an empty tracker. func NewStageTracker() *StageTracker { - return &StageTracker{obs: map[string]map[string]stageObservation{}, details: map[string]stageDetail{}, ensured: map[string]bool{}, checks: map[string]humanChecksEntry{}, superseded: map[string]time.Time{}} + return &StageTracker{obs: map[string]map[string]stageObservation{}, details: map[string]stageDetail{}, ensured: map[string]bool{}, ensuredWorkflow: map[string]string{}, checks: map[string]humanChecksEntry{}, superseded: map[string]time.Time{}} } var sharedStageTracker = NewStageTracker() @@ -1643,10 +1649,21 @@ func (f *engineStatusForge) DeleteComment(ctx context.Context, id int64) error { // missing or whose colour drifted from the table. A PATCH keeps the label on // every PR wearing it, so a pre-existing `dco` label is recoloured in place // rather than re-added. +// +// A repository bound to a workflow model (hivecommons/hive#17620, P4) also +// gets that model's own label vocabulary (Profile.LabelDefs) created or +// recoloured, memoised by the model's hash so a rebinding — or a new +// version of the bound model — re-provisions on the next pass. An unbound +// repository takes exactly the built-in path. func (c *Engine) ensureStageLabels(ctx context.Context, owner, repo string, tracker *StageTracker, kinds authorkind.Names) error { full := strings.ToLower(owner + "/" + repo) + var model []workflowmodel.LabelDef + modelHash := "" + if p := c.workflowProfile(owner, repo); p != nil { + model, modelHash = p.LabelDefs(stagelabels.StageColor), p.Hash + } tracker.mu.Lock() - done := tracker.ensured[full] + done := tracker.ensured[full] && tracker.ensuredWorkflow[full] == modelHash tracker.mu.Unlock() if done { return nil @@ -1685,27 +1702,63 @@ func (c *Engine) ensureStageLabels(ctx context.Context, owner, repo string, trac } existing[strings.ToLower(stage.Label())] = renamed } - for _, def := range stagelabels.LabelDefsFor(kinds) { - cur, ok := existing[strings.ToLower(def.Name)] + ensure := func(name, color, description string) error { + cur, ok := existing[strings.ToLower(name)] switch { case !ok: - _, _, err := c.gh.Issues.CreateLabel(ctx, owner, repo, &gh.Label{Name: gh.Ptr(def.Name), Color: gh.Ptr(def.Color), Description: gh.Ptr(stagelabels.ClampLabelDescription(def.Description))}) + created, _, err := c.gh.Issues.CreateLabel(ctx, owner, repo, &gh.Label{Name: gh.Ptr(name), Color: gh.Ptr(color), Description: gh.Ptr(stagelabels.ClampLabelDescription(description))}) if err != nil && !isGitHubStatus(err, http.StatusUnprocessableEntity) { - return fmt.Errorf("creating label %s in %s: %w", def.Name, full, err) + return fmt.Errorf("creating label %s in %s: %w", name, full, err) } - case !strings.EqualFold(cur.GetColor(), def.Color): - _, _, err := c.gh.Issues.EditLabel(ctx, owner, repo, cur.GetName(), &gh.Label{Name: gh.Ptr(def.Name), Color: gh.Ptr(def.Color), Description: gh.Ptr(stagelabels.ClampLabelDescription(def.Description))}) + if created == nil { + created = &gh.Label{Name: gh.Ptr(name), Color: gh.Ptr(color)} + } + existing[strings.ToLower(name)] = created + case !strings.EqualFold(cur.GetColor(), color): + _, _, err := c.gh.Issues.EditLabel(ctx, owner, repo, cur.GetName(), &gh.Label{Name: gh.Ptr(name), Color: gh.Ptr(color), Description: gh.Ptr(stagelabels.ClampLabelDescription(description))}) if err != nil { - return fmt.Errorf("recolouring label %s in %s: %w", def.Name, full, err) + return fmt.Errorf("recolouring label %s in %s: %w", name, full, err) } + cur.Color = gh.Ptr(color) + } + return nil + } + for _, def := range stagelabels.LabelDefsFor(kinds) { + if err := ensure(def.Name, def.Color, def.Description); err != nil { + return err + } + } + for _, def := range model { + if err := ensure(def.Name, def.Color, def.Description); err != nil { + return err } } tracker.mu.Lock() tracker.ensured[full] = true + if tracker.ensuredWorkflow == nil { + tracker.ensuredWorkflow = map[string]string{} + } + tracker.ensuredWorkflow[full] = modelHash tracker.mu.Unlock() return nil } +// workflowProfile is the compiled workflow model bound to owner/repo, or +// nil when the engine has no stage-engine options, no lookup (P2's +// Options.WorkflowFor), the repository is unbound, or the binding is +// hive-default (whose labels are the built-in family already provisioned +// above). +func (c *Engine) workflowProfile(owner, repo string) *workflowmodel.Profile { + if c == nil || c.stageEngine == nil || c.stageEngine.WorkflowFor == nil { + return nil + } + p := c.stageEngine.WorkflowFor(owner, repo) + if p == nil || p.Name == hivedefault.Name { + return nil + } + return p +} + // ApplyStageLabels runs the reconcile pass on a one-shot engine. The // tracker and store are process-shared, so observations recorded by the // lanes' own one-shot engines earlier in the cycle are what it applies. diff --git a/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go b/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go new file mode 100644 index 0000000000..943e275af4 --- /dev/null +++ b/src/pkg/github/automerge/automerge_stage_labels_workflow_test.go @@ -0,0 +1,150 @@ +package automerge + +import ( + "context" + "encoding/json" + "os" + "slices" + "testing" + "time" + + "github.com/hivecommons/hive/pkg/authorkind" + hgithub "github.com/hivecommons/hive/pkg/github" + "github.com/hivecommons/hive/pkg/stageengine" + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +func workflowTestProfile(t *testing.T, labels ...workflowmodel.LabelEntry) *workflowmodel.Profile { + t.Helper() + b, err := os.ReadFile("../../workflowmodel/testdata/models/techdocs.json") + if err != nil { + t.Fatal(err) + } + var m workflowmodel.Model + if err := json.Unmarshal(b, &m); err != nil { + t.Fatal(err) + } + m.Spec.Labels = workflowmodel.LabelSet{Prefix: "docs", Entries: labels} + p, _, err := workflowmodel.Compile(&m) + if err != nil { + t.Fatal(err) + } + return p +} + +// newWorkflowStageEngine is newStageEngine with a WorkflowFor lookup the +// test controls per call. +func newWorkflowStageEngine(t *testing.T, apiURL string, lookup func(owner, repo string) *workflowmodel.Profile) (*Engine, *StageTracker) { + t.Helper() + clock := &contributorLaneClock{at: time.Date(2026, 11, 11, 12, 0, 0, 0, time.UTC)} + client := hgithub.NewClient("token", "acme", []string{"widget"}, nil, apiURL) + tracker := NewStageTracker() + e := New(client, Options{ + Now: clock.now, + StageTracker: tracker, + StageEngine: &stageengine.Options{WorkflowFor: lookup}, + }) + return e, tracker +} + +func TestEnsureStageLabelsUnboundRepoIsUnchanged(t *testing.T) { + ctx := context.Background() + for name, lookup := range map[string]func(owner, repo string) *workflowmodel.Profile{ + "no lookup": nil, + "unbound": func(string, string) *workflowmodel.Profile { return nil }, + "hive-default": func(string, string) *workflowmodel.Profile { return hivedefault.Profile() }, + "other repo": func(_, repo string) *workflowmodel.Profile { + if repo == "other" { + return workflowTestProfile(t, workflowmodel.LabelEntry{Name: "docs/x"}) + } + return nil + }, + } { + t.Run(name, func(t *testing.T) { + api := &stageAPI{labels: map[string]string{"bug": "ff0000"}} + srv := newStageAPI(t, api) + defer srv.Close() + e, tracker := newWorkflowStageEngine(t, srv.URL, lookup) + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil { + t.Fatal(err) + } + if len(api.creates) != len(stagelabels.AllLabelDefs()) || api.lists != 1 || len(api.edits) != 0 { + t.Fatalf("creates=%d lists=%d edits=%v", len(api.creates), api.lists, api.edits) + } + for _, c := range api.creates { + if c == "docs/x" { + t.Fatalf("model label leaked into an unbound repo: %v", api.creates) + } + } + // memoised: a second call does nothing + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil || api.lists != 1 { + t.Fatalf("second call: err=%v lists=%d", err, api.lists) + } + }) + } +} + +func TestEnsureStageLabelsProvisionsBoundModelAndReprovisionsOnRebind(t *testing.T) { + ctx := context.Background() + api := &stageAPI{labels: map[string]string{"docs/020-analysing": "000000"}} + srv := newStageAPI(t, api) + defer srv.Close() + + v1 := workflowTestProfile(t, + workflowmodel.LabelEntry{Name: "docs/010-requested", State: "requested"}, + workflowmodel.LabelEntry{Name: "docs/020-analysing", Color: "123456", Description: "analysing", State: "analysing"}, + ) + current := v1 + e, tracker := newWorkflowStageEngine(t, srv.URL, func(string, string) *workflowmodel.Profile { return current }) + + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil { + t.Fatal(err) + } + if api.lists != 1 || !slices.Contains(api.creates, "docs/010-requested") || len(api.creates) != len(stagelabels.AllLabelDefs())+1 { + t.Fatalf("lists=%d creates=%v", api.lists, api.creates) + } + if api.labels["docs/010-requested"] != stagelabels.StageColor || api.labels["docs/020-analysing"] != "123456" || !slices.Contains(api.edits, "docs/020-analysing=123456") { + t.Fatalf("labels=%v edits=%v", api.labels, api.edits) + } + if tracker.ensuredWorkflow["acme/widget"] != v1.Hash { + t.Fatalf("memo %q", tracker.ensuredWorkflow["acme/widget"]) + } + + // same binding: memoised, no second list + api.reset() + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil || api.lists != 1 || api.writes() != 0 || len(api.creates)+len(api.edits) != 0 { + t.Fatalf("idempotent: err=%v lists=%d writes=%d creates=%v edits=%v", err, api.lists, api.writes(), api.creates, api.edits) + } + + // rebind to a new version with one more label: re-list, create only the new one + current = workflowTestProfile(t, + workflowmodel.LabelEntry{Name: "docs/010-requested", State: "requested"}, + workflowmodel.LabelEntry{Name: "docs/020-analysing", Color: "123456", Description: "analysing", State: "analysing"}, + workflowmodel.LabelEntry{Name: "docs/045-analysis-review", State: "analysis-review"}, + ) + if current.Hash == v1.Hash { + t.Fatal("fixture: hash did not change") + } + api.reset() + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil { + t.Fatal(err) + } + if api.lists != 2 || !slices.Equal(api.creates, []string{"docs/045-analysis-review"}) || len(api.edits) != 0 { + t.Fatalf("rebind: lists=%d creates=%v edits=%v", api.lists, api.creates, api.edits) + } + if tracker.ensuredWorkflow["acme/widget"] != current.Hash { + t.Fatalf("memo %q", tracker.ensuredWorkflow["acme/widget"]) + } + + // unbind: re-list once (hash changes to ""), nothing to create; model labels are left in place + current = nil + api.reset() + if err := e.ensureStageLabels(ctx, "acme", "widget", tracker, authorkind.DefaultNames); err != nil || api.lists != 3 || len(api.creates)+len(api.edits) != 0 { + t.Fatalf("unbind: err=%v lists=%d creates=%v edits=%v", err, api.lists, api.creates, api.edits) + } + if _, ok := api.labels["docs/045-analysis-review"]; !ok { + t.Fatal("unbind deleted a model label") + } +} From ac51901947d282829333cac9df14771656181917 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 08:31:13 -0700 Subject: [PATCH 4/7] Workflow models: editor endpoints for YAML and unsaved-model simulate Adds POST /api/workflows/yaml (model <-> YAML, inline errors) and POST /api/workflows/simulate (body model + facts, same shape as the stored-model variant), and lets POST /api/workflows assign the next free version when meta.version is empty so the editor's Save never has to guess. OpenAPI entries and handler tests included. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson (cherry picked from commit d8949480e0f33151bb7417f6d370519246cbf70a) --- dashboard/openapi.json | 110 +++++++++ src/pkg/dashboard/api_workflows.go | 11 + src/pkg/dashboard/api_workflows_editor.go | 222 ++++++++++++++++++ .../dashboard/api_workflows_editor_test.go | 124 ++++++++++ 4 files changed, 467 insertions(+) create mode 100644 src/pkg/dashboard/api_workflows_editor.go create mode 100644 src/pkg/dashboard/api_workflows_editor_test.go diff --git a/dashboard/openapi.json b/dashboard/openapi.json index abb90f57ab..f27e592f57 100644 --- a/dashboard/openapi.json +++ b/dashboard/openapi.json @@ -35398,6 +35398,116 @@ } } }, + "/api/workflows/yaml": { + "post": { + "tags": [ + "Workflows" + ], + "summary": "Convert a workflow model between JSON and YAML", + "description": "Editor helper. Send `model` to get its YAML (field names are the JSON tags), or `yaml` to get the parsed model back. A YAML parse or schema error answers 200 with `error` set so the editor can show it inline. Read-only; no store or owner role needed.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "model": { + "$ref": "#/components/schemas/WorkflowModel" + }, + "yaml": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "yaml, or model (or error) depending on the input", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "yaml": { + "type": "string" + }, + "model": { + "$ref": "#/components/schemas/WorkflowModel" + }, + "error": { + "type": "string" + } + } + } + } + } + }, + "400": { + "description": "Neither model nor yaml supplied, or malformed body" + } + } + } + }, + "/api/workflows/simulate": { + "post": { + "tags": [ + "Workflows" + ], + "summary": "Simulate an issue or PR against an unsaved workflow model", + "description": "Editor variant of /api/workflows/{name}/{version}/simulate: the model travels in the body and `facts` are required (no forge fetch). Same decision and trace shape. Read-only; no store or owner role needed.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "model", + "kind", + "facts" + ], + "properties": { + "model": { + "$ref": "#/components/schemas/WorkflowModel" + }, + "kind": { + "type": "string", + "enum": [ + "issue", + "pr" + ] + }, + "facts": { + "$ref": "#/components/schemas/WorkflowSimulateFacts" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Decision, trace and normalized facts", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkflowSimulateResult" + } + } + } + }, + "400": { + "description": "Missing model, kind or facts" + }, + "422": { + "description": "Model does not compile; body carries diagnostics" + } + } + } + }, "/api/workflows/bindings": { "get": { "tags": [ diff --git a/src/pkg/dashboard/api_workflows.go b/src/pkg/dashboard/api_workflows.go index 9d45dc69c4..048540c6e9 100644 --- a/src/pkg/dashboard/api_workflows.go +++ b/src/pkg/dashboard/api_workflows.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/http" + "strings" "time" "github.com/hivecommons/hive/pkg/config" @@ -28,6 +29,7 @@ func (s *Server) registerWorkflowRoutes() { s.mux.HandleFunc("DELETE /api/workflows/{name}/{version}", s.handleWorkflowDelete) s.registerWorkflowVersionRoutes() s.mux.HandleFunc("POST /api/workflows/{name}/{version}/simulate", s.handleWorkflowSimulate) + s.registerWorkflowEditorRoutes() } func (s *Server) workflowStore(w http.ResponseWriter) *wfstore.Store { @@ -129,6 +131,15 @@ func (s *Server) handleWorkflowSave(w http.ResponseWriter, r *http.Request) { jsonError(w, fmt.Sprintf("body is %s@%s, path is %s@%s", m.Metadata.Name, m.Spec.Meta.Version, name, r.PathValue("version")), http.StatusBadRequest) return } + // The editor posts meta.version "" to mean "next free integer version". + if r.PathValue("name") == "" && strings.TrimSpace(m.Spec.Meta.Version) == "" && m.Metadata.Name != "" { + v, err := st.NextVersion(m.Metadata.Name) + if err != nil { + workflowStoreError(w, err) + return + } + m.Spec.Meta.Version = v + } e, err := st.Save(&m, requestUser(r), time.Now().UTC()) if err != nil { workflowStoreError(w, err) diff --git a/src/pkg/dashboard/api_workflows_editor.go b/src/pkg/dashboard/api_workflows_editor.go new file mode 100644 index 0000000000..e9596da9c0 --- /dev/null +++ b/src/pkg/dashboard/api_workflows_editor.go @@ -0,0 +1,222 @@ +package dashboard + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/hivecommons/hive/pkg/stageengine" + "github.com/hivecommons/hive/pkg/stagelabels" + "github.com/hivecommons/hive/pkg/workflowmodel" + "gopkg.in/yaml.v3" +) + +// Workflow-model editor support (hivecommons/hive#17620, RFC phase 4 UI). +// +// The editor works on an unsaved model in the browser, so the read-only +// computations it needs — YAML round-trip and a what-if derivation — take +// the model in the request body rather than a stored name@version. Like +// POST /api/workflows/compile they need no store and no owner role. +func (s *Server) registerWorkflowEditorRoutes() { + s.mux.HandleFunc("POST /api/workflows/yaml", s.handleWorkflowYAML) + s.mux.HandleFunc("POST /api/workflows/simulate", s.handleWorkflowSimulateModel) +} + +// workflowYAMLRequest carries either a model (→ yaml) or yaml (→ model). +type workflowYAMLRequest struct { + Model json.RawMessage `json:"model,omitempty"` + YAML string `json:"yaml,omitempty"` +} + +// handleWorkflowYAML converts between the JSON model and its YAML form. +// Field names are the json tags on both sides: the JSON document is parsed +// as YAML (JSON is a YAML subset) and re-encoded, so a round-trip changes +// nothing but whitespace. A YAML parse or schema error answers 200 with +// `error` set so the editor can show it inline next to the text. +func (s *Server) handleWorkflowYAML(w http.ResponseWriter, r *http.Request) { + var req workflowYAMLRequest + if err := decodeBody(r, &req); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + switch { + case len(req.Model) > 0 && strings.TrimSpace(string(req.Model)) != "null": + var m workflowmodel.Model + if err := json.Unmarshal(req.Model, &m); err != nil { + jsonError(w, "model: "+err.Error(), http.StatusBadRequest) + return + } + out, err := workflowModelToYAML(&m) + if err != nil { + jsonError(w, err.Error(), http.StatusInternalServerError) + return + } + jsonResponse(w, map[string]interface{}{"yaml": out}) + case strings.TrimSpace(req.YAML) != "": + m, err := workflowModelFromYAML(req.YAML) + if err != nil { + jsonResponse(w, map[string]interface{}{"error": err.Error()}) + return + } + jsonResponse(w, map[string]interface{}{"model": m}) + default: + jsonError(w, "set model (to get yaml) or yaml (to get a model)", http.StatusBadRequest) + } +} + +func workflowModelToYAML(m *workflowmodel.Model) (string, error) { + b, err := json.Marshal(m) + if err != nil { + return "", err + } + var node yaml.Node + if err := yaml.Unmarshal(b, &node); err != nil { + return "", err + } + // The JSON decoder leaves flow style on every mapping; clear it so the + // encoder writes block YAML. + workflowYAMLBlockStyle(&node) + var buf bytes.Buffer + enc := yaml.NewEncoder(&buf) + enc.SetIndent(2) + if err := enc.Encode(&node); err != nil { + return "", err + } + if err := enc.Close(); err != nil { + return "", err + } + return buf.String(), nil +} + +func workflowYAMLBlockStyle(n *yaml.Node) { + if n == nil { + return + } + if n.Kind == yaml.MappingNode || n.Kind == yaml.SequenceNode { + n.Style = 0 + } + if n.Kind == yaml.ScalarNode && n.Style == yaml.DoubleQuotedStyle { + n.Style = 0 + } + for _, c := range n.Content { + workflowYAMLBlockStyle(c) + } +} + +func workflowModelFromYAML(text string) (*workflowmodel.Model, error) { + var generic interface{} + if err := yaml.Unmarshal([]byte(text), &generic); err != nil { + return nil, err + } + generic = workflowYAMLToJSONValue(generic) + b, err := json.Marshal(generic) + if err != nil { + return nil, err + } + var m workflowmodel.Model + dec := json.NewDecoder(bytes.NewReader(b)) + dec.DisallowUnknownFields() + if err := dec.Decode(&m); err != nil { + return nil, err + } + return &m, nil +} + +// workflowYAMLToJSONValue turns yaml.v3's map[string]interface{} and +// []interface{} trees into JSON-marshalable values (yaml.v3 already uses +// string keys for string-keyed maps; non-string keys are stringified). +func workflowYAMLToJSONValue(v interface{}) interface{} { + switch t := v.(type) { + case map[string]interface{}: + out := make(map[string]interface{}, len(t)) + for k, val := range t { + out[k] = workflowYAMLToJSONValue(val) + } + return out + case map[interface{}]interface{}: + out := make(map[string]interface{}, len(t)) + for k, val := range t { + out[fmt.Sprint(k)] = workflowYAMLToJSONValue(val) + } + return out + case []interface{}: + for i := range t { + t[i] = workflowYAMLToJSONValue(t[i]) + } + return t + } + return v +} + +// workflowSimulateModelRequest is the editor's variant of the stored-model +// simulate request: the model travels in the body and facts are required +// (no forge fetch — the canvas may hold something that was never saved). +type workflowSimulateModelRequest struct { + Model *workflowmodel.Model `json:"model"` + Kind string `json:"kind"` + Facts *SimulateFacts `json:"facts"` +} + +// handleWorkflowSimulateModel answers POST /api/workflows/simulate with the +// same SimulateResponse as the stored-model variant. 422 with diagnostics +// when the posted model does not compile, so the editor can point at the +// offending element instead of a bare "does not compile". +func (s *Server) handleWorkflowSimulateModel(w http.ResponseWriter, r *http.Request) { + var req workflowSimulateModelRequest + if err := decodeBody(r, &req); err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + if req.Model == nil { + jsonError(w, "model is required", http.StatusBadRequest) + return + } + req.Kind = strings.ToLower(strings.TrimSpace(req.Kind)) + if req.Kind == "" { + jsonError(w, "kind must be issue or pr", http.StatusBadRequest) + return + } + if req.Facts == nil { + jsonError(w, "facts are required when simulating an unsaved model", http.StatusBadRequest) + return + } + profile, diags, cerr := workflowmodel.Compile(req.Model) + if cerr != nil { + if diags == nil { + diags = []workflowmodel.Diagnostic{} + } + jsonStatusResponse(w, http.StatusUnprocessableEntity, map[string]interface{}{ + "error": fmt.Sprintf("model does not compile: %d diagnostics", len(diags)), + "diagnostics": diags, + }) + return + } + ref, err := stageengine.SimulateRef(req.Kind, "sim", "sim", 0) + if err != nil { + jsonError(w, err.Error(), http.StatusBadRequest) + return + } + var notes []string + norm := normalizeSimulateFacts(req.Kind, *req.Facts) + ef := norm.toEngine(ref) + if ref.Kind == stagelabels.PRItem && len(ef.PR.Lanes) == 0 { + lane := stageengine.SimulatedLane(ef) + ef.PR.Lanes = []stageengine.LaneFact{lane} + norm.PR.Lane = &SimulateLane{Lane: lane.Lane, Reason: lane.Reason} + notes = append(notes, fmt.Sprintf("no lane observation supplied; simulated lane %s with reason %q from the head facts", lane.Lane, lane.Reason)) + } + dec, trace := stageengine.Explain(profile, ref.Kind, ef) + if trace == nil { + trace = []stageengine.RuleTrace{} + } + jsonResponse(w, SimulateResponse{ + Decision: simulateDecision(profile, dec), + Trace: trace, + Facts: norm, + Filled: []string{}, + Unknown: []string{}, + Notes: notes, + }) +} diff --git a/src/pkg/dashboard/api_workflows_editor_test.go b/src/pkg/dashboard/api_workflows_editor_test.go new file mode 100644 index 0000000000..2e16349e68 --- /dev/null +++ b/src/pkg/dashboard/api_workflows_editor_test.go @@ -0,0 +1,124 @@ +package dashboard + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + + "github.com/hivecommons/hive/pkg/workflowmodel" + "github.com/hivecommons/hive/pkg/workflowmodel/hivedefault" +) + +func TestWorkflowYAMLRoundTrip(t *testing.T) { + s := workflowTestServer(t, false) + m := techdocsModel(t) + rec := wfReq(s, http.MethodPost, "/api/workflows/yaml", map[string]any{"model": m}, false) + if rec.Code != http.StatusOK { + t.Fatalf("to yaml: %d %s", rec.Code, rec.Body) + } + var out struct { + YAML string `json:"yaml"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(out.YAML, "apiVersion: ") || !strings.Contains(out.YAML, "\nspec:\n") || strings.Contains(out.YAML, "{") { + t.Fatalf("expected block yaml, got:\n%s", out.YAML) + } + rec = wfReq(s, http.MethodPost, "/api/workflows/yaml", map[string]any{"yaml": out.YAML}, false) + if rec.Code != http.StatusOK { + t.Fatalf("from yaml: %d %s", rec.Code, rec.Body) + } + var back struct { + Model *workflowmodel.Model `json:"model"` + Error string `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &back); err != nil { + t.Fatal(err) + } + if back.Error != "" || back.Model == nil { + t.Fatalf("round trip: %s", rec.Body) + } + a, _ := json.Marshal(m) + b, _ := json.Marshal(back.Model) + if string(a) != string(b) { + t.Fatalf("model changed across yaml round trip:\n%s\n%s", a, b) + } +} + +func TestWorkflowYAMLErrorsInline(t *testing.T) { + s := workflowTestServer(t, false) + for _, text := range []string{"spec: [unclosed", "metadata:\n name: x\nbogus: 1\n"} { + rec := wfReq(s, http.MethodPost, "/api/workflows/yaml", map[string]any{"yaml": text}, false) + if rec.Code != http.StatusOK { + t.Fatalf("%q: %d %s", text, rec.Code, rec.Body) + } + var out map[string]any + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if e, _ := out["error"].(string); e == "" { + t.Fatalf("%q: expected inline error, got %s", text, rec.Body) + } + } + rec := wfReq(s, http.MethodPost, "/api/workflows/yaml", map[string]any{}, false) + if rec.Code != http.StatusBadRequest { + t.Fatalf("empty body: %d", rec.Code) + } +} + +func TestWorkflowSimulateUnsavedModel(t *testing.T) { + s := workflowTestServer(t, false) + rec := wfReq(s, http.MethodPost, "/api/workflows/simulate", map[string]any{ + "model": hivedefault.Generate(), "kind": "pr", + "facts": map[string]any{ + "labels": []string{"role/trusted"}, "open": true, + "author": map[string]any{"login": "andy", "kind": "human", "role": "trusted"}, + "pr": map[string]any{"checks": "green", "review": "approved", "dco": "yes"}, + }, + }, false) + if rec.Code != http.StatusOK { + t.Fatalf("%d %s", rec.Code, rec.Body) + } + var out SimulateResponse + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + if out.Decision.Stage != 80 || out.Decision.State != "awaiting-merge-admission" || len(out.Trace) != 1 { + t.Fatalf("decision %+v trace %+v", out.Decision, out.Trace) + } + + // Missing facts and missing kind are 400s; a non-compiling model is a + // 422 that carries the diagnostics. + rec = wfReq(s, http.MethodPost, "/api/workflows/simulate", map[string]any{"model": hivedefault.Generate(), "kind": "pr"}, false) + if rec.Code != http.StatusBadRequest { + t.Fatalf("no facts: %d", rec.Code) + } + rec = wfReq(s, http.MethodPost, "/api/workflows/simulate", map[string]any{"model": hivedefault.Generate(), "facts": map[string]any{}}, false) + if rec.Code != http.StatusBadRequest { + t.Fatalf("no kind: %d", rec.Code) + } + broken := techdocsModel(t) + broken.Spec.Transitions = nil + rec = wfReq(s, http.MethodPost, "/api/workflows/simulate", map[string]any{"model": broken, "kind": "issue", "facts": map[string]any{"open": true}}, false) + if rec.Code != http.StatusUnprocessableEntity || !strings.Contains(rec.Body.String(), `"diagnostics"`) { + t.Fatalf("broken model: %d %s", rec.Code, rec.Body) + } +} + +func TestWorkflowSaveAssignsNextVersionWhenEmpty(t *testing.T) { + s := workflowTestServer(t, true) + m := techdocsModel(t) + m.Metadata.Name = "editor-draft" + m.Spec.Meta.Version = "" + rec := wfReq(s, http.MethodPost, "/api/workflows", m, true) + if rec.Code != http.StatusCreated { + t.Fatalf("first save: %d %s", rec.Code, rec.Body) + } + if !strings.Contains(rec.Body.String(), `"version":"1"`) { + t.Fatalf("expected version 1: %s", rec.Body) + } + rec = wfReq(s, http.MethodPost, "/api/workflows", m, true) + if rec.Code != http.StatusCreated || !strings.Contains(rec.Body.String(), `"version":"2"`) { + t.Fatalf("second save: %d %s", rec.Code, rec.Body) + } +} From ab86a8d3f54cbf2a645e30a5399e70aee3f4ea63 Mon Sep 17 00:00:00 2001 From: Andrew Anderson Date: Wed, 11 Nov 2026 09:06:06 -0700 Subject: [PATCH 5/7] Workflow models: visual editor canvas, inspector, drawers and pure core Adds js/config-workflows-editor.js (pure model/layout/diff/problems/svg core plus the runtime: SVG canvas with lanes, chips, ports and edges; drag, marquee, pan/zoom, minimap; palette; inspector; Problems/YAML/Diff/ Simulate drawers; undo/redo; save/save-as/revert; export) with css/workflows-editor.css, wires the Editor nav item, Edit / Edit-a-copy buttons and the ingest 'Open in editor' affordance into the library, and pins the pure core under node in workflows_editor_ui_test.go. Refs hivecommons/hive#17620 Signed-off-by: Andrew Anderson (cherry picked from commit 16812a4519330e7bedfe955d816e425b45d0a94a) --- .../static-src/css/workflows-editor.css | 135 ++ .../dashboard/static-src/js/config-tabs.js | 3 + .../static-src/js/config-workflows-editor.js | 1813 +++++++++++++++ .../static-src/js/config-workflows-library.js | 15 +- src/pkg/dashboard/static-src/manifest.txt | 2 + src/pkg/dashboard/static/index.html | 1966 ++++++++++++++++- src/pkg/dashboard/workflows_editor_ui_test.go | 321 +++ 7 files changed, 4251 insertions(+), 4 deletions(-) create mode 100644 src/pkg/dashboard/static-src/css/workflows-editor.css create mode 100644 src/pkg/dashboard/static-src/js/config-workflows-editor.js create mode 100644 src/pkg/dashboard/workflows_editor_ui_test.go diff --git a/src/pkg/dashboard/static-src/css/workflows-editor.css b/src/pkg/dashboard/static-src/css/workflows-editor.css new file mode 100644 index 0000000000..68f1b105b9 --- /dev/null +++ b/src/pkg/dashboard/static-src/css/workflows-editor.css @@ -0,0 +1,135 @@ +/* Settings → Workflows → Editor (#17620 phase 4): toolbar, palette, canvas, inspector, drawer */ + .wf-ed { display: flex; flex-direction: column; gap: var(--sp-2); min-height: 36rem; height: calc(100vh - 14rem); } + .wf-ed-toolbar { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; padding: var(--sp-2) var(--sp-3); background: var(--panel); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); } + .wf-ed-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-size: var(--fs-sm); font-weight: 700; margin-right: var(--sp-2); } + .wf-ed-dirty { color: var(--status-warn); } + .wf-ed-ro { color: var(--muted); font-weight: 400; font-size: var(--fs-xs); border: var(--line-width) solid var(--border); border-radius: var(--r-pill); padding: var(--sp-0) var(--sp-2); } + .wf-ed-status { display: inline-block; border-radius: var(--r-pill); padding: var(--sp-1) var(--sp-3); font-size: var(--fs-xs); font-weight: 700; cursor: pointer; border: var(--line-width) solid var(--border); white-space: nowrap; max-width: 24rem; overflow: hidden; text-overflow: ellipsis; } + .wf-ed-status.ok { color: var(--status-ok); border-color: color-mix(in srgb, var(--status-ok) 40%, transparent); } + .wf-ed-status.warn { color: var(--status-warn); border-color: color-mix(in srgb, var(--status-warn) 40%, transparent); } + .wf-ed-status.error { color: var(--status-error); border-color: color-mix(in srgb, var(--status-error) 40%, transparent); } + .wf-ed-status.busy { opacity: .7; } + .wf-ed-sep { width: var(--line-width); align-self: stretch; background: var(--border); margin: var(--sp-0) var(--sp-1); } + .wf-ed-grow { flex: 1 1 auto; } + .wf-ed-zoom { font-size: var(--fs-xs); color: var(--muted); min-width: 3em; text-align: center; } + .wf-ed-menu { position: relative; display: inline-block; } + .wf-ed-menu-items { position: absolute; top: 100%; left: 0; z-index: 5; display: flex; flex-direction: column; min-width: 10rem; background: var(--panel-strong); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); padding: var(--sp-1); box-shadow: 0 var(--sp-2) var(--sp-5) color-mix(in srgb, var(--text) 20%, transparent); } + .wf-ed-menu-items button { background: none; border: none; color: var(--text); font: inherit; font-size: var(--fs-sm); text-align: left; padding: var(--sp-2) var(--sp-3); border-radius: var(--r-sm); cursor: pointer; } + .wf-ed-menu-items button:hover { background: var(--bg-soft); } + + .wf-ed-main { display: grid; grid-template-columns: 13rem minmax(0, 1fr) 18rem; gap: var(--sp-2); flex: 1 1 auto; min-height: 0; } + .wf-ed-palette, .wf-ed-inspector { background: var(--panel); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); padding: var(--sp-3); overflow: auto; font-size: var(--fs-sm); min-height: 0; } + .wf-ed-palette h4, .wf-ed-inspector h4 { margin: var(--sp-0) var(--sp-0) var(--sp-3); font-size: var(--fs-base); } + .wf-ed-inspector h5 { margin: var(--sp-4) var(--sp-0) var(--sp-2); font-size: var(--fs-sm); color: var(--muted); } + .wf-ed-pal-section summary { cursor: pointer; font-weight: 700; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: var(--sp-2) var(--sp-0); } + .wf-ed-pal-list { display: flex; flex-direction: column; gap: var(--sp-1); padding-bottom: var(--sp-2); } + .wf-ed-pal-item { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); border: var(--line-width) solid var(--line-strong); border-radius: var(--r-sm); background: var(--panel-strong); cursor: grab; font-size: var(--fs-sm); user-select: none; } + .wf-ed-pal-item:hover { border-color: var(--brand); } + .wf-ed-pal-item.disabled { opacity: .5; cursor: default; } + .wf-ed-pal-sub { color: var(--muted); font-size: var(--fs-2xs); } + + .wf-ed-canvas { position: relative; background: var(--bg); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); overflow: hidden; min-height: 20rem; touch-action: none; } + .wf-ed-svg { display: block; width: 100%; height: 100%; font-family: var(--font-ui); outline: none; cursor: default; } + .wf-ed-svg:focus-visible { box-shadow: inset 0 0 0 2px var(--brand); } + .wf-ed-bg { cursor: crosshair; } + .wf-ed-gridline { stroke: color-mix(in srgb, var(--border) 60%, transparent); stroke-width: .5; fill: none; } + .wf-ed-lane { fill: color-mix(in srgb, var(--panel) 70%, var(--bg)); stroke: var(--border); stroke-width: 1; } + .wf-ed-col.terminal .wf-ed-lane { fill: color-mix(in srgb, var(--status-ok) 6%, var(--bg)); stroke-dasharray: 4 3; } + .wf-ed-col.virtual .wf-ed-lane { fill: none; stroke-dasharray: 2 4; } + .wf-ed-col.selected .wf-ed-lane { stroke: var(--brand); stroke-width: 2; } + .wf-ed-col.drop .wf-ed-lane { stroke: var(--brand); fill: color-mix(in srgb, var(--brand) 8%, var(--bg)); } + .wf-ed-lanehead { fill: var(--panel-strong); cursor: grab; } + .wf-ed-lanehead:focus-visible { stroke: var(--brand); stroke-width: 2; outline: none; } + .wf-ed-colname { fill: var(--muted); font-size: var(--fs-xs); font-weight: 700; text-transform: uppercase; letter-spacing: .04em; pointer-events: none; } + .wf-ed-resize { fill: transparent; cursor: col-resize; } + .wf-ed-resize:hover { fill: color-mix(in srgb, var(--brand) 25%, transparent); } + .wf-ed-chip { fill: var(--panel-strong); stroke: var(--line-strong); stroke-width: 1; } + .wf-ed-state { cursor: grab; outline: none; } + .wf-ed-state:hover .wf-ed-chip { stroke: var(--text); } + .wf-ed-state:focus-visible .wf-ed-chip, .wf-ed-state.selected .wf-ed-chip { stroke: var(--brand); stroke-width: 2; } + .wf-ed-state.initial .wf-ed-chip { stroke: var(--status-info); stroke-width: 1.5; } + .wf-ed-state.initial.selected .wf-ed-chip { stroke: var(--brand); } + .wf-ed-state.terminal .wf-ed-chip { stroke-width: 2; } + .wf-ed-state.inferred .wf-ed-chip { stroke-dasharray: 4 3; stroke: var(--status-warn); } + .wf-ed-state.error .wf-ed-chip { stroke: var(--status-error); } + .wf-ed-state.dragging { opacity: .4; } + .wf-ed-state.wf-ed-sim-hit .wf-ed-chip { stroke: var(--status-ok); stroke-width: 3; fill: color-mix(in srgb, var(--status-ok) 12%, var(--panel-strong)); } + .wf-ed-state.wf-ed-sim-dim { opacity: .45; } + .wf-ed-initial { fill: var(--status-info); } + .wf-ed-label { fill: var(--text); font-size: var(--fs-sm); pointer-events: none; } + .wf-ed-num { fill: var(--muted); font-size: var(--fs-2xs); font-family: var(--font-mono); pointer-events: none; } + .wf-ed-port { fill: var(--panel-strong); stroke: var(--muted); stroke-width: 1.5; cursor: crosshair; opacity: 0; } + .wf-ed-state:hover .wf-ed-port, .wf-ed-state.selected .wf-ed-port, .wf-ed-state:focus-visible .wf-ed-port { opacity: 1; } + .wf-ed-port:hover { fill: var(--brand); stroke: var(--brand); } + .wf-ed-edge { cursor: pointer; } + .wf-ed-edge-hit { fill: none; stroke: transparent; stroke-width: 12; } + .wf-ed-edge-line { fill: none; stroke: var(--muted); stroke-width: 1.25; opacity: .85; } + .wf-ed-edge.back .wf-ed-edge-line { stroke-dasharray: 3 3; } + .wf-ed-edge:hover .wf-ed-edge-line { stroke: var(--text); stroke-width: 2; opacity: 1; } + .wf-ed-edge.selected .wf-ed-edge-line { stroke: var(--brand); stroke-width: 2.5; opacity: 1; } + .wf-ed-edge.error .wf-ed-edge-line { stroke: var(--status-error); } + .wf-ed-edge.warning .wf-ed-edge-line { stroke: var(--status-warn); } + .wf-ed-edge-label { fill: var(--muted); font-size: var(--fs-2xs); pointer-events: none; } + .wf-ed-edge.selected .wf-ed-edge-label { fill: var(--brand); font-weight: 700; } + .wf-ed-edge-pending { fill: none; stroke: var(--brand); stroke-width: 1.5; stroke-dasharray: 4 3; pointer-events: none; } + .wf-ed-arrowhead { fill: var(--muted); } + .wf-ed-arrowhead.sel { fill: var(--brand); } + .wf-ed-badge circle { fill: var(--status-warn); } + .wf-ed-badge.error circle { fill: var(--status-error); } + .wf-ed-badge text { fill: var(--panel); font-size: var(--fs-2xs); font-weight: 700; pointer-events: none; } + .wf-ed-badge { cursor: help; } + .wf-ed-confirm { cursor: pointer; } + .wf-ed-confirm rect { fill: color-mix(in srgb, var(--status-ok) 18%, var(--panel-strong)); stroke: var(--status-ok); stroke-width: 1; } + .wf-ed-confirm text { fill: var(--status-ok); font-size: var(--fs-2xs); font-weight: 700; pointer-events: none; } + .wf-ed-confirm:hover rect { fill: var(--status-ok); } + .wf-ed-confirm:hover text { fill: var(--panel); } + .wf-ed-marquee { fill: color-mix(in srgb, var(--brand) 10%, transparent); stroke: var(--brand); stroke-dasharray: 4 2; stroke-width: 1; pointer-events: none; } + .wf-ed-minimap { position: absolute; right: var(--sp-3); bottom: var(--sp-8); background: color-mix(in srgb, var(--panel) 85%, transparent); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); padding: var(--sp-1); cursor: pointer; } + .wf-ed-minimap-svg { display: block; } + .wf-ed-mm-lane { fill: var(--panel-strong); stroke: var(--border); stroke-width: .5; } + .wf-ed-mm-lane.terminal { fill: color-mix(in srgb, var(--status-ok) 10%, var(--panel-strong)); } + .wf-ed-mm-chip { fill: var(--muted); } + .wf-ed-mm-view { fill: color-mix(in srgb, var(--brand) 10%, transparent); stroke: var(--brand); stroke-width: 1; } + .wf-ed-hint { position: absolute; left: var(--sp-3); bottom: var(--sp-2); font-size: var(--fs-2xs); pointer-events: none; } + + .wf-ed-field { margin-bottom: var(--sp-3); } + .wf-ed-field input[type="text"], .wf-ed-field input[type="number"], .wf-ed-field select, .wf-ed-field textarea { width: 100%; } + .wf-ed-field input[readonly] { color: var(--muted); } + .wf-ed-inline { display: flex; gap: var(--sp-2); align-items: center; } + .wf-ed-inline input { flex: 1 1 auto; min-width: 0; } + .wf-ed-checks { display: flex; gap: var(--sp-4); align-items: center; flex-wrap: wrap; } + .wf-ed-warn { color: var(--status-warn); } + .wf-ed-translist { margin: var(--sp-0); padding-left: var(--sp-5); font-size: var(--fs-xs); } + .wf-ed-translist a { color: var(--text); text-decoration: none; } + .wf-ed-translist a:hover { text-decoration: underline; } + + .wf-ed-drawer { background: var(--panel); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); display: flex; flex-direction: column; flex: 0 0 auto; max-height: 2.5rem; overflow: hidden; } + .wf-ed-drawer.open { max-height: 16rem; } + .wf-ed-drawer-tabs { display: flex; gap: var(--sp-1); border-bottom: var(--line-width) solid var(--border); padding: var(--sp-0) var(--sp-2); } + .wf-ed-drawer-tab { background: none; border: none; border-bottom: 2px solid transparent; color: var(--muted); font: inherit; font-size: var(--fs-xs); font-weight: 700; padding: var(--sp-2) var(--sp-3); cursor: pointer; } + .wf-ed-drawer-tab.active { color: var(--text); border-bottom-color: var(--brand); } + .wf-ed-drawer-body { overflow: auto; padding: var(--sp-3); font-size: var(--fs-sm); min-height: 0; } + .wf-ed-drawer-body .wf-sim-form { grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr)); } + .wf-ed-problems { margin: var(--sp-0); padding-left: var(--sp-5); } + .wf-ed-problem a { color: var(--text); text-decoration: none; } + .wf-ed-problem a:hover { text-decoration: underline; } + .wf-ed-problem.error .wf-diag-code { color: var(--status-error); } + .wf-ed-problem.warning .wf-diag-code { color: var(--status-warn); } + .wf-ed-yaml-bar { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); } + .wf-ed-yaml-bar span { flex: 1 1 auto; } + .wf-ed-yaml { width: 100%; min-height: 10rem; font-size: var(--fs-xs); background: var(--bg-soft); border: var(--line-width) solid var(--border); border-radius: var(--r-sm); padding: var(--sp-2); resize: vertical; } + .wf-ed-diff-group h5 { margin: var(--sp-3) var(--sp-0) var(--sp-1); font-size: var(--fs-xs); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; } + .wf-ed-diff-row { padding: var(--sp-0) var(--sp-2); font-size: var(--fs-xs); border-left: 3px solid var(--border); margin-bottom: var(--sp-1); } + .wf-ed-diff-row.added { border-left-color: var(--status-ok); } + .wf-ed-diff-row.removed { border-left-color: var(--status-error); } + .wf-ed-diff-row.changed { border-left-color: var(--status-warn); } + .wf-ed-diff-row ul { margin: var(--sp-0); padding-left: var(--sp-5); } + + .wf-ed-start-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(14rem, 1fr)); gap: var(--sp-3); margin: var(--sp-4) var(--sp-0); } + .wf-ed-start-card { display: flex; flex-direction: column; gap: var(--sp-1); align-items: flex-start; text-align: left; padding: var(--sp-4); background: var(--panel); border: var(--line-width) solid var(--border); border-radius: var(--r-lg); color: var(--text); font: inherit; cursor: pointer; } + .wf-ed-start-card:hover:not([disabled]) { border-color: var(--brand); } + .wf-ed-start-card[disabled] { opacity: .55; cursor: not-allowed; } + .wf-ed-start-card span { color: var(--muted); font-size: var(--fs-xs); } + .wf-ed-start h4 { margin: var(--sp-5) var(--sp-0) var(--sp-2); font-size: var(--fs-base); } + .wf-ed-start-list { margin: var(--sp-0); padding-left: var(--sp-5); font-size: var(--fs-sm); display: flex; flex-direction: column; gap: var(--sp-1); } + .wf-ed-png { max-width: 100%; height: auto; display: block; border: var(--line-width) solid var(--border); border-radius: var(--r-sm); } diff --git a/src/pkg/dashboard/static-src/js/config-tabs.js b/src/pkg/dashboard/static-src/js/config-tabs.js index 858f7df9df..c16d315d5b 100644 --- a/src/pkg/dashboard/static-src/js/config-tabs.js +++ b/src/pkg/dashboard/static-src/js/config-tabs.js @@ -63,6 +63,9 @@ if (tabId === 'Appearance') loadThemeConfig(); if (tabId === 'Knowledge') { renderPublicKnowledgeControls(); loadKnowledgeConnectors(); loadKnowledgePipeline(); } if (tabId === 'Compliance') loadComplianceStatus(); + // The workflow editor's canvas needs real pointer listeners and a + // measured viewport once its SVG is in the DOM. + if (tabId === 'Workflows' && typeof wfEdAfterRender === 'function') wfEdAfterRender(); } // Keep the Save button's enabled state in lockstep with the repos/default // invariant on every render (requirement #4). Cheap and idempotent — a diff --git a/src/pkg/dashboard/static-src/js/config-workflows-editor.js b/src/pkg/dashboard/static-src/js/config-workflows-editor.js new file mode 100644 index 0000000000..5f9f673269 --- /dev/null +++ b/src/pkg/dashboard/static-src/js/config-workflows-editor.js @@ -0,0 +1,1813 @@ +// Settings → Workflows → Editor (hivecommons/hive#17620, RFC phase 4 UI). + // + // A design surface for workflow models: columns are vertical lanes on an + // SVG canvas, states are chips inside them, transitions are edges drawn + // by dragging from a chip's port. Everything that changes the model goes + // through the pure functions in the first half of this file (wfEd* that + // take a model and return a new one) so the node harness + // (workflows_editor_ui_test.go) can exercise them without a DOM. The + // second half is the runtime: _wfEd state, render, pointer handling, + // live compile, the YAML/Diff/Simulate drawers and persistence. + const WF_ED = { colW: 200, colGap: 32, chipH: 34, chipGap: 12, headH: 36, pad: 16, grid: 8, minRows: 4, portR: 5, minColW: 120, maxColW: 480 }; + const WF_ED_HISTORY_CAP = 200; + const WF_ED_COMPILE_DEBOUNCE_MS = 300; + const WF_ED_YAML_DEBOUNCE_MS = 500; + const WF_ED_UNASSIGNED = '__unassigned'; + const WF_ED_API_VERSION = 'workflow.hivecommons.dev/v1alpha1'; + const WF_ED_KINDS = ['issue', 'pr']; + const WF_ED_OWNERS = ['human', 'registered-agent', 'external-service', 'external-observation']; + const WF_ED_STATE_TYPES = ['', 'parallel', 'final']; + const WF_ED_DERIVATIONS = ['builtin', 'rules']; + const WF_ED_DRAWERS = ['problems', 'yaml', 'diff', 'simulate']; + // Palette: common states and columns. Numbers follow hive-default's + // stage ladder so a palette-built model lines up with builtin derivation. + const WF_ED_PALETTE_STATES = [ + { id: 'open', label: 'Open', number: 10 }, + { id: 'triage', label: 'Triage', number: 20 }, + { id: 'needs-decision', label: 'Needs decision', number: 25, kinds: ['issue'] }, + { id: 'claimed', label: 'Claimed', number: 30, kinds: ['issue'] }, + { id: 'in-progress', label: 'In progress', number: 40 }, + { id: 'in-review', label: 'In review', number: 60, kinds: ['pr'] }, + { id: 'ci-failing', label: 'CI failing', number: 65, kinds: ['pr'] }, + { id: 'merge-queue', label: 'Merge queue', number: 80, kinds: ['pr'] }, + { id: 'merged', label: 'Merged', number: 90, kinds: ['pr'] }, + { id: 'released', label: 'Released', number: 95 }, + { id: 'deployed-staging', label: 'Deployed · staging', number: 96 }, + { id: 'deployed-production', label: 'Deployed · production', number: 97 }, + { id: 'done', label: 'Done', number: 100 }, + { id: 'blocked', label: 'Blocked', number: 50 }, + ]; + const WF_ED_PALETTE_COLUMNS = [ + { id: 'intake', label: 'Intake' }, + { id: 'analysis', label: 'Analysis' }, + { id: 'writing', label: 'Writing' }, + { id: 'review', label: 'Review' }, + { id: 'publish', label: 'Publish' }, + { id: 'done', label: 'Done', terminal: true }, + ]; + + // --- Pure model core ---------------------------------------------------- + + function wfEdClone(v) { return v === undefined ? undefined : JSON.parse(JSON.stringify(v)); } + + function wfEdSlug(s) { + return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'item'; + } + + function wfEdSpec(model) { return (model && model.spec) || {}; } + + // wfEdStates flattens states including regions (the compiler does too). + function wfEdStates(model) { + const out = []; + (function walk(list) { (list || []).forEach(s => { if (s && s.id) { out.push(s); walk(s.regions); } }); })(wfEdSpec(model).states); + return out; + } + + function wfEdFindState(model, id) { return wfEdStates(model).find(s => s.id === id) || null; } + function wfEdFindColumn(model, id) { return (wfEdSpec(model).columns || []).find(c => c.id === id) || null; } + + function wfEdUniqueId(model, base) { + const taken = {}; + wfEdStates(model).forEach(s => { taken[s.id] = true; }); + (wfEdSpec(model).columns || []).forEach(c => { taken[c.id] = true; }); + const root = wfEdSlug(base); + if (!taken[root]) return root; + for (let i = 2; ; i++) { const cand = root + '-' + i; if (!taken[cand]) return cand; } + } + + function wfEdNewModel(name) { + return { + apiVersion: WF_ED_API_VERSION, kind: 'WorkflowModel', + metadata: { name: wfEdSlug(name || 'my-lifecycle') }, + spec: { + meta: { version: '1', provenance: 'local', source: 'hand', description: '' }, + scope: { forge: 'github.com', repository: '*' }, + initial: 'open', + columns: [{ id: 'intake', label: 'Intake', states: ['open'] }], + states: [{ id: 'open', label: 'Open', owner: 'human', lifecycle: { column: 'intake', number: 10 } }], + transitions: [], + derivation: { mode: 'builtin' }, + labels: { prefix: 'stage/' }, + }, + }; + } + + // wfEdNormalize makes the two column/state links agree: every column's + // states list is rebuilt from membership plus lifecycle.column fallbacks, + // and each state's lifecycle.column names the lane it sits in. + function wfEdNormalize(model) { + const m = wfEdClone(model) || {}; + m.spec = m.spec || {}; + const spec = m.spec; + spec.columns = Array.isArray(spec.columns) ? spec.columns : []; + spec.states = Array.isArray(spec.states) ? spec.states : []; + spec.transitions = Array.isArray(spec.transitions) ? spec.transitions : []; + const states = wfEdStates(m); + const known = {}; states.forEach(s => { known[s.id] = s; }); + const placed = {}; + spec.columns.forEach(c => { + c.states = (Array.isArray(c.states) ? c.states : []).filter(id => known[id] && !placed[id]); + c.states.forEach(id => { placed[id] = c.id; }); + }); + states.forEach(s => { + if (placed[s.id]) { s.lifecycle = Object.assign({}, s.lifecycle, { column: placed[s.id] }); return; } + const want = s.lifecycle && s.lifecycle.column; + const col = want && spec.columns.find(c => c.id === want); + if (col) { col.states.push(s.id); placed[s.id] = col.id; } + else if (s.lifecycle && s.lifecycle.column) { s.lifecycle = Object.assign({}, s.lifecycle); delete s.lifecycle.column; } + }); + return m; + } + + function wfEdAddColumn(model, col, index) { + const m = wfEdNormalize(model); + const c = Object.assign({ label: '' }, col || {}); + c.id = wfEdUniqueId(m, c.id || c.label || 'column'); + if (!c.label) c.label = c.id; + c.states = []; + if (!c.terminal) delete c.terminal; + if (!c.hint) delete c.hint; + const at = (index === undefined || index === null || index < 0 || index > m.spec.columns.length) ? m.spec.columns.length : index; + m.spec.columns.splice(at, 0, c); + return m; + } + + function wfEdMoveColumn(model, id, toIndex) { + const m = wfEdNormalize(model); + const from = m.spec.columns.findIndex(c => c.id === id); + if (from < 0) return m; + const col = m.spec.columns.splice(from, 1)[0]; + const to = Math.max(0, Math.min(toIndex, m.spec.columns.length)); + m.spec.columns.splice(to, 0, col); + return m; + } + + function wfEdRenameColumn(model, oldId, newId) { + const m = wfEdNormalize(model); + const id = wfEdSlug(newId); + if (!id || id === oldId) return m; + if (m.spec.columns.some(c => c.id === id) || wfEdStates(m).some(s => s.id === id)) return m; + m.spec.columns.forEach(c => { if (c.id === oldId) c.id = id; }); + wfEdStates(m).forEach(s => { if (s.lifecycle && s.lifecycle.column === oldId) s.lifecycle.column = id; }); + return m; + } + + // wfEdRemoveColumn drops a lane. Its states move to moveStatesTo when + // given, otherwise they become unassigned (drawn in the trailing lane). + function wfEdRemoveColumn(model, id, moveStatesTo) { + const m = wfEdNormalize(model); + const idx = m.spec.columns.findIndex(c => c.id === id); + if (idx < 0) return m; + const gone = m.spec.columns.splice(idx, 1)[0]; + const target = moveStatesTo && moveStatesTo !== id ? m.spec.columns.find(c => c.id === moveStatesTo) : null; + (gone.states || []).forEach(sid => { + const s = wfEdFindState(m, sid); + if (target) { target.states.push(sid); if (s) s.lifecycle = Object.assign({}, s.lifecycle, { column: target.id }); } + else if (s && s.lifecycle) { delete s.lifecycle.column; } + }); + return m; + } + + function wfEdAddState(model, state, columnId, index) { + const m = wfEdNormalize(model); + const s = Object.assign({ label: '', owner: 'human' }, state || {}); + s.id = wfEdUniqueId(m, s.id || s.label || 'state'); + if (!s.label) s.label = s.id; + if (!s.owner) s.owner = 'human'; + s.lifecycle = Object.assign({}, s.lifecycle || {}); + const col = columnId ? m.spec.columns.find(c => c.id === columnId) : null; + if (col) { + s.lifecycle.column = col.id; + const at = (index === undefined || index === null || index < 0 || index > col.states.length) ? col.states.length : index; + col.states.splice(at, 0, s.id); + } else { + delete s.lifecycle.column; + } + m.spec.states.push(s); + if (!m.spec.initial) m.spec.initial = s.id; + return m; + } + + // wfEdMoveState puts a state into a lane at a position (columnId '' or + // unknown → unassigned). Moving within the same lane re-orders it. + function wfEdMoveState(model, id, columnId, index) { + const m = wfEdNormalize(model); + const s = wfEdFindState(m, id); + if (!s) return m; + m.spec.columns.forEach(c => { c.states = c.states.filter(x => x !== id); }); + const col = columnId && columnId !== WF_ED_UNASSIGNED ? m.spec.columns.find(c => c.id === columnId) : null; + s.lifecycle = Object.assign({}, s.lifecycle || {}); + if (col) { + const at = (index === undefined || index === null || index < 0 || index > col.states.length) ? col.states.length : index; + col.states.splice(at, 0, id); + s.lifecycle.column = col.id; + } else { + delete s.lifecycle.column; + } + return m; + } + + // wfEdRenameState changes an id and every reference to it: column + // membership, transitions, initial, label entries, derivation rules, + // lanes and agent grants. + function wfEdRenameState(model, oldId, newId) { + const m = wfEdNormalize(model); + const id = wfEdSlug(newId); + if (!id || id === oldId || !wfEdFindState(m, oldId)) return m; + if (wfEdFindState(m, id) || m.spec.columns.some(c => c.id === id)) return m; + const spec = m.spec; + wfEdStates(m).forEach(s => { if (s.id === oldId) s.id = id; }); + spec.columns.forEach(c => { c.states = c.states.map(x => x === oldId ? id : x); }); + spec.transitions.forEach(t => { if (t.from === oldId) t.from = id; if (t.to === oldId) t.to = id; }); + if (spec.initial === oldId) spec.initial = id; + ((spec.labels && spec.labels.entries) || []).forEach(l => { if (l.state === oldId) l.state = id; }); + ((spec.derivation && spec.derivation.rules) || []).forEach(r => { if (r.state === oldId) r.state = id; }); + (spec.lanes || []).forEach(l => { if (l.stateRef === oldId) l.stateRef = id; }); + (spec.agents || []).forEach(a => { if (Array.isArray(a.states)) a.states = a.states.map(x => x === oldId ? id : x); }); + return m; + } + + function wfEdRemoveState(model, id) { + const m = wfEdNormalize(model); + const spec = m.spec; + (function prune(list) { + for (let i = list.length - 1; i >= 0; i--) { + if (list[i].id === id) list.splice(i, 1); + else if (Array.isArray(list[i].regions)) prune(list[i].regions); + } + })(spec.states); + spec.columns.forEach(c => { c.states = c.states.filter(x => x !== id); }); + spec.transitions = spec.transitions.filter(t => t.from !== id && t.to !== id); + if (spec.labels && Array.isArray(spec.labels.entries)) spec.labels.entries = spec.labels.entries.filter(l => l.state !== id); + if (spec.derivation && Array.isArray(spec.derivation.rules)) spec.derivation.rules = spec.derivation.rules.filter(r => r.state !== id); + if (spec.initial === id) { const rest = wfEdStates(m); spec.initial = rest.length ? rest[0].id : ''; } + return m; + } + + function wfEdAddTransition(model, from, to, on) { + const m = wfEdNormalize(model); + if (!wfEdFindState(m, from) || !wfEdFindState(m, to)) return m; + const ev = on == null ? '' : String(on); + if (m.spec.transitions.some(t => t.from === from && t.to === to && (t.on || '') === ev)) return m; + m.spec.transitions.push({ from: from, to: to, on: ev }); + return m; + } + + function wfEdRemoveTransition(model, index) { + const m = wfEdNormalize(model); + if (index >= 0 && index < m.spec.transitions.length) m.spec.transitions.splice(index, 1); + return m; + } + + // wfEdPath splits "spec.states[id=open].lifecycle.number" or + // "spec.transitions[2].on" into segments; [k=v] selects an array item by + // field, [n] by index. + function wfEdPath(path) { + const out = []; + String(path || '').split('.').forEach(part => { + const re = /([^[\]]+)|\[([^\]]+)\]/g; + let mt; + while ((mt = re.exec(part)) !== null) { + if (mt[1] !== undefined) out.push({ key: mt[1] }); + else if (mt[2].indexOf('=') > 0) { const kv = mt[2].split('='); out.push({ field: kv[0], value: kv.slice(1).join('=') }); } + else out.push({ index: parseInt(mt[2], 10) }); + } + }); + return out; + } + + // wfEdUpdate sets one field by path and returns a new model. Empty + // string / null / undefined deletes optional scalars so the saved JSON + // stays tidy. Fields that re-link structure (state column, ids) have + // their own functions; this one never touches links. + function wfEdUpdate(model, path, value) { + const m = wfEdClone(model); + const segs = wfEdPath(path); + if (!segs.length) return m; + let cur = m; + for (let i = 0; i < segs.length - 1; i++) { + const s = segs[i]; + let next; + if (s.key !== undefined) { if (cur[s.key] === undefined || cur[s.key] === null) cur[s.key] = (segs[i + 1].key !== undefined) ? {} : []; next = cur[s.key]; } + else if (s.index !== undefined) { next = cur[s.index]; } + else { next = Array.isArray(cur) ? cur.find(x => x && String(x[s.field]) === s.value) : undefined; } + if (next === undefined || next === null) return m; + cur = next; + } + const last = segs[segs.length - 1]; + const remove = value === undefined || value === null || value === '' || (Array.isArray(value) && !value.length); + if (last.key !== undefined) { if (remove) delete cur[last.key]; else cur[last.key] = value; } + else if (last.index !== undefined) { if (remove) cur.splice(last.index, 1); else cur[last.index] = value; } + return m; + } + + // wfEdDepths is the BFS distance of every state from the initial state + // along transitions; unreachable states get Infinity. + function wfEdDepths(model) { + const spec = wfEdSpec(model); + const depth = {}; + wfEdStates(model).forEach(s => { depth[s.id] = Infinity; }); + const start = spec.initial && depth[spec.initial] !== undefined ? spec.initial : (wfEdStates(model)[0] || {}).id; + if (!start) return depth; + depth[start] = 0; + const queue = [start]; + while (queue.length) { + const cur = queue.shift(); + (spec.transitions || []).forEach(t => { + if (t.from === cur && depth[t.to] !== undefined && depth[t.to] === Infinity) { depth[t.to] = depth[cur] + 1; queue.push(t.to); } + }); + } + return depth; + } + + // wfEdAutoLayout orders columns by the depth of their states from the + // initial state (terminal lanes last), places unassigned states by + // depth into the nearest lane, and sorts each lane by stage number then + // label. Pure: returns a new model. + function wfEdAutoLayout(model) { + const m = wfEdNormalize(model); + const depth = wfEdDepths(m); + const states = wfEdStates(m); + const byId = {}; states.forEach(s => { byId[s.id] = s; }); + const cols = m.spec.columns; + if (!cols.length) return m; + const colDepth = cols.map((c, i) => { + const ds = c.states.map(id => depth[id]).filter(d => isFinite(d)); + return { col: c, i: i, d: ds.length ? Math.min.apply(null, ds) : Infinity }; + }); + colDepth.sort((a, b) => { + if (!!a.col.terminal !== !!b.col.terminal) return a.col.terminal ? 1 : -1; + if (a.d !== b.d) return (isFinite(a.d) ? a.d : 1e9) - (isFinite(b.d) ? b.d : 1e9); + return a.i - b.i; + }); + m.spec.columns = colDepth.map(x => x.col); + // Unassigned states join the lane whose depth is closest. + const placed = {}; m.spec.columns.forEach(c => c.states.forEach(id => { placed[id] = true; })); + states.forEach(s => { + if (placed[s.id]) return; + const d = depth[s.id]; + let best = null, bestGap = Infinity; + colDepth.forEach(x => { const gap = Math.abs((isFinite(x.d) ? x.d : 1e9) - (isFinite(d) ? d : 1e9)); if (gap < bestGap) { bestGap = gap; best = x.col; } }); + if (!best) best = m.spec.columns[m.spec.columns.length - 1]; + best.states.push(s.id); + s.lifecycle = Object.assign({}, s.lifecycle, { column: best.id }); + }); + m.spec.columns.forEach(c => { + c.states.sort((a, b) => { + const na = (byId[a].lifecycle || {}).number || 0, nb = (byId[b].lifecycle || {}).number || 0; + if (na !== nb) return (na || 1e9) - (nb || 1e9); + const da = depth[a], db = depth[b]; + if (da !== db) return (isFinite(da) ? da : 1e9) - (isFinite(db) ? db : 1e9); + return String(byId[a].label || a).localeCompare(String(byId[b].label || b)); + }); + }); + return m; + } + + function wfEdSnap(v) { return Math.round(v / WF_ED.grid) * WF_ED.grid; } + + // wfEdLayout computes lane and chip geometry in model space (before + // pan/zoom). view.laneWidths may override a lane's width by id. States + // not in any lane are drawn in a trailing dashed "unassigned" lane. + function wfEdLayout(model, profile, view) { + const D = WF_ED; + const spec = wfEdSpec(model); + const widths = (view && view.laneWidths) || {}; + const states = wfEdStates(model); + const byId = {}; states.forEach(s => { byId[s.id] = s; }); + const lanes = (spec.columns || []).map(c => ({ id: c.id, label: c.label || c.id, hint: c.hint || '', terminal: !!c.terminal, states: (c.states || []).filter(id => byId[id]), virtual: false })); + const placed = {}; lanes.forEach(l => l.states.forEach(id => { placed[id] = true; })); + const loose = states.filter(s => !placed[s.id]).map(s => s.id); + if (loose.length || !lanes.length) lanes.push({ id: WF_ED_UNASSIGNED, label: 'Unassigned', hint: 'drag states into a lane', terminal: false, states: loose, virtual: true }); + let maxRows = D.minRows; + lanes.forEach(l => { maxRows = Math.max(maxRows, l.states.length + 1); }); + const laneH = D.headH + maxRows * (D.chipH + D.chipGap) + D.chipGap; + let x = D.pad; + const cols = [], chips = {}; + lanes.forEach((l, ci) => { + const w = Math.max(D.minColW, Math.min(D.maxColW, wfEdSnap(widths[l.id] || D.colW))); + cols.push({ id: l.id, label: l.label, hint: l.hint, terminal: l.terminal, virtual: l.virtual, index: ci, x: x, y: D.pad, w: w, h: laneH }); + l.states.forEach((id, ri) => { + chips[id] = { id: id, col: l.id, colIndex: ci, index: ri, x: x + D.grid, y: D.pad + D.headH + D.chipGap + ri * (D.chipH + D.chipGap), w: w - D.grid * 2, h: D.chipH }; + }); + x += w + D.colGap; + }); + const width = x - D.colGap + D.pad, height = D.pad * 2 + laneH; + const edges = []; + (spec.transitions || []).forEach((t, i) => { + const a = chips[t.from], b = chips[t.to]; + if (!a || !b) return; + const e = { index: i, from: t.from, to: t.to, on: t.on || '', note: t.note || '', self: t.from === t.to, back: false, d: '', lx: 0, ly: 0 }; + if (e.self) { + const px = a.x + a.w, py = a.y + a.h / 2, r = 12; + e.d = `M${px} ${py - 6} C${px + r * 2} ${py - r * 2}, ${px + r * 2} ${py + r * 2}, ${px} ${py + 6}`; + e.lx = px + r * 1.6; e.ly = py; + } else if (a.colIndex === b.colIndex) { + const px = a.x + a.w, y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, bow = D.colGap * 0.7; + e.back = b.index < a.index; + e.d = `M${px} ${y1} C${px + bow} ${y1}, ${px + bow} ${y2}, ${px} ${y2}`; + e.lx = px + bow * 0.75; e.ly = (y1 + y2) / 2; + } else { + const fwd = b.colIndex > a.colIndex; + e.back = !fwd; + const x1 = fwd ? a.x + a.w : a.x, x2 = fwd ? b.x : b.x + b.w; + const y1 = a.y + a.h / 2, y2 = b.y + b.h / 2, dx = (x2 - x1) / 2; + e.d = `M${x1} ${y1} C${x1 + dx} ${y1}, ${x2 - dx} ${y2}, ${x2} ${y2}`; + e.lx = (x1 + x2) / 2; e.ly = (y1 + y2) / 2; + } + edges.push(e); + }); + return { width: width, height: height, cols: cols, chips: chips, edges: edges, laneH: laneH }; + } + + // wfEdLaneAt / wfEdSlotAt map a model-space point onto a lane and a chip + // slot index, for drops. + function wfEdLaneAt(layout, x) { + for (const c of layout.cols) { if (x >= c.x - WF_ED.colGap / 2 && x < c.x + c.w + WF_ED.colGap / 2) return c; } + return null; + } + function wfEdSlotAt(layout, lane, y) { + const top = lane.y + WF_ED.headH + WF_ED.chipGap; + const n = Object.keys(layout.chips).filter(id => layout.chips[id].col === lane.id).length; + return Math.max(0, Math.min(n, Math.round((y - top) / (WF_ED.chipH + WF_ED.chipGap)))); + } + + // wfEdLocalProblems mirrors the compiler's refusals so the canvas can + // flag them before the server answers. Codes match workflowmodel. + function wfEdLocalProblems(model) { + const spec = wfEdSpec(model); + const out = []; + const push = (code, severity, subject, detail) => out.push({ code: code, severity: severity, subject: subject, detail: detail }); + const states = wfEdStates(model); + const byId = {}; states.forEach(s => { byId[s.id] = s; }); + const cols = spec.columns || []; + const colById = {}; cols.forEach(c => { colById[c.id] = c; }); + if (!(model && model.metadata && model.metadata.name)) push('invalid', 'error', '', 'metadata.name is required'); + if (!states.length) push('invalid', 'error', '', 'the model has no states'); + if (!spec.initial) push('invalid', 'error', '', 'spec.initial is empty'); + else if (!byId[spec.initial]) push('unknown-state', 'error', spec.initial, 'spec.initial names a state that does not exist'); + const seenIds = {}; + states.forEach(s => { if (seenIds[s.id]) push('invalid', 'error', s.id, 'duplicate state id'); seenIds[s.id] = true; }); + const terminalStates = {}; + cols.forEach(c => { if (c.terminal) (c.states || []).forEach(id => { terminalStates[id] = true; }); }); + const hasExit = {}; + (spec.transitions || []).forEach((t, i) => { + const subj = t.from + '->' + t.to; + if (!byId[t.from]) push('unknown-state', 'error', subj, 'transition from unknown state ' + t.from); + if (!byId[t.to]) push('unknown-state', 'error', subj, 'transition to unknown state ' + t.to); + if (t.from !== t.to) hasExit[t.from] = true; + if (Array.isArray(spec.events) && spec.events.length && t.on && !spec.events.some(e => e.id === t.on)) push('unknown-event', 'error', subj, 'event ' + t.on + ' is not declared in spec.events'); + }); + const memberOf = {}; + cols.forEach(c => { + if (!(c.states || []).length) push('column-empty', 'warning', c.id, 'column has no states'); + (c.states || []).forEach(id => { + if (!byId[id]) push('unknown-state', 'error', c.id, 'column lists unknown state ' + id); + if (memberOf[id]) push('state-no-column', 'error', id, 'state is listed in both ' + memberOf[id] + ' and ' + c.id); + memberOf[id] = c.id; + }); + }); + if (cols.length && !cols.some(c => c.terminal)) push('no-terminal-column', 'error', '', 'no column is marked terminal'); + const numbers = {}; + states.forEach(s => { + const lc = s.lifecycle || {}; + if (!s.owner) push('invalid', 'error', s.id, 'state has no owner'); + if (s.type === 'parallel' && !(Array.isArray(s.regions) && s.regions.length)) push('invalid', 'error', s.id, 'parallel state has no regions'); + if (cols.length && !memberOf[s.id]) push('state-no-column', 'error', s.id, 'state is not in any column'); + if (lc.column && !colById[lc.column]) push('unknown-column', 'error', s.id, 'lifecycle.column names unknown column ' + lc.column); + if (lc.column && memberOf[s.id] && lc.column !== memberOf[s.id]) push('state-no-column', 'error', s.id, 'lifecycle.column is ' + lc.column + ' but the state sits in ' + memberOf[s.id]); + if (!terminalStates[s.id] && s.type !== 'final' && s.type !== 'parallel' && !hasExit[s.id]) push('state-no-exit', 'error', s.id, 'non-terminal state has no outgoing transition'); + if (lc.number) { + const kinds = Array.isArray(lc.kinds) && lc.kinds.length ? lc.kinds : WF_ED_KINDS; + kinds.forEach(k => { + const key = k + ':' + lc.number; + if (numbers[key] && numbers[key] !== s.id) push('duplicate-stage-number', 'error', s.id, 'stage ' + lc.number + ' for ' + k + ' is also used by ' + numbers[key]); + numbers[key] = s.id; + }); + } + if (lc.confidence === 'inferred') push('inferred-element', 'warning', s.id, 'state was inferred and not confirmed'); + }); + return out; + } + + // wfEdDiff is the client-side twin of workflowmodel.Diff for the unsaved + // working copy: same keyed collections and the same flat field changes. + function wfEdDiff(a, b) { + const flat = v => v === undefined || v === null ? '' : (Array.isArray(v) ? v.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(',') : (typeof v === 'object' ? JSON.stringify(v) : String(v))); + const diffItems = (ia, ib) => { + const out = { added: [], removed: [], changed: [] }; + Object.keys(ib).forEach(k => { if (!(k in ia)) out.added.push(k); }); + Object.keys(ia).forEach(k => { if (!(k in ib)) out.removed.push(k); }); + Object.keys(ia).forEach(k => { + if (!(k in ib)) return; + const fields = []; + const keys = {}; Object.keys(ia[k]).concat(Object.keys(ib[k])).forEach(f => { keys[f] = true; }); + Object.keys(keys).sort().forEach(f => { const fa = flat(ia[k][f]), fb = flat(ib[k][f]); if (fa !== fb) fields.push({ field: f, from: fa, to: fb }); }); + if (fields.length) out.changed.push({ key: k, fields: fields }); + }); + out.added.sort(); out.removed.sort(); out.changed.sort((x, y) => x.key.localeCompare(y.key)); + return out; + }; + const strip = (o, drop) => { const c = Object.assign({}, o); drop.forEach(k => { delete c[k]; }); return c; }; + const colItems = m => { const o = {}; (wfEdSpec(m).columns || []).forEach(c => { o[c.id] = strip(c, ['id']); }); return o; }; + const stateItems = m => { const o = {}; wfEdStates(m).forEach(s => { const c = strip(s, ['id', 'regions', 'lifecycle']); Object.keys(s.lifecycle || {}).forEach(k => { c['lifecycle.' + k] = s.lifecycle[k]; }); o[s.id] = c; }); return o; }; + const transItems = m => { + const o = {}, count = {}; + (wfEdSpec(m).transitions || []).forEach(t => { const k = t.from + '>' + t.to; count[k] = (count[k] || 0) + 1; }); + (wfEdSpec(m).transitions || []).forEach(t => { const k = t.from + '>' + t.to; o[count[k] > 1 ? k + '@' + (t.on || '') : k] = strip(t, ['from', 'to']); }); + return o; + }; + const labelItems = m => { const o = {}; ((wfEdSpec(m).labels || {}).entries || []).forEach(l => { o[l.name] = strip(l, ['name']); }); return o; }; + const ruleItems = m => { const o = {}; ((wfEdSpec(m).derivation || {}).rules || []).forEach((r, i) => { o[r.id || 'rules[' + i + ']'] = strip(r, ['id']); }); return o; }; + const d = { + columns: diffItems(colItems(a), colItems(b)), + states: diffItems(stateItems(a), stateItems(b)), + transitions: diffItems(transItems(a), transItems(b)), + labels: diffItems(labelItems(a), labelItems(b)), + rules: diffItems(ruleItems(a), ruleItems(b)), + meta: [], + }; + const ma = wfEdSpec(a).meta || {}, mb = wfEdSpec(b).meta || {}; + const mkeys = {}; Object.keys(ma).concat(Object.keys(mb)).forEach(k => { mkeys[k] = true; }); + Object.keys(mkeys).sort().forEach(k => { if (flat(ma[k]) !== flat(mb[k])) d.meta.push({ field: k, from: flat(ma[k]), to: flat(mb[k]) }); }); + const parts = []; + let changed = d.meta.length; + ['columns', 'states', 'transitions', 'labels', 'rules'].forEach(k => { + const cs = d[k]; + if (cs.added.length) parts.push('+' + cs.added.length + ' ' + k); + if (cs.removed.length) parts.push('\u2212' + cs.removed.length + ' ' + k); + changed += cs.changed.length; + }); + if (changed) parts.push(changed + ' changed'); + d.empty = !parts.length; + d.summary = d.empty ? 'no changes' : parts.join(', '); + return d; + } + + // wfEdSvg renders the canvas. `state` carries selection, diagnostics by + // subject, a simulate hit, profile and the view transform. Pure. + function wfEdSvg(model, layout, state) { + const st = state || {}; + const D = WF_ED; + const spec = wfEdSpec(model); + const view = st.view || { panX: 0, panY: 0, zoom: 1 }; + const sel = st.selection || { type: '', ids: [] }; + const isSel = (type, id) => sel.type === type && sel.ids.indexOf(id) >= 0; + const diags = {}; + (st.diags || []).forEach(d => { const k = d.subject || ''; if (!diags[k] || d.severity === 'error') diags[k] = Object.assign({}, diags[k] || {}, { severity: diags[k] && diags[k].severity === 'error' ? 'error' : d.severity, text: ((diags[k] && diags[k].text) ? diags[k].text + '\n' : '') + d.code + ': ' + d.detail }); }); + const states = {}; wfEdStates(model).forEach(s => { states[s.id] = s; }); + const profile = st.profile || null; + const out = []; + const vw = st.viewportW || layout.width, vh = st.viewportH || layout.height; + out.push(``); + out.push('' + + '' + + ``); + out.push(``); + out.push(``); + layout.cols.forEach(c => { + const d = diags[c.id]; + const cls = 'wf-ed-col' + (c.terminal ? ' terminal' : '') + (c.virtual ? ' virtual' : '') + (isSel('column', c.id) ? ' selected' : '') + (st.dropLane === c.id ? ' drop' : ''); + out.push(`` + + `` + + `` + + `${esc(wfTrim(c.label, Math.floor(c.w / 9)))}${c.terminal ? ' \u25A0' : ''}` + + (d ? `!${esc(d.text)}` : '') + + (c.virtual ? '' : ``) + + ''); + }); + layout.edges.forEach(e => { + const subj = e.from + '->' + e.to, d = diags[subj]; + const selected = isSel('transition', String(e.index)); + const cls = 'wf-ed-edge' + (e.back ? ' back' : '') + (e.self ? ' self' : '') + (selected ? ' selected' : '') + (st.simHit && (st.simHit === e.to) ? ' sim-path' : '') + (d ? ' ' + d.severity : ''); + out.push(`` + + `` + + `` + + (e.on || d ? `${esc(wfTrim(e.on || '', 18))}${d ? ' !' : ''}` : '') + + `${esc(e.from)} \u2192 ${esc(e.to)}${e.on ? ' on ' + esc(e.on) : ' (no event)'}${e.note ? '\n' + esc(e.note) : ''}${d ? '\n' + esc(d.text) : ''}`); + }); + Object.keys(layout.chips).forEach(id => { + const p = layout.chips[id], s = states[id] || { id: id, label: id }; + const lc = s.lifecycle || {}; + const inferred = lc.confidence === 'inferred'; + const num = lc.number ? String(lc.number).padStart(3, '0') : ''; + const d = diags[id]; + const terminal = (profile && profile.terminal && profile.terminal[id]) || (layout.cols.find(c => c.id === p.col) || {}).terminal; + const cls = 'wf-ed-state' + (inferred ? ' inferred' : '') + (spec.initial === id ? ' initial' : '') + (terminal ? ' terminal' : '') + (isSel('state', id) ? ' selected' : '') + + (st.simHit ? (st.simHit === id ? ' wf-ed-sim-hit' : ' wf-ed-sim-dim') : '') + (d ? ' ' + d.severity : '') + (st.dragging && st.dragging.indexOf(id) >= 0 ? ' dragging' : ''); + const kinds = Array.isArray(lc.kinds) && lc.kinds.length ? lc.kinds.join('+') : ''; + const tip = (s.label || id) + (num ? ' \u00b7 stage/' + num : '') + (kinds ? ' \u00b7 ' + kinds : '') + (inferred ? ' \u00b7 inferred' : '') + (d ? '\n' + d.text : ''); + out.push(`` + + `${esc(tip)}` + + `` + + (spec.initial === id ? `` : '') + + (num ? `${num}` : '') + + `${esc(wfTrim(s.label || id, Math.floor((p.w - (num ? 56 : 24)) / 7.2)))}` + + (d ? `!` : '') + + (inferred ? `\u2713Mark as confirmed` : '') + + `Drag to another state to add a transition` + + ''); + }); + if (st.pendingEdge) out.push(``); + out.push(''); + if (st.marquee) out.push(``); + out.push(''); + return out.join(''); + } + + // wfEdMinimapSvg draws lanes and chips as small rectangles with the + // viewport outlined. Pure. + function wfEdMinimapSvg(layout, view, viewportW, viewportH) { + const W = 160, H = Math.max(40, Math.round(W * layout.height / Math.max(1, layout.width))); + const k = Math.min(W / Math.max(1, layout.width), H / Math.max(1, layout.height)); + const out = [``]; + layout.cols.forEach(c => out.push(``)); + Object.keys(layout.chips).forEach(id => { const p = layout.chips[id]; out.push(``); }); + const vx = -view.panX / view.zoom * k, vy = -view.panY / view.zoom * k, vw = viewportW / view.zoom * k, vh = viewportH / view.zoom * k; + out.push(``); + out.push(''); + return out.join(''); + } + + // wfEdFitView returns a view that fits the layout into a viewport. + function wfEdFitView(layout, viewportW, viewportH) { + const zoom = Math.max(0.2, Math.min(2, Math.min(viewportW / Math.max(1, layout.width), viewportH / Math.max(1, layout.height)))); + return { zoom: zoom, panX: Math.max(0, (viewportW - layout.width * zoom) / 2), panY: Math.max(0, (viewportH - layout.height * zoom) / 2) }; + } + + // wfEdCompileModel is the model as sent to compile/simulate: a draft + // version stands in for the empty one the editor saves with. + function wfEdCompileModel(model) { + const m = wfEdClone(model); + m.spec = m.spec || {}; + m.spec.meta = Object.assign({}, m.spec.meta); + if (!m.spec.meta.version) m.spec.meta.version = 'draft'; + return m; + } + + // wfEdSelectedProblem picks the compile status pill text from diagnostics. + function wfEdStatus(diags, busy, compiledFlag) { + const list = Array.isArray(diags) ? diags : []; + const errors = list.filter(d => d.severity === 'error').length; + const warnings = list.filter(d => d.severity !== 'error').length; + if (errors || compiledFlag === false) return { cls: 'error', text: 'does not compile' + (errors ? ' \u00b7 ' + errors + ' error' + (errors === 1 ? '' : 's') : ''), errors: errors, warnings: warnings, busy: !!busy }; + if (warnings) return { cls: 'warn', text: warnings + ' warning' + (warnings === 1 ? '' : 's'), errors: 0, warnings: warnings, busy: !!busy }; + return { cls: 'ok', text: 'compiles', errors: 0, warnings: 0, busy: !!busy }; + } + + // --- Runtime state -------------------------------------------------------- + + function wfEdDefaultState() { + return { + model: null, saved: null, source: null, readOnly: false, rev: 0, + history: [], future: [], selection: { type: '', ids: [] }, + view: { panX: 0, panY: 0, zoom: 1, laneWidths: {} }, + compile: { busy: false, rev: -1, profile: null, diags: [], compiled: null, error: '' }, + drawer: { tab: 'problems', open: true }, + yaml: { text: '', rev: -1, error: '', busy: false, focused: false }, + diff: { versions: [], against: '', remote: null, error: '', busy: false, loadedFor: '' }, + sim: { kind: 'pr', facts: wfSimDefaultFacts(), result: null, error: '', busy: false, hit: '' }, + palette: { hiveDefault: null, loading: false, section: 'states' }, + exportMenu: false, spaceHeld: false, viewport: { w: 900, h: 520 }, timers: {}, drag: null, marquee: null, pendingEdge: null, dropLane: '', + }; + } + const _wfEd = wfEdDefaultState(); + + function wfEdOwner() { return dashboardRoleAtLeast(window._hiveRole || 'read', 'owner'); } + function wfEdDirty() { return !!_wfEd.model && JSON.stringify(_wfEd.model) !== JSON.stringify(_wfEd.saved); } + function wfEdCanEdit() { return !!_wfEd.model && !_wfEd.readOnly && wfEdOwner(); } + + // wfEdDiags is what the canvas and Problems drawer show: the server's + // diagnostics when they are for the current revision, local ones until. + function wfEdDiags() { + if (!_wfEd.model) return []; + if (_wfEd.compile.rev === _wfEd.rev && !_wfEd.compile.error) return _wfEd.compile.diags || []; + return wfEdLocalProblems(_wfEd.model); + } + + function wfEdLoad(model, source, readOnly) { + const m = wfEdNormalize(model); + Object.assign(_wfEd, wfEdDefaultState(), { model: m, saved: wfEdClone(m), source: source || null, readOnly: !!readOnly, palette: _wfEd.palette }); + _wfEd.rev = 1; + _wfLib.view = 'editor'; + wfEdScheduleCompile(0); + wfLibRerender(); + } + + function wfEdCommit(next, opts) { + if (!_wfEd.model) return; + if (!wfEdCanEdit()) { showToast(_wfEd.readOnly ? 'This model is read-only. Use "Edit a copy".' : 'Owner role is required to edit.', 'error'); return; } + const o = opts || {}; + _wfEd.history.push(wfEdClone(_wfEd.model)); + if (_wfEd.history.length > WF_ED_HISTORY_CAP) _wfEd.history.shift(); + _wfEd.future = []; + _wfEd.model = wfEdNormalize(next); + _wfEd.rev++; + _wfEd.sim.hit = ''; + wfEdScheduleCompile(); + wfEdRefresh(o.parts || ['toolbar', 'canvas', 'inspector', 'drawer']); + } + + function wfEdUndo() { + if (!_wfEd.history.length) return; + _wfEd.future.push(wfEdClone(_wfEd.model)); + _wfEd.model = _wfEd.history.pop(); + _wfEd.rev++; wfEdPruneSelection(); wfEdScheduleCompile(); wfEdRefresh(); + } + function wfEdRedo() { + if (!_wfEd.future.length) return; + _wfEd.history.push(wfEdClone(_wfEd.model)); + _wfEd.model = _wfEd.future.pop(); + _wfEd.rev++; wfEdPruneSelection(); wfEdScheduleCompile(); wfEdRefresh(); + } + + function wfEdPruneSelection() { + const sel = _wfEd.selection; + if (sel.type === 'state') sel.ids = sel.ids.filter(id => wfEdFindState(_wfEd.model, id)); + else if (sel.type === 'column') sel.ids = sel.ids.filter(id => wfEdFindColumn(_wfEd.model, id)); + else if (sel.type === 'transition') sel.ids = sel.ids.filter(i => _wfEd.model.spec.transitions[parseInt(i, 10)]); + if (!sel.ids.length) { sel.type = ''; } + } + + function wfEdSelect(type, id, additive) { + const sel = _wfEd.selection; + if (!type) { sel.type = ''; sel.ids = []; } + else if (additive && sel.type === type) { const i = sel.ids.indexOf(id); if (i >= 0) sel.ids.splice(i, 1); else sel.ids.push(id); if (!sel.ids.length) sel.type = ''; } + else { sel.type = type; sel.ids = [id]; } + wfEdRefresh(['canvas', 'inspector']); + } + + // --- Live compile --------------------------------------------------------- + + function wfEdScheduleCompile(delay) { + clearTimeout(_wfEd.timers.compile); + _wfEd.timers.compile = setTimeout(wfEdCompileNow, delay === undefined ? WF_ED_COMPILE_DEBOUNCE_MS : delay); + } + + async function wfEdCompileNow() { + if (!_wfEd.model) return; + const rev = _wfEd.rev, c = _wfEd.compile; + c.busy = true; wfEdRefresh(['toolbar']); + try { + const res = await fetch('/api/workflows/compile', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(wfEdCompileModel(_wfEd.model)) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + if (rev !== _wfEd.rev) return; + c.profile = data.profile || null; c.diags = Array.isArray(data.diagnostics) ? data.diagnostics : []; c.compiled = !!data.compiled; c.error = ''; c.rev = rev; + } catch (err) { + if (rev !== _wfEd.rev) return; + c.error = err.message; c.rev = rev; + } finally { + if (rev === _wfEd.rev) { c.busy = false; wfEdRefresh(['toolbar', 'canvas', 'drawer']); } + } + } + + // --- Render --------------------------------------------------------------- + + function renderWorkflowEditor(owner) { + if (!_wfEd.model) return renderWorkflowEditorStart(owner); + return '
' + + '
' + renderWfEdToolbar(_wfEd) + '
' + + '
' + + '' + + '
' + renderWfEdCanvas(_wfEd) + '
' + + '' + + '
' + + '
' + renderWfEdDrawer(_wfEd) + '
' + + '
'; + } + + function renderWorkflowEditorStart(owner) { + const list = Array.isArray(_workflowState.workflows) ? _workflowState.workflows.slice().sort((a, b) => (a.name + '@' + a.version).localeCompare(b.name + '@' + b.version)) : []; + const dis = owner ? '' : 'disabled'; + const ingest = _wfLib.ingest && _wfLib.ingest.result && _wfLib.ingest.result.model; + return '
' + + '

Workflow editor

' + + '

Design a lifecycle on a canvas: columns are lanes, states are chips, transitions are drawn by dragging from a chip\u2019s port. The model compiles live as you edit; the YAML, Diff and Simulate drawers work on the unsaved copy.

' + + (owner ? '' : '

Owner role is required to edit or save; you can still open models to look at them.

') + + '
' + + `` + + `` + + `` + + (ingest ? `` : '') + + '' + + '
' + + '

Open from Library

' + + (list.length ? '
    ' + list.map(e => { + const builtin = wfProvenanceOf(e) === 'built-in'; + return `
  • ${esc(e.name + '@' + e.version)} ${wfProvenanceBadge(e)} ${wfCompiledBadge(e)}` + + (builtin ? ` opens read-only` : '') + '
  • '; + }).join('') + '
' : '

No stored models yet.

') + + '
'; + } + + function renderWfEdToolbar(st) { + const can = wfEdCanEdit(); + const dis = can ? '' : 'disabled'; + const status = st.compile.error ? { cls: 'error', text: 'compile failed: ' + st.compile.error } : wfEdStatus(wfEdDiags(), st.compile.busy, st.compile.rev === st.rev ? st.compile.compiled : null); + const name = (st.model.metadata || {}).name || 'untitled'; + const ver = st.source ? '@' + st.source.version : (st.model.spec.meta && st.model.spec.meta.version ? '@' + st.model.spec.meta.version + ' (unsaved)' : ' (unsaved)'); + const dirty = wfEdDirty(); + const inferred = wfEdStates(st.model).filter(s => (s.lifecycle || {}).confidence === 'inferred').length; + const btn = (action, label, title, extra) => ``; + return ''; + } + + function renderWfEdPalette(st) { + const can = wfEdCanEdit(); + const item = (kind, payload, label, sub) => `
${esc(label)}${sub ? `${esc(sub)}` : ''}
`; + const hd = st.palette.hiveDefault; + const hdStates = hd ? wfEdStates(hd).filter(s => (s.lifecycle || {}).number).sort((a, b) => a.lifecycle.number - b.lifecycle.number) : []; + const sections = [ + ['states', 'States', WF_ED_PALETTE_STATES.map(s => item('state', s, s.label, String(s.number).padStart(3, '0') + (s.kinds ? ' ' + s.kinds.join('+') : ''))).join('')], + ['columns', 'Columns', WF_ED_PALETTE_COLUMNS.map(c => item('column', c, c.label, c.terminal ? 'terminal' : '')).join('')], + ['hive', 'hive-default stages', hd ? (hdStates.map(s => item('state', { id: s.id, label: s.label, number: s.lifecycle.number, kinds: s.lifecycle.kinds || [], owner: s.owner, replies: s.lifecycle.replies || [] }, s.label, String(s.lifecycle.number).padStart(3, '0'))).join('') || '

No numbered stages.

') : (st.palette.loading ? '

Loading\u2026

' : '')], + ]; + return '

Templates

' + sections.map(([id, label, body]) => + `
${label}
${body}
`).join(''); + } + + function wfEdLayoutNow() { return wfEdLayout(_wfEd.model, _wfEd.compile.rev === _wfEd.rev ? _wfEd.compile.profile : null, _wfEd.view); } + + function renderWfEdCanvas(st) { + const layout = wfEdLayoutNow(); + const svg = wfEdSvg(st.model, layout, { + selection: st.selection, diags: wfEdDiags(), profile: st.compile.rev === st.rev ? st.compile.profile : null, view: st.view, + viewportW: st.viewport.w, viewportH: st.viewport.h, simHit: st.sim.hit, marquee: st.marquee, pendingEdge: st.pendingEdge, dropLane: st.dropLane, + dragging: st.drag && st.drag.type === 'chip' && st.drag.moved ? st.drag.ids : null, + }); + const big = layout.width * st.view.zoom > st.viewport.w || layout.height * st.view.zoom > st.viewport.h; + return svg + (big ? '
' + wfEdMinimapSvg(layout, st.view, st.viewport.w, st.viewport.h) + '
' : '') + + '
drag chips between lanes \u00b7 drag a port to connect \u00b7 wheel pans, Ctrl+wheel zooms \u00b7 dbl-click renames \u00b7 Del removes
'; + } + + function wfEdField(label, input, hint) { return ``; } + function wfEdText(path, value, extra) { return ``; } + function wfEdSelectHtml(action, arg0, value, options, extra) { + return `'; + } + + function renderWfEdInspector(st) { + const m = st.model, spec = m.spec, sel = st.selection; + const can = wfEdCanEdit(); + const dis = can ? '' : 'disabled'; + const events = (spec.events || []).map(e => e.id); + let body; + if (sel.type === 'state' && sel.ids.length > 1) { + body = `

${sel.ids.length} states

${sel.ids.map(id => `${esc(id)}`).join(', ')}

` + + `
`; + } else if (sel.type === 'state') { + const s = wfEdFindState(m, sel.ids[0]); + if (!s) return '

Nothing selected.

'; + const lc = s.lifecycle || {}, base = 'spec.states[id=' + s.id + ']'; + const cols = [[WF_ED_UNASSIGNED, '\u2014 unassigned']].concat(spec.columns.map(c => [c.id, c.label || c.id])); + const dupNumber = lc.number && wfEdStates(m).some(o => o.id !== s.id && (o.lifecycle || {}).number === lc.number); + body = `

State ${esc(s.id)}

` + + wfEdField('Id', `
`, 'Renaming updates transitions, initial, labels and rules.') + + wfEdField('Label', wfEdText(base + '.label', s.label, dis)) + + wfEdField('Column', wfEdSelectHtml('wfEdSetStateColumn', s.id, lc.column || WF_ED_UNASSIGNED, cols, dis)) + + wfEdField('Stage number', ``, dupNumber ? 'another state uses this number' : 'unique per kind; blank = no stage label') + + wfEdField('Owner', wfEdSelectHtml('wfEdFieldSet', base + '.owner', s.owner || 'human', WF_ED_OWNERS, dis)) + + wfEdField('Type', wfEdSelectHtml('wfEdFieldSet', base + '.type', s.type || '', [['', 'sequential'], ['parallel', 'parallel'], ['final', 'final']], dis)) + + `
Kinds
${WF_ED_KINDS.map(k => ``).join('')}none = both
` + + wfEdField('Confidence', wfEdSelectHtml('wfEdFieldSet', base + '.lifecycle.confidence', lc.confidence || '', [['', 'confirmed'], ['inferred', 'inferred']], dis)) + + wfEdField('Replies / verbs', wfEdText(base + '.lifecycle.replies', (lc.replies || []).join(', '), dis + ' data-change-action="wfEdSetList" placeholder="comma separated"'), 'reply verbs that move an item here') + + wfEdField('Waiting on', wfEdText(base + '.lifecycle.waiting', (lc.waiting || []).join(', '), dis + ' data-change-action="wfEdSetList" placeholder="comma separated"')) + + `
Initial
` + + (Array.isArray(lc.citations) && lc.citations.length ? '
Citations
    ' + lc.citations.map(c => `
  • ${esc(c.path || '')}${c.line ? ':' + esc(String(c.line)) : ''}${c.text ? ` ${esc(c.text)}` : ''}
  • `).join('') + '
' : '') + + '
Transitions
    ' + (spec.transitions.map((t, i) => ({ t: t, i: i })).filter(x => x.t.from === s.id || x.t.to === s.id).map(x => + `
  • ${esc(x.t.from)} \u2192 ${esc(x.t.to)} ${esc(x.t.on || '(no event)')}
  • `).join('') || '
  • none
  • ') + '
' + + `
`; + } else if (sel.type === 'transition') { + const i = parseInt(sel.ids[0], 10), t = spec.transitions[i]; + if (!t) return '

Nothing selected.

'; + const base = 'spec.transitions[' + i + ']'; + body = `

Transition ${esc(t.from)} \u2192 ${esc(t.to)}

` + + wfEdField('On (event)', `${events.map(e => ``, events.length ? 'declared events: ' + esc(events.join(', ')) : 'free text; declare events in spec.events to validate') + + wfEdField('Guard ref', wfEdText(base + '.guardRef', t.guardRef, dis + ' list="wf-ed-guards"') + `${(spec.guards || []).map(g => ``) + + wfEdField('Gate ref', wfEdText(base + '.gateRef', t.gateRef, dis)) + + wfEdField('Note', wfEdText(base + '.note', t.note, dis)) + + `
`; + } else if (sel.type === 'column') { + const c = wfEdFindColumn(m, sel.ids[0]); + if (!c) return '

Nothing selected.

'; + const base = 'spec.columns[id=' + c.id + ']'; + const others = spec.columns.filter(o => o.id !== c.id); + body = `

Column ${esc(c.id)}

` + + wfEdField('Id', `
`) + + wfEdField('Label', wfEdText(base + '.label', c.label, dis)) + + wfEdField('Hint', wfEdText(base + '.hint', c.hint, dis), 'shown as the lane\u2019s tooltip in the Activity Monitor') + + `
states here need no exit
` + + wfEdField('Width', ``) + + `

${(c.states || []).length} state${(c.states || []).length === 1 ? '' : 's'}

` + + '
' + + `` + + `` + + `` + + '
' + (others.length && (c.states || []).length ? '

Deleting asks where to move its states.

' : ''); + } else { + const meta = spec.meta || {}, lab = spec.labels || {}; + const states = wfEdStates(m); + body = '

Model

' + + wfEdField('Name', wfEdText('metadata.name', (m.metadata || {}).name, dis + ' pattern="[a-z0-9][a-z0-9._-]*"'), 'lower-case, digits, dots, dashes') + + wfEdField('Description', ``) + + wfEdField('Source', wfEdSelectHtml('wfEdFieldSet', 'spec.meta.source', meta.source || 'hand', ['hand', 'spec', 'github-actions', 'tekton', 'issue-templates', 'built-in'], dis)) + + wfEdField('Provenance', ``, 'set by the store on save') + + wfEdField('Version', ``, 'Save assigns the next free integer') + + wfEdField('Derivation', wfEdSelectHtml('wfEdFieldSet', 'spec.derivation.mode', (spec.derivation || {}).mode || 'builtin', WF_ED_DERIVATIONS, dis), 'builtin = hive\u2019s stage engine by number; rules = spec.derivation.rules') + + wfEdField('Initial state', wfEdSelectHtml('wfEdFieldSet', 'spec.initial', spec.initial || '', states.map(s => [s.id, s.label || s.id]), dis)) + + wfEdField('Label prefix', wfEdText('spec.labels.prefix', lab.prefix, dis + ' placeholder="stage/"')) + + wfEdField('Scope repository', wfEdText('spec.scope.repository', (spec.scope || {}).repository, dis + ' placeholder="*"')) + + `

${spec.columns.length} column${spec.columns.length === 1 ? '' : 's'} \u00b7 ${states.length} state${states.length === 1 ? '' : 's'} \u00b7 ${spec.transitions.length} transition${spec.transitions.length === 1 ? '' : 's'}

`; + } + return body; + } + + function wfEdProblemsHtml(diags) { + const list = Array.isArray(diags) ? diags : []; + if (!list.length) return '

No problems: the model compiles clean.

'; + return ''; + } + + function wfEdChangeSetHtml(label, cs) { + if (!cs || !(cs.added || []).length && !(cs.removed || []).length && !(cs.changed || []).length) return ''; + return `
${esc(label)}
` + + (cs.added || []).map(k => `
+ ${esc(k)}
`).join('') + + (cs.removed || []).map(k => `
\u2212 ${esc(k)}
`).join('') + + (cs.changed || []).map(c => `
~ ${esc(c.key)}
    ${(c.fields || []).map(f => `
  • ${esc(f.field)}: ${esc(f.from || '\u2205')} \u2192 ${esc(f.to || '\u2205')}
  • `).join('')}
`).join('') + '
'; + } + + function wfEdDiffHtml(d) { + if (!d) return ''; + return `

${esc(d.summary || '')}

` + + wfEdChangeSetHtml('Columns', d.columns) + wfEdChangeSetHtml('States', d.states) + wfEdChangeSetHtml('Transitions', d.transitions) + wfEdChangeSetHtml('Labels', d.labels) + wfEdChangeSetHtml('Rules', d.rules) + + ((d.meta || []).length ? '
Meta
' + d.meta.map(f => `
~ ${esc(f.field)}: ${esc(f.from || '\u2205')} \u2192 ${esc(f.to || '\u2205')}
`).join('') + '
' : ''); + } + + function wfEdSimFieldHtml(field, facts, disabled) { + const val = wfSimFormFromFacts(facts, field); + const common = `data-wf-sim-field="${esc(field.path)}" ${disabled}`; + if (field.type === 'bool') return ``; + if (field.type === 'select') return ``; + return ``; + } + + function renderWfEdDrawer(st) { + const tabs = [['problems', 'Problems' + (wfEdDiags().length ? ' (' + wfEdDiags().length + ')' : '')], ['yaml', 'YAML'], ['diff', 'Diff'], ['simulate', 'Simulate']]; + let body = ''; + const tab = st.drawer.tab; + if (!st.drawer.open) body = ''; + else if (tab === 'problems') { + body = (st.compile.error ? `

Compile request failed: ${esc(st.compile.error)}

` : '') + + (st.compile.rev !== st.rev ? '

Showing local checks until the server answers\u2026

' : '') + wfEdProblemsHtml(wfEdDiags()); + } else if (tab === 'yaml') { + body = `
${st.yaml.busy ? 'Working\u2026' : (st.yaml.rev === st.rev ? 'In sync with the canvas.' : 'Edit, then Apply (or leave the field) to push changes to the canvas.')}` + + `` + + `
` + + (st.yaml.error ? `

${esc(st.yaml.error)}

` : '') + + ``; + } else if (tab === 'diff') { + const local = wfEdDiff(st.saved || {}, st.model); + const versions = st.diff.versions || []; + const opts = [['', '\u2014 saved version \u2014']].concat(versions.map(v => [v.version, v.version + (v.savedAt && v.savedAt !== '0001-01-01T00:00:00Z' ? ' \u00b7 ' + formatDashboardDateTime(v.savedAt) : '')])); + body = '
Working copy vs last saved
' + (local.empty ? '

No unsaved changes.

' : wfEdDiffHtml(local)) + + (st.source ? '
Saved versions
' + + `
` + + (st.diff.busy ? '

Loading\u2026

' : '') + (st.diff.error ? `

${esc(st.diff.error)}

` : '') + + (st.diff.remote ? wfEdDiffHtml(st.diff.remote) : '') : '

Save the model to compare stored versions.

'); + } else if (tab === 'simulate') { + const sim = st.sim; + const dis = sim.busy ? 'disabled' : ''; + const kinds = WF_ED_KINDS.map(k => ``).join(''); + body = `
${kinds}${sim.hit ? '' : ''}
` + + `
${wfSimFieldsFor(sim.kind).map(f => wfEdSimFieldHtml(f, sim.facts, dis)).join('')}
` + + (sim.error ? `

${esc(sim.error)}

` : '') + + (sim.result ? renderWorkflowSimulateResult(sim.result) : ''); + } + return '
' + tabs.map(([id, label]) => + ``).join('') + + `
` + + `
${body}
`; + } + + // wfEdRefresh re-renders only the named regions so inputs elsewhere keep + // focus; a full re-render goes through the config tab. + function wfEdRefresh(parts) { + const root = document.querySelector('[data-wf-ed-root]'); + if (!root) { if (_wfLib.view === 'editor') wfLibRerender(); return; } + const want = parts || ['toolbar', 'palette', 'canvas', 'inspector', 'drawer']; + const map = { toolbar: renderWfEdToolbar, palette: renderWfEdPalette, canvas: renderWfEdCanvas, inspector: renderWfEdInspector, drawer: renderWfEdDrawer }; + want.forEach(p => { + const el = document.getElementById('wf-ed-' + p); + if (!el) return; + if (p === 'drawer' && _wfEd.drawer.tab === 'yaml' && _wfEd.yaml.focused) return; + if (p === 'canvas') wfEdMeasure(el); + el.innerHTML = map[p](_wfEd); + if (p === 'drawer') el.classList.toggle('open', _wfEd.drawer.open); + }); + wfEdAttach(root); + if (want.indexOf('drawer') >= 0 && _wfEd.drawer.open && _wfEd.drawer.tab === 'yaml') wfEdYamlSync(); + } + + function wfEdMeasure(el) { + const r = el.getBoundingClientRect(); + if (r.width > 50) _wfEd.viewport.w = Math.floor(r.width); + if (r.height > 50) _wfEd.viewport.h = Math.floor(r.height); + } + + // wfEdAfterRender runs after the Workflows tab is drawn. + function wfEdAfterRender() { + const root = document.querySelector('[data-wf-ed-root]'); + if (!root || !_wfEd.model) return; + const canvas = document.getElementById('wf-ed-canvas'); + if (canvas) { + const before = _wfEd.viewport.w + 'x' + _wfEd.viewport.h; + wfEdMeasure(canvas); + if (before !== _wfEd.viewport.w + 'x' + _wfEd.viewport.h) canvas.innerHTML = renderWfEdCanvas(_wfEd); + } + wfEdAttach(root); + if (_wfEd.drawer.open && _wfEd.drawer.tab === 'yaml') wfEdYamlSync(); + if (!_wfEd.palette.hiveDefault && !_wfEd.palette.loading) wfEdLoadHiveDefault(); + if (_wfEd.source && _wfEd.diff.loadedFor !== _wfEd.source.name) wfEdLoadVersions(); + } + + // --- Pointer interactions ------------------------------------------------- + + function wfEdCanvasPoint(ev) { + const svg = document.querySelector('#wf-ed-canvas svg[data-wf-ed-canvas]'); + if (!svg) return { sx: 0, sy: 0, x: 0, y: 0 }; + const r = svg.getBoundingClientRect(); + const sx = ev.clientX - r.left, sy = ev.clientY - r.top, v = _wfEd.view; + return { sx: sx, sy: sy, x: (sx - v.panX) / v.zoom, y: (sy - v.panY) / v.zoom }; + } + + // wfEdAttach wires the canvas, palette and keyboard once per root. + function wfEdAttach(root) { + if (!root) return; + const canvas = document.getElementById('wf-ed-canvas'); + if (canvas && !canvas._wfEdWired) { + canvas._wfEdWired = true; + canvas.addEventListener('pointerdown', wfEdPointerDown); + canvas.addEventListener('pointermove', wfEdPointerMove); + canvas.addEventListener('pointerup', wfEdPointerUp); + canvas.addEventListener('pointercancel', wfEdPointerUp); + canvas.addEventListener('wheel', wfEdWheel, { passive: false }); + canvas.addEventListener('dblclick', wfEdDblClick); + canvas.addEventListener('click', wfEdCanvasClick); + canvas.addEventListener('dragover', ev => { if (!wfEdCanEdit()) return; ev.preventDefault(); const p = wfEdCanvasPoint(ev); const lane = wfEdLaneAt(wfEdLayoutNow(), p.x); const id = lane && !lane.virtual ? lane.id : ''; if (id !== _wfEd.dropLane) { _wfEd.dropLane = id; wfEdRefresh(['canvas']); } }); + canvas.addEventListener('dragleave', () => { if (_wfEd.dropLane) { _wfEd.dropLane = ''; wfEdRefresh(['canvas']); } }); + canvas.addEventListener('drop', wfEdDrop); + canvas.addEventListener('keydown', wfEdCanvasKey); + } + const palette = document.getElementById('wf-ed-palette'); + if (palette && !palette._wfEdWired) { + palette._wfEdWired = true; + palette.addEventListener('dragstart', ev => { + const item = ev.target.closest && ev.target.closest('[data-wf-pal]'); + if (!item || !wfEdCanEdit()) { ev.preventDefault(); return; } + ev.dataTransfer.setData('text/plain', item.getAttribute('data-wf-pal')); + ev.dataTransfer.effectAllowed = 'copy'; + }); + } + if (!root._wfEdWired) { + root._wfEdWired = true; + root.addEventListener('keydown', wfEdRootKey); + root.addEventListener('keyup', ev => { if (ev.key === ' ') _wfEd.spaceHeld = false; }); + root.addEventListener('focusin', ev => { if (ev.target && ev.target.id === 'wf-ed-yaml') _wfEd.yaml.focused = true; }); + root.addEventListener('focusout', ev => { if (ev.target && ev.target.id === 'wf-ed-yaml') _wfEd.yaml.focused = false; }); + root.addEventListener('click', ev => { if (_wfEd.exportMenu && !(ev.target.closest && ev.target.closest('.wf-ed-menu'))) { _wfEd.exportMenu = false; wfEdRefresh(['toolbar']); } }); + } + if (!window._wfEdResizeWired) { + window._wfEdResizeWired = true; + window.addEventListener('resize', () => { if (document.getElementById('wf-ed-canvas')) wfEdRefresh(['canvas']); }); + } + } + + function wfEdPointerDown(ev) { + if (ev.button === 2) return; + const t = ev.target; + const closest = sel => t.closest ? t.closest(sel) : null; + const p = wfEdCanvasPoint(ev); + const can = wfEdCanEdit(); + const layout = wfEdLayoutNow(); + ev.currentTarget.setPointerCapture && ev.currentTarget.setPointerCapture(ev.pointerId); + if (ev.button === 1 || _wfEd.spaceHeld) { _wfEd.drag = { type: 'pan', sx: p.sx, sy: p.sy, panX: _wfEd.view.panX, panY: _wfEd.view.panY }; ev.preventDefault(); return; } + const mm = closest('[data-wf-ed-minimap]'); + if (mm) { wfEdMinimapJump(ev, mm); _wfEd.drag = { type: 'minimap', el: mm }; return; } + if (closest('[data-wf-confirm]') || closest('[data-wf-problem]')) return; + const port = closest('[data-wf-port]'); + if (port) { + if (!can) return; + const from = port.getAttribute('data-wf-port'), c = layout.chips[from]; + _wfEd.drag = { type: 'port', from: from, x1: c.x + c.w, y1: c.y + c.h / 2 }; + _wfEd.pendingEdge = { x1: _wfEd.drag.x1, y1: _wfEd.drag.y1, x2: p.x, y2: p.y }; + ev.preventDefault(); return; + } + const rs = closest('[data-wf-resize]'); + if (rs) { const id = rs.getAttribute('data-wf-resize'); _wfEd.drag = { type: 'resize', id: id, sx: p.x, w: _wfEd.view.laneWidths[id] || WF_ED.colW }; ev.preventDefault(); return; } + const chip = closest('[data-wf-state]'); + if (chip) { + const id = chip.getAttribute('data-wf-state'); + const sel = _wfEd.selection; + if (ev.shiftKey) wfEdSelect('state', id, true); + else if (!(sel.type === 'state' && sel.ids.indexOf(id) >= 0)) wfEdSelect('state', id, false); + const ids = _wfEd.selection.type === 'state' ? _wfEd.selection.ids.slice() : [id]; + if (can) _wfEd.drag = { type: 'chip', ids: ids, sx: p.x, sy: p.y, moved: false }; + ev.preventDefault(); return; + } + const head = closest('[data-wf-colhead]'); + if (head) { + const id = head.getAttribute('data-wf-colhead'); + if (id !== WF_ED_UNASSIGNED) { wfEdSelect('column', id, false); if (can) _wfEd.drag = { type: 'col', id: id, sx: p.x, moved: false }; } + ev.preventDefault(); return; + } + const edge = closest('[data-wf-edge]'); + if (edge) { wfEdSelect('transition', edge.getAttribute('data-wf-edge'), ev.shiftKey); ev.preventDefault(); return; } + if (closest('[data-wf-col]')) { + // Clicking lane body: select lane but allow marquee drag. + const id = closest('[data-wf-col]').getAttribute('data-wf-col'); + if (!ev.shiftKey) { if (id !== WF_ED_UNASSIGNED) wfEdSelect('column', id, false); else wfEdSelect('', null); } + } else if (!ev.shiftKey) wfEdSelect('', null); + _wfEd.drag = { type: 'marquee', sx: p.sx, sy: p.sy, additive: ev.shiftKey }; + _wfEd.marquee = null; + ev.preventDefault(); + } + + function wfEdPointerMove(ev) { + const d = _wfEd.drag; + if (!d) return; + const p = wfEdCanvasPoint(ev); + if (d.type === 'pan') { _wfEd.view.panX = d.panX + (p.sx - d.sx); _wfEd.view.panY = d.panY + (p.sy - d.sy); wfEdRefresh(['canvas']); return; } + if (d.type === 'minimap') { wfEdMinimapJump(ev, d.el); return; } + if (d.type === 'port') { _wfEd.pendingEdge = { x1: d.x1, y1: d.y1, x2: p.x, y2: p.y }; wfEdRefresh(['canvas']); return; } + if (d.type === 'resize') { _wfEd.view.laneWidths[d.id] = Math.max(WF_ED.minColW, Math.min(WF_ED.maxColW, wfEdSnap(d.w + (p.x - d.sx)))); wfEdRefresh(['canvas', 'inspector']); return; } + if (d.type === 'chip' || d.type === 'col') { + if (!d.moved && Math.abs(p.x - d.sx) + Math.abs(p.y - (d.sy || p.y)) < 4) return; + d.moved = true; + const lane = wfEdLaneAt(wfEdLayoutNow(), p.x); + _wfEd.dropLane = lane ? lane.id : ''; + d.x = p.x; d.y = p.y; + wfEdRefresh(['canvas']); return; + } + if (d.type === 'marquee') { _wfEd.marquee = { x1: d.sx, y1: d.sy, x2: p.sx, y2: p.sy }; wfEdRefresh(['canvas']); } + } + + function wfEdPointerUp(ev) { + const d = _wfEd.drag; + if (!d) return; + _wfEd.drag = null; + const p = wfEdCanvasPoint(ev); + const layout = wfEdLayoutNow(); + const t = ev.target, closest = sel => t && t.closest ? t.closest(sel) : null; + if (d.type === 'port') { + _wfEd.pendingEdge = null; + const chip = closest('[data-wf-state]') || document.elementFromPoint(ev.clientX, ev.clientY)?.closest?.('[data-wf-state]'); + if (chip) { + const to = chip.getAttribute('data-wf-state'); + const before = _wfEd.model.spec.transitions.length; + const next = wfEdAddTransition(_wfEd.model, d.from, to, ''); + if (next.spec.transitions.length > before) { + _wfEd.selection = { type: 'transition', ids: [String(before)] }; + wfEdCommit(next); + const on = document.getElementById('wf-ed-focus-on'); if (on) on.focus(); + return; + } + showToast('That transition already exists.', 'info'); + } + wfEdRefresh(['canvas']); return; + } + if (d.type === 'chip') { + _wfEd.dropLane = ''; + if (!d.moved) { wfEdRefresh(['canvas']); return; } + const lane = wfEdLaneAt(layout, p.x); + if (!lane) { wfEdRefresh(['canvas']); return; } + let slot = lane.virtual ? 0 : wfEdSlotAt(layout, lane, p.y); + let m = _wfEd.model; + d.ids.forEach(id => { + const cur = layout.chips[id]; + if (cur && cur.col === lane.id && cur.index < slot) slot = Math.max(0, slot - 1); + m = wfEdMoveState(m, id, lane.virtual ? '' : lane.id, slot); + slot++; + }); + wfEdCommit(m); return; + } + if (d.type === 'col') { + _wfEd.dropLane = ''; + if (!d.moved) { wfEdRefresh(['canvas']); return; } + const lane = wfEdLaneAt(layout, p.x); + if (!lane || lane.virtual || lane.id === d.id) { wfEdRefresh(['canvas']); return; } + const cols = _wfEd.model.spec.columns; + const from = cols.findIndex(c => c.id === d.id), to = cols.findIndex(c => c.id === lane.id); + wfEdCommit(wfEdMoveColumn(_wfEd.model, d.id, to > from ? to : to)); return; + } + if (d.type === 'marquee') { + const mq = _wfEd.marquee; _wfEd.marquee = null; + if (mq && Math.abs(mq.x2 - mq.x1) > 4 && Math.abs(mq.y2 - mq.y1) > 4) { + const v = _wfEd.view; + const x1 = (Math.min(mq.x1, mq.x2) - v.panX) / v.zoom, x2 = (Math.max(mq.x1, mq.x2) - v.panX) / v.zoom; + const y1 = (Math.min(mq.y1, mq.y2) - v.panY) / v.zoom, y2 = (Math.max(mq.y1, mq.y2) - v.panY) / v.zoom; + const hit = Object.keys(layout.chips).filter(id => { const c = layout.chips[id]; return c.x < x2 && c.x + c.w > x1 && c.y < y2 && c.y + c.h > y1; }); + const sel = _wfEd.selection; + if (d.additive && sel.type === 'state') hit.forEach(id => { if (sel.ids.indexOf(id) < 0) sel.ids.push(id); }); + else _wfEd.selection = hit.length ? { type: 'state', ids: hit } : { type: '', ids: [] }; + wfEdRefresh(['canvas', 'inspector']); return; + } + wfEdRefresh(['canvas']); return; + } + if (d.type === 'resize') { wfEdRefresh(['canvas', 'inspector']); } + } + + function wfEdMinimapJump(ev, mm) { + const k = parseFloat(mm.getAttribute('data-k')) || 1; + const r = mm.getBoundingClientRect(); + const mx = (ev.clientX - r.left) / k, my = (ev.clientY - r.top) / k, v = _wfEd.view; + v.panX = -(mx * v.zoom - _wfEd.viewport.w / 2); v.panY = -(my * v.zoom - _wfEd.viewport.h / 2); + wfEdRefresh(['canvas']); + } + + function wfEdWheel(ev) { + ev.preventDefault(); + const v = _wfEd.view; + if (ev.ctrlKey || ev.metaKey) { + const p = wfEdCanvasPoint(ev); + const factor = ev.deltaY < 0 ? 1.1 : 1 / 1.1; + const zoom = Math.max(0.2, Math.min(3, v.zoom * factor)); + v.panX = p.sx - (p.sx - v.panX) * (zoom / v.zoom); v.panY = p.sy - (p.sy - v.panY) * (zoom / v.zoom); v.zoom = zoom; + wfEdRefresh(['canvas', 'toolbar']); + } else { + v.panX -= ev.shiftKey ? ev.deltaY : ev.deltaX; v.panY -= ev.shiftKey ? 0 : ev.deltaY; + wfEdRefresh(['canvas']); + } + } + + function wfEdCanvasClick(ev) { + const t = ev.target, closest = sel => t.closest ? t.closest(sel) : null; + const confirm = closest('[data-wf-confirm]'); + if (confirm) { wfEdConfirmState(confirm.getAttribute('data-wf-confirm')); return; } + const prob = closest('[data-wf-problem]'); + if (prob) { _wfEd.drawer.open = true; _wfEd.drawer.tab = 'problems'; wfEdRefresh(['drawer']); } + } + + async function wfEdDblClick(ev) { + const t = ev.target, closest = sel => t.closest ? t.closest(sel) : null; + const chip = closest('[data-wf-state]'), head = closest('[data-wf-colhead]'); + if (chip) { + const id = chip.getAttribute('data-wf-state'), s = wfEdFindState(_wfEd.model, id); + if (!s || !wfEdCanEdit()) return; + const label = await hivePrompt({ title: 'Rename state', message: 'Display label for ' + id + '.', defaultValue: s.label || id, confirmLabel: 'Rename' }); + if (label && label !== s.label) wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.states[id=' + id + '].label', String(label).trim())); + } else if (head) { + const id = head.getAttribute('data-wf-colhead'), c = wfEdFindColumn(_wfEd.model, id); + if (!c || !wfEdCanEdit()) return; + const label = await hivePrompt({ title: 'Rename column', message: 'Display label for ' + id + '.', defaultValue: c.label || id, confirmLabel: 'Rename' }); + if (label && label !== c.label) wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.columns[id=' + id + '].label', String(label).trim())); + } + } + + function wfEdDrop(ev) { + ev.preventDefault(); + _wfEd.dropLane = ''; + if (!wfEdCanEdit()) return; + let payload = null; + try { payload = JSON.parse(ev.dataTransfer.getData('text/plain')); } catch (e) { payload = null; } + if (!payload) { wfEdRefresh(['canvas']); return; } + const p = wfEdCanvasPoint(ev), layout = wfEdLayoutNow(), lane = wfEdLaneAt(layout, p.x); + wfEdPaletteAdd(payload, lane && !lane.virtual ? lane.id : '', lane && !lane.virtual ? wfEdSlotAt(layout, lane, p.y) : undefined, lane ? lane.index : undefined); + } + + function wfEdPaletteAdd(payload, laneId, slot, laneIndex) { + if (!payload || !_wfEd.model) return; + if (payload.kind === 'column') { + const m = wfEdAddColumn(_wfEd.model, { id: payload.id, label: payload.label, terminal: !!payload.terminal, hint: payload.hint }, laneIndex); + _wfEd.selection = { type: 'column', ids: [m.spec.columns[laneIndex === undefined ? m.spec.columns.length - 1 : Math.min(laneIndex, m.spec.columns.length - 1)].id] }; + wfEdCommit(m); return; + } + const lc = { number: payload.number || undefined }; + if (Array.isArray(payload.kinds) && payload.kinds.length) lc.kinds = payload.kinds.slice(); + if (Array.isArray(payload.replies) && payload.replies.length) lc.replies = payload.replies.slice(); + const target = laneId || (_wfEd.selection.type === 'column' ? _wfEd.selection.ids[0] : (_wfEd.selection.type === 'state' ? ((wfEdFindState(_wfEd.model, _wfEd.selection.ids[0]) || {}).lifecycle || {}).column : '')) || (_wfEd.model.spec.columns[0] || {}).id || ''; + const m = wfEdAddState(_wfEd.model, { id: payload.id, label: payload.label, owner: payload.owner || 'human', lifecycle: lc }, target, slot); + const added = m.spec.states[m.spec.states.length - 1]; + _wfEd.selection = { type: 'state', ids: [added.id] }; + wfEdCommit(m); + } + + function wfEdPaletteClick(payload) { if (wfEdCanEdit()) wfEdPaletteAdd(payload, '', undefined, undefined); } + + function wfEdRootKey(ev) { + const tag = (ev.target.tagName || '').toLowerCase(); + const inField = tag === 'input' || tag === 'textarea' || tag === 'select' || ev.target.isContentEditable; + if ((ev.metaKey || ev.ctrlKey) && (ev.key === 'z' || ev.key === 'Z')) { + if (inField) return; + ev.preventDefault(); + if (ev.shiftKey) wfEdRedo(); else wfEdUndo(); + } else if ((ev.metaKey || ev.ctrlKey) && (ev.key === 's' || ev.key === 'S')) { + ev.preventDefault(); if (wfEdCanEdit()) wfEdSave(); + } + } + + function wfEdCanvasKey(ev) { + const t = ev.target, closest = sel => t.closest ? t.closest(sel) : null; + if (ev.key === ' ') { _wfEd.spaceHeld = true; ev.preventDefault(); return; } + if (ev.key === 'Escape') { _wfEd.exportMenu = false; wfEdSelect('', null); return; } + if (ev.key === 'Enter') { + const chip = closest('[data-wf-state]'), head = closest('[data-wf-colhead]'); + if (chip) { wfEdSelect('state', chip.getAttribute('data-wf-state'), ev.shiftKey); ev.preventDefault(); } + else if (head && head.getAttribute('data-wf-colhead') !== WF_ED_UNASSIGNED) { wfEdSelect('column', head.getAttribute('data-wf-colhead'), false); ev.preventDefault(); } + return; + } + if (ev.key === 'Delete' || ev.key === 'Backspace') { + const chip = closest('[data-wf-state]'); + if (chip && !(_wfEd.selection.type === 'state' && _wfEd.selection.ids.indexOf(chip.getAttribute('data-wf-state')) >= 0)) _wfEd.selection = { type: 'state', ids: [chip.getAttribute('data-wf-state')] }; + ev.preventDefault(); wfEdDeleteSelection(); return; + } + if (ev.key.indexOf('Arrow') === 0 && _wfEd.selection.type === 'state' && _wfEd.selection.ids.length === 1 && wfEdCanEdit()) { + ev.preventDefault(); + const id = _wfEd.selection.ids[0], layout = wfEdLayoutNow(), c = layout.chips[id]; + if (!c) return; + if (ev.key === 'ArrowUp' || ev.key === 'ArrowDown') { + const idx = c.index + (ev.key === 'ArrowUp' ? -1 : 1); + if (idx < 0) return; + wfEdCommit(wfEdMoveState(_wfEd.model, id, c.col === WF_ED_UNASSIGNED ? '' : c.col, idx)); + } else { + const ci = c.colIndex + (ev.key === 'ArrowLeft' ? -1 : 1); + const lane = layout.cols[ci]; + if (!lane) return; + wfEdCommit(wfEdMoveState(_wfEd.model, id, lane.virtual ? '' : lane.id, c.index)); + } + } + } + + // --- Actions (delegated) -------------------------------------------------- + + function wfEdOpen(name, version) { return wfEdOpenEntry(name, version); } + async function wfEdOpenEntry(name, version) { + if (!(await wfEdGuardLeave())) return; + try { + const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + const ro = wfProvenanceOf(data.entry || { name: name }) === 'built-in' || !!(data.entry && data.entry.readOnly); + wfEdLoad(data.model, { name: name, version: version }, ro); + } catch (err) { + showToast('Could not open ' + name + '@' + version + ': ' + err.message, 'error'); + } + } + + async function wfEdNew() { + if (!wfEdOwner()) { showToast('Owner role is required to create a model.', 'error'); return; } + if (!(await wfEdGuardLeave())) return; + const name = await hivePrompt({ title: 'New workflow model', message: 'Name (lower-case, digits, dots, dashes).', defaultValue: 'my-lifecycle', confirmLabel: 'Create' }); + if (!name) return; + const slug = wfEdSlug(name); + if (!/^[a-z0-9][a-z0-9._-]*$/.test(slug)) { showToast('Name must be lower-case letters, digits, dots, dashes or underscores.', 'error'); return; } + wfEdLoad(wfEdNewModel(slug), null, false); + } + + async function wfEdDuplicateDefault() { return wfEdCopyOf('hive-default', '1'); } + async function wfEdEditCopy() { if (_wfEd.source) return wfEdCopyOf(_wfEd.source.name, _wfEd.source.version, _wfEd.model); } + + // wfEdCopyOf opens a copy of name@version (or the given model) as an + // unsaved working copy under a new name. + async function wfEdCopyOf(name, version, model) { + if (!wfEdOwner()) { showToast('Owner role is required to edit.', 'error'); return; } + if (_wfEd.model && !_wfEd.readOnly && !(await wfEdGuardLeave())) return; + const newName = await hivePrompt({ title: 'Edit a copy of ' + name + '@' + version, message: 'Name for the copy. It opens unsaved; Save stores it as version 1 with provenance local.', defaultValue: name === 'hive-default' ? 'my-lifecycle' : name + '-copy', confirmLabel: 'Open copy' }); + if (!newName) return; + const target = wfEdSlug(newName); + try { + let m = model; + if (!m) { + const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/' + encodeURIComponent(version), { headers: _inceptionAuthHeaders() }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + m = (await res.json()).model; + } + m = wfEdClone(m); + m.metadata = Object.assign({}, m.metadata, { name: target }); + m.spec = Object.assign({}, m.spec); + m.spec.meta = Object.assign({}, m.spec.meta, { version: '', provenance: 'local', description: ((m.spec.meta && m.spec.meta.description) ? m.spec.meta.description + ' ' : '') + '(copy of ' + name + '@' + version + ')' }); + wfEdLoad(m, null, false); + _wfEd.saved = null; + wfEdRefresh(['toolbar']); + } catch (err) { + showToast('Could not copy: ' + err.message, 'error'); + } + } + + function wfEdOpenIngestResult() { + const r = _wfLib.ingest && _wfLib.ingest.result; + if (!r || !r.model) { showToast('No ingest result to open.', 'error'); return; } + wfEdOpenModel(r.model, r.entry ? { name: r.entry.name, version: r.entry.version } : null); + } + async function wfEdOpenModel(model, source) { + if (!(await wfEdGuardLeave())) return; + wfEdLoad(model, source, false); + if (!source) { _wfEd.saved = null; wfEdRefresh(['toolbar']); } + } + + // wfEdGuardLeave asks before discarding unsaved work; true = go ahead. + async function wfEdGuardLeave() { + if (!_wfEd.model || !wfEdDirty() || _wfEd.readOnly) return true; + return hiveConfirm({ title: 'Discard unsaved changes?', message: 'The working copy of ' + ((_wfEd.model.metadata || {}).name || 'this model') + ' has changes that were not saved.', confirmLabel: 'Discard', danger: true }); + } + + async function wfEdClose() { + if (!(await wfEdGuardLeave())) return; + Object.assign(_wfEd, wfEdDefaultState(), { palette: _wfEd.palette }); + _wfLib.view = 'library'; + wfLibRerender(); + } + + function wfEdDrawerTab(tab) { _wfEd.drawer.tab = WF_ED_DRAWERS.indexOf(tab) >= 0 ? tab : 'problems'; _wfEd.drawer.open = true; wfEdRefresh(['drawer']); } + function wfEdDrawerToggle() { _wfEd.drawer.open = !_wfEd.drawer.open; wfEdRefresh(['drawer', 'canvas']); } + function wfEdToggleExport() { _wfEd.exportMenu = !_wfEd.exportMenu; wfEdRefresh(['toolbar']); } + function wfEdZoom(dir) { const v = _wfEd.view, z = Math.max(0.2, Math.min(3, v.zoom * (dir > 0 ? 1.2 : 1 / 1.2))); const cx = _wfEd.viewport.w / 2, cy = _wfEd.viewport.h / 2; v.panX = cx - (cx - v.panX) * (z / v.zoom); v.panY = cy - (cy - v.panY) * (z / v.zoom); v.zoom = z; wfEdRefresh(['canvas', 'toolbar']); } + function wfEdFit() { const fit = wfEdFitView(wfEdLayoutNow(), _wfEd.viewport.w, _wfEd.viewport.h); Object.assign(_wfEd.view, fit); wfEdRefresh(['canvas', 'toolbar']); } + function wfEdLaneWidth(id, value) { _wfEd.view.laneWidths[id] = wfEdSnap(parseInt(value, 10) || WF_ED.colW); wfEdRefresh(['canvas']); } + + function wfEdSelectTransition(i) { wfEdSelect('transition', String(i), false); } + function wfEdSelectSubject(subject) { + const s = String(subject || ''); + if (!s) return; + if (wfEdFindState(_wfEd.model, s)) { wfEdSelect('state', s, false); return; } + if (wfEdFindColumn(_wfEd.model, s)) { wfEdSelect('column', s, false); return; } + const arrow = s.indexOf('->'); + if (arrow > 0) { + const from = s.slice(0, arrow), to = s.slice(arrow + 2); + const i = _wfEd.model.spec.transitions.findIndex(t => t.from === from && t.to === to); + if (i >= 0) wfEdSelect('transition', String(i), false); + } + } + + function wfEdFieldSet(path, value) { + if (!_wfEd.model) return; + let v = value == null ? '' : String(value); + if (path === 'metadata.name') { v = wfEdSlug(v); } + const parts = path === 'spec.initial' || path === 'spec.derivation.mode' ? undefined : ['toolbar', 'canvas', 'inspector', 'drawer']; + wfEdCommit(wfEdUpdate(_wfEd.model, path, v), { parts: parts }); + } + function wfEdFieldSetNumber(path, value) { + const n = parseInt(value, 10); + wfEdCommit(wfEdUpdate(_wfEd.model, path, isNaN(n) || n <= 0 ? '' : n)); + } + function wfEdFieldSetBool(path, checked) { wfEdCommit(wfEdUpdate(_wfEd.model, path, checked ? true : '')); } + function wfEdSetList(path, value) { wfEdCommit(wfEdUpdate(_wfEd.model, path, String(value || '').split(/[,\s]+/).map(s => s.trim()).filter(Boolean))); } + function wfEdSetStateColumn(id, columnId) { wfEdCommit(wfEdMoveState(_wfEd.model, id, columnId === WF_ED_UNASSIGNED ? '' : columnId)); } + function wfEdSetInitial(id) { wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.initial', id)); } + function wfEdSetKind(id, kind, checked) { + const s = wfEdFindState(_wfEd.model, id); if (!s) return; + const kinds = ((s.lifecycle || {}).kinds || []).filter(k => k !== kind); + if (checked) kinds.push(kind); + wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.states[id=' + id + '].lifecycle.kinds', WF_ED_KINDS.filter(k => kinds.indexOf(k) >= 0))); + } + async function wfEdRenameStateAction(id) { + const v = await hivePrompt({ title: 'Rename state id', message: 'New id for ' + id + '. Transitions, initial, labels and rules follow.', defaultValue: id, confirmLabel: 'Rename' }); + if (!v) return; + const next = wfEdRenameState(_wfEd.model, id, v); + const nid = wfEdSlug(v); + if (!wfEdFindState(next, nid)) { showToast('That id is taken or invalid.', 'error'); return; } + _wfEd.selection = { type: 'state', ids: [nid] }; + wfEdCommit(next); + } + async function wfEdRenameColumnAction(id) { + const v = await hivePrompt({ title: 'Rename column id', message: 'New id for ' + id + '.', defaultValue: id, confirmLabel: 'Rename' }); + if (!v) return; + const next = wfEdRenameColumn(_wfEd.model, id, v), nid = wfEdSlug(v); + if (!wfEdFindColumn(next, nid)) { showToast('That id is taken or invalid.', 'error'); return; } + _wfEd.selection = { type: 'column', ids: [nid] }; + wfEdCommit(next); + } + function wfEdMoveColumnBy(id, delta) { + const i = _wfEd.model.spec.columns.findIndex(c => c.id === id); + if (i < 0) return; + wfEdCommit(wfEdMoveColumn(_wfEd.model, id, i + delta)); + } + function wfEdReverseTransition(i) { + const t = _wfEd.model.spec.transitions[i]; if (!t) return; + const m = wfEdUpdate(wfEdUpdate(_wfEd.model, 'spec.transitions[' + i + '].from', t.to), 'spec.transitions[' + i + '].to', t.from); + wfEdCommit(m); + } + + async function wfEdAddColumnAction() { + const label = await hivePrompt({ title: 'New column', message: 'Column label (the id is derived).', defaultValue: 'Column ' + (_wfEd.model.spec.columns.length + 1), confirmLabel: 'Add' }); + if (!label) return; + const sel = _wfEd.selection.type === 'column' ? _wfEd.model.spec.columns.findIndex(c => c.id === _wfEd.selection.ids[0]) : -1; + const m = wfEdAddColumn(_wfEd.model, { label: String(label).trim() }, sel >= 0 ? sel + 1 : undefined); + const at = sel >= 0 ? sel + 1 : m.spec.columns.length - 1; + _wfEd.selection = { type: 'column', ids: [m.spec.columns[at].id] }; + wfEdCommit(m); + } + async function wfEdAddStateAction() { + const label = await hivePrompt({ title: 'New state', message: 'State label (the id is derived).', defaultValue: 'New state', confirmLabel: 'Add' }); + if (!label) return; + wfEdPaletteAdd({ kind: 'state', label: String(label).trim() }, '', undefined, undefined); + } + function wfEdAutoLayoutAction() { wfEdCommit(wfEdAutoLayout(_wfEd.model)); } + + async function wfEdDeleteSelection() { + const sel = _wfEd.selection; + if (!sel.type || !wfEdCanEdit()) return; + if (sel.type === 'state') { + const ids = sel.ids.slice(); + const touching = _wfEd.model.spec.transitions.filter(t => ids.indexOf(t.from) >= 0 || ids.indexOf(t.to) >= 0).length; + if (ids.length > 1 || touching) { + const ok = await hiveConfirm({ title: 'Delete ' + (ids.length === 1 ? 'state ' + ids[0] : ids.length + ' states') + '?', message: (touching ? touching + ' transition' + (touching === 1 ? '' : 's') + ' will go with ' + (ids.length === 1 ? 'it' : 'them') + '. ' : '') + 'Undo restores everything.', confirmLabel: 'Delete', danger: true }); + if (!ok) return; + } + let m = _wfEd.model; ids.forEach(id => { m = wfEdRemoveState(m, id); }); + _wfEd.selection = { type: '', ids: [] }; wfEdCommit(m); return; + } + if (sel.type === 'transition') { + const idx = sel.ids.map(i => parseInt(i, 10)).sort((a, b) => b - a); + let m = _wfEd.model; idx.forEach(i => { m = wfEdRemoveTransition(m, i); }); + _wfEd.selection = { type: '', ids: [] }; wfEdCommit(m); return; + } + if (sel.type === 'column') wfEdRemoveColumnAction(sel.ids[0]); + } + + async function wfEdRemoveColumnAction(id) { + const c = wfEdFindColumn(_wfEd.model, id); if (!c || !wfEdCanEdit()) return; + let moveTo = ''; + if ((c.states || []).length) { + const others = _wfEd.model.spec.columns.filter(o => o.id !== id); + const choice = await hivePrompt({ title: 'Delete column ' + id + '?', message: 'It holds ' + c.states.length + ' state' + (c.states.length === 1 ? '' : 's') + '. Type the id of the column to move them to' + (others.length ? ' (' + others.map(o => o.id).join(', ') + ')' : '') + ', or leave blank to unassign them.', defaultValue: others.length ? others[0].id : '', confirmLabel: 'Delete column' }); + if (choice === null || choice === undefined || choice === false) return; + moveTo = String(choice).trim(); + if (moveTo && !others.some(o => o.id === moveTo)) { showToast('No column ' + moveTo + '.', 'error'); return; } + } else { + const ok = await hiveConfirm({ title: 'Delete column ' + id + '?', message: 'The column is empty.', confirmLabel: 'Delete', danger: true }); + if (!ok) return; + } + _wfEd.selection = { type: '', ids: [] }; + wfEdCommit(wfEdRemoveColumn(_wfEd.model, id, moveTo)); + } + + function wfEdConfirmState(id) { + if (!wfEdCanEdit()) return; + wfEdCommit(wfEdUpdate(_wfEd.model, 'spec.states[id=' + id + '].lifecycle.confidence', '')); + } + async function wfEdConfirmAll() { + if (!wfEdCanEdit()) return; + let m = _wfEd.model; + wfEdStates(m).forEach(s => { if ((s.lifecycle || {}).confidence === 'inferred') m = wfEdUpdate(m, 'spec.states[id=' + s.id + '].lifecycle.confidence', ''); }); + (m.spec.events || []).forEach((e, i) => { if (e.confidence === 'inferred') m = wfEdUpdate(m, 'spec.events[' + i + '].confidence', ''); }); + (m.spec.gates || []).forEach((g, i) => { if (g.confidence === 'inferred') m = wfEdUpdate(m, 'spec.gates[' + i + '].confidence', ''); }); + wfEdCommit(m); + if (_wfEd.source && !_wfEd.readOnly && !_wfEd.saved) return; + if (_wfEd.source && (_wfEd.saved && (_wfEd.saved.spec.meta || {}).provenance === 'ingested-unreviewed')) { + const ok = await hiveConfirm({ title: 'Also confirm the stored version?', message: 'POST /confirm records that a human reviewed ' + _wfEd.source.name + '@' + _wfEd.source.version + ' and saves a confirmed copy as the next version. Your canvas edits stay unsaved until you Save.', confirmLabel: 'Confirm stored version' }); + if (!ok) return; + try { + const res = await fetch('/api/workflows/' + encodeURIComponent(_wfEd.source.name) + '/' + encodeURIComponent(_wfEd.source.version) + '/confirm', { method: 'POST', headers: _inceptionAuthHeaders() }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + showToast('Confirmed as ' + data.entry.name + '@' + data.entry.version); + _workflowState.loaded = false; await loadWorkflowLibrary(); wfEdLoadVersions(); + } catch (err) { showToast('Confirm failed: ' + err.message, 'error'); } + } + } + + // --- Persistence ---------------------------------------------------------- + + async function wfEdSave(asName) { + if (!wfEdCanEdit()) return; + const m = wfEdClone(_wfEd.model); + if (asName) m.metadata = Object.assign({}, m.metadata, { name: asName }); + const name = (m.metadata || {}).name; + if (!/^[a-z0-9][a-z0-9._-]*$/.test(name || '')) { showToast('Name must be lower-case letters, digits, dots, dashes or underscores.', 'error'); return; } + m.spec.meta = Object.assign({}, m.spec.meta, { version: '' }); + if (!m.spec.meta.provenance || m.spec.meta.provenance === 'built-in') m.spec.meta.provenance = 'local'; + try { + const res = await fetch('/api/workflows', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify(m) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(), e = data.entry || {}; + showToast('Saved ' + e.name + '@' + e.version + (e.compiled ? '' : ' (does not compile)'), e.compiled ? 'success' : 'info'); + _workflowState.loaded = false; await loadWorkflowLibrary(); + m.spec.meta.version = e.version; + _wfEd.model = wfEdNormalize(m); _wfEd.saved = wfEdClone(_wfEd.model); _wfEd.source = { name: e.name, version: e.version }; _wfEd.readOnly = false; + _wfEd.diff.loadedFor = ''; _wfEd.rev++; wfEdScheduleCompile(0); + wfEdRefresh(); wfEdLoadVersions(); + } catch (err) { + showToast('Save failed: ' + err.message, 'error'); + } + } + async function wfEdSaveAs() { + const cur = (_wfEd.model.metadata || {}).name || 'model'; + const v = await hivePrompt({ title: 'Save as\u2026', message: 'New name. The model is saved as version 1 of that name; the current name is untouched.', defaultValue: cur + '-copy', confirmLabel: 'Save' }); + if (!v) return; + return wfEdSave(wfEdSlug(v)); + } + async function wfEdRevert() { + if (!wfEdDirty()) return; + const ok = await hiveConfirm({ title: 'Revert to last saved?', message: 'Unsaved canvas changes are discarded (undo history is kept).', confirmLabel: 'Revert', danger: true }); + if (!ok) return; + wfEdCommit(_wfEd.saved || wfEdNewModel((_wfEd.model.metadata || {}).name)); + } + + async function wfEdLoadHiveDefault() { + _wfEd.palette.loading = true; + try { + const res = await fetch('/api/workflows/hive-default/1', { headers: _inceptionAuthHeaders() }); + if (res.ok) _wfEd.palette.hiveDefault = (await res.json()).model || null; + } catch (e) { /* palette section stays on its Load button */ } + _wfEd.palette.loading = false; + wfEdRefresh(['palette']); + } + + async function wfEdLoadVersions() { + if (!_wfEd.source) return; + const name = _wfEd.source.name; + _wfEd.diff.loadedFor = name; + try { + const res = await fetch('/api/workflows/' + encodeURIComponent(name) + '/versions', { headers: _inceptionAuthHeaders() }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + _wfEd.diff.versions = (data.versions || []).filter(v => v.version !== _wfEd.source.version); + } catch (err) { _wfEd.diff.versions = []; } + if (_wfEd.drawer.tab === 'diff') wfEdRefresh(['drawer']); + } + + async function wfEdDiffAgainst(version) { + const d = _wfEd.diff; + d.against = String(version || ''); d.remote = null; d.error = ''; + if (!d.against || !_wfEd.source) { wfEdRefresh(['drawer']); return; } + d.busy = true; wfEdRefresh(['drawer']); + try { + const res = await fetch('/api/workflows/' + encodeURIComponent(_wfEd.source.name) + '/diff?from=' + encodeURIComponent(d.against) + '&to=' + encodeURIComponent(_wfEd.source.version), { headers: _inceptionAuthHeaders() }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + d.remote = (await res.json()).diff || null; + } catch (err) { d.error = err.message; } + d.busy = false; wfEdRefresh(['drawer']); + } + + // --- YAML drawer ---------------------------------------------------------- + + function wfEdYamlSync() { + const y = _wfEd.yaml; + if (!_wfEd.model || y.focused || y.busy || y.rev === _wfEd.rev) return; + clearTimeout(_wfEd.timers.yaml); + _wfEd.timers.yaml = setTimeout(wfEdYamlRefresh, WF_ED_YAML_DEBOUNCE_MS); + } + async function wfEdYamlRefresh() { + const y = _wfEd.yaml, rev = _wfEd.rev; + if (!_wfEd.model) return; + y.busy = true; + try { + const res = await fetch('/api/workflows/yaml', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify({ model: _wfEd.model }) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + if (rev !== _wfEd.rev) return; + y.text = data.yaml || ''; y.rev = rev; y.error = ''; + } catch (err) { y.error = 'YAML render failed: ' + err.message; } + y.busy = false; y.focused = false; + if (_wfEd.drawer.tab === 'yaml') wfEdRefresh(['drawer']); + } + async function wfEdYamlApply() { + const y = _wfEd.yaml, el = document.getElementById('wf-ed-yaml'); + if (!el || !wfEdCanEdit()) return; + const text = el.value; + if (text === y.text) return; + y.busy = true; y.error = ''; y.focused = false; + try { + const res = await fetch('/api/workflows/yaml', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify({ yaml: text }) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + const data = await res.json(); + if (data.error) { y.error = data.error; y.text = text; y.busy = false; wfEdRefresh(['drawer']); return; } + y.text = text; y.busy = false; + wfEdPruneSelection(); + wfEdCommit(data.model); + y.rev = _wfEd.rev; + } catch (err) { y.error = 'YAML apply failed: ' + err.message; y.busy = false; wfEdRefresh(['drawer']); } + } + + // --- Simulate drawer ------------------------------------------------------ + + function wfEdSimSet(field, value) { + const sim = _wfEd.sim; + if (field === 'kind') { sim.kind = WF_ED_KINDS.indexOf(value) >= 0 ? value : 'pr'; sim.result = null; sim.error = ''; wfEdRefresh(['drawer']); return; } + sim.facts = wfSimFactsFromForm(sim.facts, sim.kind, f => f.path === field ? value : undefined); + } + async function wfEdSimRun() { + const sim = _wfEd.sim; + sim.facts = wfSimFactsFromForm(sim.facts, sim.kind, f => { const el = document.querySelector('[data-wf-sim-field="' + f.path + '"]'); return el ? (f.type === 'bool' ? el.checked : el.value) : undefined; }); + sim.busy = true; sim.error = ''; wfEdRefresh(['drawer']); + try { + const res = await fetch('/api/workflows/simulate', { method: 'POST', headers: _inceptionAuthHeaders(), body: JSON.stringify({ model: wfEdCompileModel(_wfEd.model), kind: sim.kind, facts: sim.facts }) }); + if (!res.ok) throw new Error(await saveErrorMessage(res)); + sim.result = await res.json(); + sim.hit = (sim.result.decision && !sim.result.decision.abstain && sim.result.decision.state) || ''; + } catch (err) { sim.error = 'Simulate failed: ' + err.message; sim.result = null; sim.hit = ''; } + sim.busy = false; wfEdRefresh(['drawer', 'canvas']); + } + function wfEdSimClear() { _wfEd.sim.hit = ''; wfEdRefresh(['drawer', 'canvas']); } + + // --- Export --------------------------------------------------------------- + + // wfEdExportStyle resolves the CSS tokens the canvas uses so the exported + // SVG is self-contained. The tag is built in pieces so the style ratchet, + // which scans inline