From 788cae7aff7954c553c8a971af36769c4e0b63c6 Mon Sep 17 00:00:00 2001 From: Christopher Maher Date: Thu, 9 Jul 2026 23:23:51 -0700 Subject: [PATCH] feat(cli): llmkube foreman slice renders a sliced Workload from a plan Task 6a of the Sliced Workloads epic (#1033): the native successor to the experiment's plan_to_workload.py. `llmkube foreman slice --plan FILE` parses a slice plan (the planner's YAML) and renders a Workload whose pipeline is one issue-fix step per disjoint slice, an integrate step (dependsOn every slice) that unions the slice branches, and a reconcile step (dependsOn integrate) that checks the union against the plan's pinned shared identifiers. --dry-run prints the Workload; otherwise it is created. buildSliceWorkload is a pure function (unit-tested for pipeline shape, branch naming foreman/slicer-/, dependsOn wiring, and pins in the reconcile payload); validateSlicePlan rejects a non-disjoint plan, a missing issue/repo, or no slices. The planner invocation (fetch issue + repomap + call the model to produce the plan) is Task 6b. Part of #1033 Signed-off-by: Christopher Maher --- pkg/cli/dispatch.go | 1 + pkg/cli/slice.go | 272 ++++++++++++++++++++++++++++++++++++++++++ pkg/cli/slice_test.go | 174 +++++++++++++++++++++++++++ 3 files changed, 447 insertions(+) create mode 100644 pkg/cli/slice.go create mode 100644 pkg/cli/slice_test.go diff --git a/pkg/cli/dispatch.go b/pkg/cli/dispatch.go index cb3aff78..64f3bf36 100644 --- a/pkg/cli/dispatch.go +++ b/pkg/cli/dispatch.go @@ -100,6 +100,7 @@ Foreman runs coder and reviewer agents as AgenticTasks against a fleet of heterogeneous executors. These subcommands create and watch those tasks.`, } cmd.AddCommand(newDispatchCommand()) + cmd.AddCommand(newSliceCommand()) return cmd } diff --git a/pkg/cli/slice.go b/pkg/cli/slice.go new file mode 100644 index 00000000..5d315227 --- /dev/null +++ b/pkg/cli/slice.go @@ -0,0 +1,272 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "context" + "fmt" + "io" + "os" + + "github.com/spf13/cobra" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + sigsyaml "sigs.k8s.io/yaml" + + foremanv1alpha1 "github.com/defilantech/llmkube/api/foreman/v1alpha1" + "gopkg.in/yaml.v3" +) + +// slicePlan mirrors the planner's YAML output (snake_case, matching the slicer +// experiment's PLANNER_PROMPT). It is the input to `llmkube foreman slice`. +type slicePlan struct { + Issue int32 `yaml:"issue"` + Repo string `yaml:"repo"` + Contract string `yaml:"contract"` + SharedIdentifiers []planSharedID `yaml:"shared_identifiers"` + Slices []planSlice `yaml:"slices"` +} + +type planSharedID struct { + ID string `yaml:"id"` + DefinedBy string `yaml:"defined_by"` + ReferencedBy []string `yaml:"referenced_by"` +} + +type planSlice struct { + Name string `yaml:"name"` + Files []string `yaml:"files"` + Task string `yaml:"task"` +} + +type sliceOptions struct { + planFile string + namespace string + coderAgent string + integrateAgent string + reconcileAgent string + baseBranch string + dryRun bool +} + +// newSliceCommand renders a sliced Workload from a slice plan and applies it. +func newSliceCommand() *cobra.Command { + opts := &sliceOptions{} + cmd := &cobra.Command{ + Use: "slice --plan FILE", + Short: "Render a sliced Workload from a slice plan and apply it", + Long: `Render a Workload from a slice plan (the planner's output) and apply it. + +The plan decomposes one issue into disjoint-file slices. This command renders a +Workload whose pipeline is: one issue-fix step per slice, then an integrate step +that unions the slice branches, then a reconcile step that checks the union +against the plan's pinned shared identifiers. + +Examples: + + # Dry-run: print the rendered Workload without applying it: + llmkube foreman slice --plan slice-plan-700.yaml --dry-run + + # Apply against a cluster: + llmkube foreman slice --plan slice-plan-700.yaml --coder-agent coder-metal`, + RunE: func(cmd *cobra.Command, _ []string) error { + return runSlice(cmd.Context(), cmd.OutOrStdout(), opts) + }, + } + f := cmd.Flags() + f.StringVar(&opts.planFile, "plan", "", "Path to the slice plan YAML (required)") + f.StringVar(&opts.namespace, "namespace", "default", "Namespace to create the Workload in") + f.StringVar(&opts.coderAgent, "coder-agent", "coder-metal", "Agent that runs each slice's issue-fix step") + f.StringVar(&opts.integrateAgent, "integrate-agent", "integrate", "Deterministic agent that runs the integrate step") + f.StringVar(&opts.reconcileAgent, "reconcile-agent", "reconcile", "Deterministic agent that runs the reconcile step") + f.StringVar(&opts.baseBranch, "base-branch", "main", "Base branch the slices are cut from and unioned onto") + f.BoolVar(&opts.dryRun, "dry-run", false, "Print the rendered Workload without applying it") + _ = cmd.MarkFlagRequired("plan") + return cmd +} + +func runSlice(ctx context.Context, out io.Writer, opts *sliceOptions) error { + plan, err := loadSlicePlan(opts.planFile) + if err != nil { + return err + } + if err := validateSlicePlan(plan); err != nil { + return err + } + wl := buildSliceWorkload(plan, opts) + + if opts.dryRun { + b, err := sigsyaml.Marshal(wl) + if err != nil { + return fmt.Errorf("marshal workload: %w", err) + } + _, err = out.Write(b) + return err + } + + c, err := newForemanClient() + if err != nil { + return err + } + if err := c.Create(ctx, wl); err != nil { + return fmt.Errorf("create workload %s/%s: %w", wl.Namespace, wl.Name, err) + } + _, _ = fmt.Fprintf(out, "created Workload %s/%s (%d slices)\n", wl.Namespace, wl.Name, len(plan.Slices)) + return nil +} + +func loadSlicePlan(path string) (slicePlan, error) { + var p slicePlan + if path == "" { + return p, fmt.Errorf("--plan is required") + } + b, err := os.ReadFile(path) + if err != nil { + return p, fmt.Errorf("read plan %s: %w", path, err) + } + if err := yaml.Unmarshal(b, &p); err != nil { + return p, fmt.Errorf("parse plan %s: %w", path, err) + } + return p, nil +} + +// validateSlicePlan enforces the invariants the render relies on: an issue, a +// repo, at least one slice, and DISJOINT files (no file owned by two slices). +func validateSlicePlan(p slicePlan) error { + if p.Issue <= 0 { + return fmt.Errorf("plan has no issue number") + } + if p.Repo == "" { + return fmt.Errorf("plan has no repo") + } + if len(p.Slices) == 0 { + return fmt.Errorf("plan has no slices") + } + owner := map[string]string{} + for _, s := range p.Slices { + if s.Name == "" { + return fmt.Errorf("a slice has no name") + } + for _, f := range s.Files { + if prev, ok := owner[f]; ok && prev != s.Name { + return fmt.Errorf("file %q is owned by both slices %q and %q (slices must be disjoint)", f, prev, s.Name) + } + owner[f] = s.Name + } + } + return nil +} + +// buildSliceWorkload renders the Workload: one issue-fix step per slice, then an +// integrate step (dependsOn every slice), then a reconcile step (dependsOn +// integrate). The integration branch and each slice branch follow the +// foreman/slicer-/ convention. +func buildSliceWorkload(p slicePlan, opts *sliceOptions) *foremanv1alpha1.Workload { + integBranch := fmt.Sprintf("foreman/slicer-%d/integ", p.Issue) + + steps := make([]foremanv1alpha1.PipelineStep, 0, len(p.Slices)+2) + sliceNames := make([]string, 0, len(p.Slices)) + integSlices := make([]foremanv1alpha1.SliceRef, 0, len(p.Slices)) + reconSlices := make([]foremanv1alpha1.SliceRef, 0, len(p.Slices)) + + for _, s := range p.Slices { + branch := fmt.Sprintf("foreman/slicer-%d/%s", p.Issue, s.Name) + steps = append(steps, foremanv1alpha1.PipelineStep{ + Name: s.Name, + Kind: foremanv1alpha1.AgenticTaskKindIssueFix, + AgentRef: corev1.LocalObjectReference{Name: opts.coderAgent}, + Payload: foremanv1alpha1.AgenticTaskPayload{ + Repo: p.Repo, + Issue: p.Issue, + Branch: branch, + BaseBranch: opts.baseBranch, + Prompt: buildSlicePrompt(p, s), + }, + }) + sliceNames = append(sliceNames, s.Name) + integSlices = append(integSlices, foremanv1alpha1.SliceRef{Name: s.Name, Branch: branch, Files: s.Files}) + reconSlices = append(reconSlices, foremanv1alpha1.SliceRef{Name: s.Name, Files: s.Files}) + } + + steps = append(steps, foremanv1alpha1.PipelineStep{ + Name: "integrate", + Kind: foremanv1alpha1.AgenticTaskKindIntegrate, + AgentRef: corev1.LocalObjectReference{Name: opts.integrateAgent}, + Payload: foremanv1alpha1.AgenticTaskPayload{ + Repo: p.Repo, + Branch: integBranch, + BaseBranch: opts.baseBranch, + Slices: integSlices, + }, + DependsOn: sliceNames, + }) + + ids := make([]foremanv1alpha1.SharedIdentifier, 0, len(p.SharedIdentifiers)) + for _, si := range p.SharedIdentifiers { + ids = append(ids, foremanv1alpha1.SharedIdentifier{ + ID: si.ID, + DefinedBy: si.DefinedBy, + ReferencedBy: si.ReferencedBy, + }) + } + steps = append(steps, foremanv1alpha1.PipelineStep{ + Name: "reconcile", + Kind: foremanv1alpha1.AgenticTaskKindReconcile, + AgentRef: corev1.LocalObjectReference{Name: opts.reconcileAgent}, + Payload: foremanv1alpha1.AgenticTaskPayload{ + Repo: p.Repo, + Branch: integBranch, + Slices: reconSlices, + SharedIdentifiers: ids, + Contract: p.Contract, + }, + DependsOn: []string{"integrate"}, + }) + + return &foremanv1alpha1.Workload{ + TypeMeta: metav1.TypeMeta{ + APIVersion: foremanv1alpha1.GroupVersion.String(), + Kind: "Workload", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("slicer-%d", p.Issue), + Namespace: opts.namespace, + }, + Spec: foremanv1alpha1.WorkloadSpec{ + Intent: fmt.Sprintf("slicer: issue #%d in %d slices", p.Issue, len(p.Slices)), + Repo: p.Repo, + Pipeline: steps, + }, + } +} + +// buildSlicePrompt assembles one slice's coder prompt: the file scope, the +// shared contract, and the slice's scoped task. +func buildSlicePrompt(p slicePlan, s planSlice) string { + files := "" + for _, f := range s.Files { + files += " - " + f + "\n" + } + return fmt.Sprintf( + "You are implementing ONE SLICE of issue #%d. Touch ONLY these files "+ + "(create or edit); do not touch any other file:\n%s\n"+ + "Shared contract (all slices agree on this):\n%s\n\n"+ + "Your slice:\n%s\n\n"+ + "When your slice's files are done and verified once, submit_result GO.", + p.Issue, files, p.Contract, s.Task, + ) +} diff --git a/pkg/cli/slice_test.go b/pkg/cli/slice_test.go new file mode 100644 index 00000000..d3c1cd3b --- /dev/null +++ b/pkg/cli/slice_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2025. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cli + +import ( + "bytes" + "context" + "os" + "strings" + "testing" + + foremanv1alpha1 "github.com/defilantech/llmkube/api/foreman/v1alpha1" +) + +func samplePlan() slicePlan { + return slicePlan{ + Issue: 700, + Repo: "defilantech/LLMKube", + Contract: "all slices agree on the metric names", + SharedIdentifiers: []planSharedID{ + {ID: "rocm_smi_gpu_temp", DefinedBy: "exporter", ReferencedBy: []string{"dashboard"}}, + }, + Slices: []planSlice{ + {Name: "exporter", Files: []string{"config/exp.yaml"}, Task: "emit rocm_smi_gpu_temp"}, + {Name: "dashboard", Files: []string{"config/dash.json"}, Task: "query rocm_smi_gpu_temp"}, + }, + } +} + +func defaultOpts() *sliceOptions { + return &sliceOptions{ + namespace: "default", coderAgent: "coder-metal", + integrateAgent: "integrate", reconcileAgent: "reconcile", baseBranch: "main", + } +} + +func TestBuildSliceWorkload_PipelineShape(t *testing.T) { + wl := buildSliceWorkload(samplePlan(), defaultOpts()) + + if wl.Name != "slicer-700" || wl.Namespace != "default" { + t.Fatalf("meta = %s/%s", wl.Namespace, wl.Name) + } + steps := wl.Spec.Pipeline + if len(steps) != 4 { + t.Fatalf("want 4 steps (2 slices + integrate + reconcile), got %d", len(steps)) + } + + // slice steps + for i, name := range []string{"exporter", "dashboard"} { + s := steps[i] + if s.Name != name || s.Kind != foremanv1alpha1.AgenticTaskKindIssueFix { + t.Fatalf("step %d = %s/%s, want %s/issue-fix", i, s.Name, s.Kind, name) + } + wantBranch := "foreman/slicer-700/" + name + if s.Payload.Branch != wantBranch { + t.Errorf("slice %s branch = %q, want %q", name, s.Payload.Branch, wantBranch) + } + if s.AgentRef.Name != "coder-metal" { + t.Errorf("slice %s agent = %q", name, s.AgentRef.Name) + } + if !strings.Contains(s.Payload.Prompt, "config/") || !strings.Contains(s.Payload.Prompt, "ONE SLICE") { + t.Errorf("slice %s prompt missing scope/contract: %q", name, s.Payload.Prompt) + } + } + + // integrate step + integ := steps[2] + if integ.Name != "integrate" || integ.Kind != foremanv1alpha1.AgenticTaskKindIntegrate { + t.Fatalf("integrate step = %s/%s", integ.Name, integ.Kind) + } + if got := integ.DependsOn; len(got) != 2 || got[0] != "exporter" || got[1] != "dashboard" { + t.Errorf("integrate dependsOn = %v, want [exporter dashboard]", got) + } + if len(integ.Payload.Slices) != 2 || integ.Payload.Slices[0].Branch != "foreman/slicer-700/exporter" { + t.Errorf("integrate payload slices wrong: %+v", integ.Payload.Slices) + } + + // reconcile step + rec := steps[3] + if rec.Name != "reconcile" || rec.Kind != foremanv1alpha1.AgenticTaskKindReconcile { + t.Fatalf("reconcile step = %s/%s", rec.Name, rec.Kind) + } + if len(rec.DependsOn) != 1 || rec.DependsOn[0] != "integrate" { + t.Errorf("reconcile dependsOn = %v, want [integrate]", rec.DependsOn) + } + if len(rec.Payload.SharedIdentifiers) != 1 || rec.Payload.SharedIdentifiers[0].ID != "rocm_smi_gpu_temp" { + t.Errorf("reconcile pins wrong: %+v", rec.Payload.SharedIdentifiers) + } + if rec.Payload.Contract == "" || rec.Payload.Branch != "foreman/slicer-700/integ" { + t.Errorf("reconcile payload missing contract/branch: %+v", rec.Payload) + } + // reconcile carries files (for pinned_check), integrate carries branches. + if len(rec.Payload.Slices) != 2 || len(rec.Payload.Slices[0].Files) == 0 { + t.Errorf("reconcile payload slices need files: %+v", rec.Payload.Slices) + } +} + +func TestValidateSlicePlan(t *testing.T) { + valid := samplePlan() + if err := validateSlicePlan(valid); err != nil { + t.Fatalf("valid plan rejected: %v", err) + } + + overlap := samplePlan() + overlap.Slices[1].Files = []string{"config/exp.yaml"} // same file as slice 0 + if err := validateSlicePlan(overlap); err == nil { + t.Error("overlapping files must be rejected") + } + + noIssue := samplePlan() + noIssue.Issue = 0 + if err := validateSlicePlan(noIssue); err == nil { + t.Error("missing issue must be rejected") + } + + noSlices := samplePlan() + noSlices.Slices = nil + if err := validateSlicePlan(noSlices); err == nil { + t.Error("no slices must be rejected") + } +} + +func TestRunSlice_DryRunRendersYAML(t *testing.T) { + dir := t.TempDir() + planFile := dir + "/plan.yaml" + planYAML := `issue: 700 +repo: defilantech/LLMKube +contract: | + agree on names +shared_identifiers: + - id: rocm_smi_gpu_temp + defined_by: exporter + referenced_by: [dashboard] +slices: + - name: exporter + files: [config/exp.yaml] + task: emit the metric + - name: dashboard + files: [config/dash.json] + task: query the metric +` + if err := os.WriteFile(planFile, []byte(planYAML), 0o644); err != nil { + t.Fatal(err) + } + opts := defaultOpts() + opts.planFile = planFile + opts.dryRun = true + + var buf bytes.Buffer + if err := runSlice(context.Background(), &buf, opts); err != nil { + t.Fatalf("runSlice: %v", err) + } + out := buf.String() + wants := []string{"kind: Workload", "name: slicer-700", "kind: integrate", "kind: reconcile", "rocm_smi_gpu_temp"} + for _, want := range wants { + if !strings.Contains(out, want) { + t.Errorf("dry-run output missing %q:\n%s", want, out) + } + } +}