Skip to content

Commit 37caad9

Browse files
committed
✨ workflowmodel: ingest → lifecycle models, /api/workflows/ingest, survey compile stats (RFC #17620 P3a) (#17652)
ingest.Lifecycle composes hive-default's compiled lifecycle with a repository's pipelines: PR-verify pipelines on the CI / Review states with citations, the post-merge chain as inferred columns after Merged, provenance ingested-unreviewed. New pipeline.status fact (engine reports unknown until post-merge runs are observed). ingest.ReadTree selects CI files from a checkout. POST /api/workflows/ingest fetches a repo's default branch read-only (or takes pasted files), compiles, optionally saves at the next integer version. workflow-survey compiles every corpus repo's lifecycle model: 317/317 compile, 0 refusals. Signed-off-by: Andrew Anderson <andy@clubanderson.com>
1 parent 2b97f5b commit 37caad9

18 files changed

Lines changed: 922 additions & 2 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Workflow models, RFC phase 3a ([#17620](https://github.com/hivecommons/hive/issues/17620)): an ingested repository now yields a model that compiles. `ingest.Lifecycle` composes the hive-default lifecycle (states, columns, builtin derivation, roles, gates, events) with the repository's pipelines — PR-verify pipelines bound to the CI / Review states with file citations, the post-merge chain (main CI, release, per-environment deploys, done) as new columns after Merged — every ingested element marked inferred and the model's provenance `ingested-unreviewed`. New fact `pipeline.status` names the post-merge run the chain waits on (the engine reports `unknown` until it observes post-merge runs, so nothing moves on it yet). New owner-gated `POST /api/workflows/ingest` takes `{repo}` (default branch fetched read-only through the hive's GitHub client, CI files selected by `ingest.ReadTree`) or pasted `{files}`, returns the model, compile result and diagnostics, and with `save: true` stores it at the next integer version. `cmd/workflow-survey` compiles every repository's lifecycle model and reports pass/refusal counts by code; over the 317-repository CNCF corpus 317/317 compile with 1,117 inferred-element warnings and no refusals.

‎dashboard/openapi.json‎

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39356,6 +39356,79 @@
3935639356
}
3935739357
}
3935839358
}
39359+
},
39360+
"/api/workflows/ingest": {
39361+
"post": {
39362+
"tags": [
39363+
"Workflows"
39364+
],
39365+
"summary": "Ingest a repository's CI definitions into a lifecycle workflow model",
39366+
"description": "Reads the repository's default-branch GitHub Actions workflows and Tekton resources (or pasted files) and returns the hive-default lifecycle with the repository's pipelines bound: PR-verify pipelines on the CI / Review states, the post-merge chain as new columns. Provenance ingested-unreviewed. With save=true the model is stored as the next integer version under name. Owner role required.",
39367+
"requestBody": {
39368+
"required": true,
39369+
"content": {
39370+
"application/json": {
39371+
"schema": {
39372+
"type": "object",
39373+
"properties": {
39374+
"repo": {
39375+
"type": "string",
39376+
"description": "owner/name to fetch through the forge client; exactly one of repo or files"
39377+
},
39378+
"files": {
39379+
"type": "object",
39380+
"additionalProperties": {
39381+
"type": "string"
39382+
},
39383+
"description": "repo-relative path to file contents, for pasted or uploaded definitions"
39384+
},
39385+
"save": {
39386+
"type": "boolean"
39387+
},
39388+
"name": {
39389+
"type": "string",
39390+
"description": "store name (default: slug of the repository)"
39391+
}
39392+
}
39393+
}
39394+
}
39395+
}
39396+
},
39397+
"responses": {
39398+
"200": {
39399+
"description": "Ingested (not saved)",
39400+
"content": {
39401+
"application/json": {
39402+
"schema": {
39403+
"$ref": "#/components/schemas/WorkflowIngestResult"
39404+
}
39405+
}
39406+
}
39407+
},
39408+
"201": {
39409+
"description": "Ingested and saved",
39410+
"content": {
39411+
"application/json": {
39412+
"schema": {
39413+
"$ref": "#/components/schemas/WorkflowIngestResult"
39414+
}
39415+
}
39416+
}
39417+
},
39418+
"400": {
39419+
"description": "Bad request"
39420+
},
39421+
"403": {
39422+
"description": "Owner role required"
39423+
},
39424+
"502": {
39425+
"description": "Repository fetch failed"
39426+
},
39427+
"503": {
39428+
"description": "No forge client or no workflow store"
39429+
}
39430+
}
39431+
}
3935939432
}
3936039433
},
3936139434
"components": {
@@ -39759,6 +39832,38 @@
3975939832
"type": "object",
3976039833
"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.",
3976139834
"additionalProperties": true
39835+
},
39836+
"WorkflowIngestResult": {
39837+
"type": "object",
39838+
"properties": {
39839+
"model": {
39840+
"$ref": "#/components/schemas/WorkflowModel"
39841+
},
39842+
"compiled": {
39843+
"type": "boolean"
39844+
},
39845+
"diagnostics": {
39846+
"type": "array",
39847+
"items": {
39848+
"$ref": "#/components/schemas/WorkflowDiagnostic"
39849+
}
39850+
},
39851+
"profile": {
39852+
"type": "object"
39853+
},
39854+
"ref": {
39855+
"type": "string"
39856+
},
39857+
"files": {
39858+
"type": "integer"
39859+
},
39860+
"pipelines": {
39861+
"type": "integer"
39862+
},
39863+
"entry": {
39864+
"$ref": "#/components/schemas/WorkflowEntry"
39865+
}
39866+
}
3976239867
}
3976339868
}
3976439869
}

‎src/cmd/hive/main.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,6 +1087,7 @@ func (b *boot) wireBootClosures() {
10871087
return &dashboard.Dependencies{
10881088
Config: b.cfg,
10891089
WorkflowStore: wfstore.Open(wfstore.Root(b.cfg.Data.WorkflowsDir)),
1090+
WorkflowRepoFiles: workflowRepoFiles(func() *github.Client { return b.ghClient }),
10901091
AgentMgr: b.agentMgr,
10911092
CruxRuntimeHealth: cruxRuntimeHealthFunc(b.cruxSupervision),
10921093
// Provider gateways (#5565 slice 3): concrete openrouter/watsonx/

‎src/cmd/hive/workflow_ingest.go‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
7+
"github.com/hivecommons/hive/pkg/github"
8+
"github.com/hivecommons/hive/pkg/workflowmodel/ingest"
9+
)
10+
11+
// errNoForgeClient is returned by the workflow ingest fetcher before a
12+
// GitHub client exists.
13+
var errNoForgeClient = errors.New("no GitHub client")
14+
15+
// workflowRepoFiles is the dashboard's WorkflowRepoFiles dependency
16+
// (POST /api/workflows/ingest, RFC #17620 P3): it downloads the repo's
17+
// default branch read-only, keeps only the CI definition files and
18+
// removes the checkout. client is read per call so a credential rebuild is
19+
// followed.
20+
func workflowRepoFiles(client func() *github.Client) func(ctx context.Context, repo string) (map[string][]byte, string, error) {
21+
return func(ctx context.Context, repo string) (map[string][]byte, string, error) {
22+
c := client()
23+
if c == nil {
24+
return nil, "", errNoForgeClient
25+
}
26+
co, err := c.FetchDefaultBranchTree(ctx, repo)
27+
if err != nil {
28+
return nil, "", err
29+
}
30+
defer co.Cleanup()
31+
files, err := ingest.ReadTree(co.Dir)
32+
if err != nil {
33+
return nil, "", err
34+
}
35+
return files, co.Ref, nil
36+
}
37+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"errors"
6+
"testing"
7+
8+
"github.com/hivecommons/hive/pkg/github"
9+
)
10+
11+
func TestWorkflowRepoFilesWithoutClient(t *testing.T) {
12+
fetch := workflowRepoFiles(func() *github.Client { return nil })
13+
if _, _, err := fetch(context.Background(), "acme/widgets"); !errors.Is(err, errNoForgeClient) {
14+
t.Fatalf("err = %v", err)
15+
}
16+
}

‎src/cmd/workflow-survey/main.go‎

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ type repoResult struct {
6868
ValidateError string `json:"validateError,omitempty"`
6969
Hash string `json:"hash"`
7070
Severities map[workflowmodel.Severity]int `json:"severities,omitempty"`
71+
// Compiled says whether the lifecycle model (ingest.Lifecycle) compiles;
72+
// CompileCodes counts its diagnostics by code, errors and warnings.
73+
Compiled bool `json:"compiled"`
74+
CompileError string `json:"compileError,omitempty"`
75+
CompileCodes map[string]int `json:"compileCodes,omitempty"`
76+
LifecycleCols int `json:"lifecycleColumns"`
7177
}
7278

7379
type summary struct {
@@ -78,6 +84,8 @@ type summary struct {
7884
Parsed int `json:"parsed"`
7985
Pipelines int `json:"pipelines"`
8086
Valid int `json:"valid"`
87+
Compiled int `json:"compiled"`
88+
CompileCodes map[string]int `json:"compileCodes"`
8189
Roles map[workflowmodel.Role]int `json:"roles"`
8290
Constructs map[string]int `json:"constructs"`
8391
FindingCodes map[string]int `json:"findingCodes"`
@@ -160,7 +168,7 @@ func run(corpus, out string, filter map[string]bool, stdout, stderr io.Writer) e
160168
return err
161169
}
162170
maturity := loadProjects(corpus)
163-
sum := &summary{Roles: map[workflowmodel.Role]int{}, Constructs: map[string]int{}, FindingCodes: map[string]int{}, Severities: map[workflowmodel.Severity]int{}, ColumnSets: map[string]int{}, ByProject: map[string]*projectStats{}}
171+
sum := &summary{Roles: map[workflowmodel.Role]int{}, Constructs: map[string]int{}, FindingCodes: map[string]int{}, Severities: map[workflowmodel.Severity]int{}, CompileCodes: map[string]int{}, ColumnSets: map[string]int{}, ByProject: map[string]*projectStats{}}
164172
for _, d := range dirs {
165173
if !d.IsDir() {
166174
continue
@@ -252,9 +260,25 @@ func surveyRepo(dir, repo, outDir string) (repoResult, error) {
252260
}
253261
res.ColumnDiff = render.Diff(m)
254262

263+
lm := ingest.Lifecycle(workflowmodel.Scope{Forge: defaultForge, Repository: repo}, files)
264+
res.LifecycleCols = len(lm.Spec.Columns)
265+
res.CompileCodes = map[string]int{}
266+
_, diags, cerr := workflowmodel.Compile(lm)
267+
for _, d := range diags {
268+
res.CompileCodes[d.Code]++
269+
}
270+
if cerr != nil {
271+
res.CompileError = cerr.Error()
272+
} else {
273+
res.Compiled = true
274+
}
275+
255276
if err := os.MkdirAll(outDir, 0o755); err != nil {
256277
return res, err
257278
}
279+
if err := writeJSON(filepath.Join(outDir, "lifecycle.json"), lm); err != nil {
280+
return res, err
281+
}
258282
if err := writeJSON(filepath.Join(outDir, "model.json"), m); err != nil {
259283
return res, err
260284
}
@@ -324,6 +348,12 @@ func accumulate(s *summary, r repoResult) {
324348
if r.Valid {
325349
s.Valid++
326350
}
351+
if r.Compiled {
352+
s.Compiled++
353+
}
354+
for k, v := range r.CompileCodes {
355+
s.CompileCodes[k] += v
356+
}
327357
if len(r.ColumnDiff) > 0 {
328358
s.ReposChangedCols++
329359
}
@@ -375,7 +405,12 @@ func markdown(s *summary) string {
375405
fmt.Fprintf(&b, "# Workflow model survey\n\n")
376406
fmt.Fprintf(&b, "| | |\n|---|---|\n")
377407
fmt.Fprintf(&b, "| Repositories | %d |\n| with GitHub Actions pipelines | %d |\n| with Tekton pipelines | %d |\n", s.Repos, s.ReposWithGHA, s.ReposWithTekton)
378-
fmt.Fprintf(&b, "| Files read | %d |\n| Files parsed | %d (%.1f%%) |\n| Pipelines | %d |\n| Models valid | %d / %d |\n| Repos whose columns change | %d |\n\n", s.Files, s.Parsed, pct(s.Parsed, s.Files), s.Pipelines, s.Valid, s.Repos, s.ReposChangedCols)
408+
fmt.Fprintf(&b, "| Files read | %d |\n| Files parsed | %d (%.1f%%) |\n| Pipelines | %d |\n| Models valid | %d / %d |\n| Lifecycle models compiled | %d / %d |\n| Repos whose columns change | %d |\n\n", s.Files, s.Parsed, pct(s.Parsed, s.Files), s.Pipelines, s.Valid, s.Repos, s.Compiled, s.Repos, s.ReposChangedCols)
409+
b.WriteString("## Lifecycle compile diagnostics\n\n| Code | Count |\n|---|---|\n")
410+
for _, k := range sortedKeys(s.CompileCodes) {
411+
fmt.Fprintf(&b, "| %s | %d |\n", k, s.CompileCodes[k])
412+
}
413+
b.WriteString("\n")
379414
b.WriteString("## Pipeline roles\n\n| Role | Pipelines |\n|---|---|\n")
380415
for _, k := range sortedKeys(s.Roles) {
381416
fmt.Fprintf(&b, "| %s | %d |\n", k, s.Roles[workflowmodel.Role(k)])

‎src/docs/design/workflow-models.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,3 +125,33 @@ kind is not covered. Models are whole (no inheritance) through phase 4.
125125
columns, numbers, error}}}` (nil when nothing is bound). The JS labels the model column,
126126
renders a per-row column strip by stage number, and shows an amber "Not covered" chevron for
127127
abstentions. Settings → Repos has a per-repo Workflow select that PUTs the binding.
128+
129+
## Ingest → lifecycle (RFC phase 3a)
130+
131+
`ingest.Repository` (phase 0) is unchanged and still produces the process half. `ingest.Lifecycle`
132+
composes on top of it:
133+
134+
1. Start from `hivedefault.Generate()` — the compiled hive-default lifecycle, `derivation: builtin`.
135+
2. Copy the ingested pipelines, sources and coverage; `meta.source` is the system(s) read,
136+
`meta.provenance` is `ingested-unreviewed`, `meta.version` is `1` (the store assigns later
137+
versions).
138+
3. PR-verify pipelines are bound to every state in the CI / Review column, each with a citation to
139+
the file the pipeline came from.
140+
4. Every post-merge state the process half produced (main CI, release, `deploy-<env>`, done)
141+
becomes a state in its own column after Merged, `confidence: inferred`, with the environment
142+
guards carried over. The chain's transitions fire on `pipeline-succeeded` =
143+
`pipeline.status is green`. The engine does not observe post-merge runs yet and reports
144+
`unknown` for that fact, so a bound ingested model derives exactly like hive-default today;
145+
the extra columns render, nothing lands in them until post-merge observation exists.
146+
147+
The compiler warns once per inferred element; a human confirming them in the editor (phase 4)
148+
is what moves provenance to `local`/`curated`.
149+
150+
`POST /api/workflows/ingest` is the spoke entry point (owner role): `{repo}` fetches the default
151+
branch through `FetchDefaultBranchTree`, `ingest.ReadTree` keeps GitHub Actions workflows, YAML
152+
under Tekton directories and pipeline-named YAML that looks like Tekton (the survey collector's
153+
rules), and the checkout is removed. `{files}` accepts pasted definitions. `save: true` stores the
154+
result at the next integer version under `name` (default: the repository slug).
155+
156+
`cmd/workflow-survey` now compiles every corpus repository's lifecycle model (`lifecycle.json`
157+
alongside `model.json`) and reports pass/refusal counts by diagnostic code.

‎src/pkg/dashboard/api_workflows.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func (s *Server) registerWorkflowRoutes() {
2020
s.mux.HandleFunc("GET /api/workflows", s.handleWorkflowList)
2121
s.mux.HandleFunc("POST /api/workflows", s.handleWorkflowSave)
2222
s.mux.HandleFunc("POST /api/workflows/compile", s.handleWorkflowCompile)
23+
s.mux.HandleFunc("POST /api/workflows/ingest", s.handleWorkflowIngest)
2324
s.mux.HandleFunc("GET /api/workflows/bindings", s.handleWorkflowBindingsGet)
2425
s.mux.HandleFunc("PUT /api/workflows/bindings", s.handleWorkflowBindingPut)
2526
s.mux.HandleFunc("GET /api/workflows/{name}/{version}", s.handleWorkflowGet)

0 commit comments

Comments
 (0)