diff --git a/cmd/foreman-agent/main.go b/cmd/foreman-agent/main.go index 46d6b206..5cc28889 100644 --- a/cmd/foreman-agent/main.go +++ b/cmd/foreman-agent/main.go @@ -755,6 +755,13 @@ func makeRegistryFactory( Workspace: workspace, Token: repo.TokenFromEnvOrFile, }, + // run_reconcile: deterministic tool for a sliced Workload's + // reconcile step. Checks the integrated union against the pinned + // shared identifiers for cross-slice interface drift (#1033). + &foremantools.RunReconcileTool{ + Workspace: workspace, + Token: repo.TokenFromEnvOrFile, + }, } var mcpTools []foremantools.Tool diff --git a/pkg/foreman/agent/tools/run_reconcile.go b/pkg/foreman/agent/tools/run_reconcile.go new file mode 100644 index 00000000..acc63dac --- /dev/null +++ b/pkg/foreman/agent/tools/run_reconcile.go @@ -0,0 +1,167 @@ +/* +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 tools + +import ( + "context" + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/defilantech/llmkube/pkg/foreman/agent" + "github.com/defilantech/llmkube/pkg/foreman/agent/oai" + "github.com/defilantech/llmkube/pkg/foreman/agent/repo" + "github.com/defilantech/llmkube/pkg/foreman/slicer" +) + +// RunReconcileTool checks a sliced Workload's integrated union against the slice +// plan's pinned shared identifiers, catching cross-slice interface drift a +// build cannot see. It runs on the deterministic (no-LLM) path: the executor +// dispatches it and its Terminal verdict drives the AgenticTask status. Part of +// Sliced Workloads (#1033). +// +// v1 runs only the deterministic, authoritative pinned_check. The advisory +// llm_sweep (slicer.LLMSweep) needs a model endpoint the deterministic path +// does not resolve; wiring it is a follow-up. +type RunReconcileTool struct { + // Workspace is the cloned working tree the integration branch is checked + // out into so pinned_check can read the union's files. + Workspace string + // Token sources the git token for fetching the integration branch. A nil + // Token skips auth (public repos, tests against local remotes). + Token func() (string, error) +} + +type reconcileSharedID struct { + ID string `json:"id"` + DefinedBy string `json:"definedBy"` + ReferencedBy []string `json:"referencedBy"` +} + +type reconcileSlice struct { + Name string `json:"name"` + Files []string `json:"files"` +} + +type reconcileArgs struct { + Branch string `json:"branch"` + Slices []reconcileSlice `json:"slices"` + SharedIdentifiers []reconcileSharedID `json:"sharedIdentifiers"` +} + +func (RunReconcileTool) Name() string { return "run_reconcile" } + +// Schema advertises the tool. The deterministic executor path never shows this +// to a model, but a future LLM-driven wrapper would, so it stays faithful. +func (RunReconcileTool) Schema() oai.ToolSchemaDef { + return oai.ToolSchemaDef{ + Name: "run_reconcile", + Description: "Check the integrated union of a sliced workload against the pinned shared " + + "identifiers. Returns GATE-PASS when every pinned identifier is present in the slices " + + "that define or reference it, GATE-FAIL when one is missing (cross-slice interface " + + "drift), or GATE-ERROR on a fetch failure.", + Parameters: json.RawMessage(`{ +"type": "object", +"properties": { + "branch": {"type": "string", "description": "the integration branch to check"}, + "slices": {"type": "array", "items": {"type": "object", "properties": { + "name": {"type": "string"}, + "files": {"type": "array", "items": {"type": "string"}} + }}}, + "sharedIdentifiers": {"type": "array", "items": {"type": "object", "properties": { + "id": {"type": "string"}, + "definedBy": {"type": "string"}, + "referencedBy": {"type": "array", "items": {"type": "string"}} + }}} +}, +"required": ["branch"] +}`), + } +} + +// Execute fetches + checks out the integration branch and runs pinned_check +// against it. It never returns a non-nil error for a content/transport failure: +// those map to a Terminal verdict so the AgenticTask reaches a terminal state. +func (t *RunReconcileTool) Execute(ctx context.Context, raw json.RawMessage) (*agent.ToolResult, error) { + var a reconcileArgs + if err := json.Unmarshal(raw, &a); err != nil { + return t.result(VerdictGateError, "bad args: "+err.Error(), nil), nil + } + if !refSafe(a.Branch) { + return t.result(VerdictGateError, fmt.Sprintf("unsafe or empty integration branch %q", a.Branch), nil), nil + } + + var authEnv []string + if t.Token != nil { + if tok, err := t.Token(); err == nil && tok != "" { + if auth, aerr := repo.NewAuth(tok); aerr == nil { + authEnv = auth.Env() + } + } + } + + // Fetch the integration branch (pushed by the integrate step) and check it + // out so pinned_check reads the union's files from the workspace. + if err := t.runGit(ctx, authEnv, "fetch", "--end-of-options", "origin", a.Branch); err != nil { + return t.result(VerdictGateError, "fetch integration branch: "+err.Error(), nil), nil + } + if err := t.runGit(ctx, nil, "checkout", "--force", "--end-of-options", "FETCH_HEAD"); err != nil { + return t.result(VerdictGateError, "checkout integration branch: "+err.Error(), nil), nil + } + + sliceFiles := make(map[string][]string, len(a.Slices)) + for _, s := range a.Slices { + sliceFiles[s.Name] = s.Files + } + ids := make([]slicer.SharedIdentifier, 0, len(a.SharedIdentifiers)) + for _, si := range a.SharedIdentifiers { + ids = append(ids, slicer.SharedIdentifier{ + ID: si.ID, + DefinedBy: si.DefinedBy, + ReferencedBy: si.ReferencedBy, + }) + } + + drifts := slicer.PinnedCheck(ids, t.Workspace, sliceFiles) + if len(drifts) == 0 { + return t.result(VerdictGatePass, + fmt.Sprintf("union interface-consistent: %d pinned identifiers present", len(ids)), nil), nil + } + return t.result(VerdictGateFail, + fmt.Sprintf("interface drift: %d pinned identifier(s) missing from the slices that must carry them", len(drifts)), + drifts), nil +} + +// runGit shells one git subcommand in the workspace with the extra env. +func (t *RunReconcileTool) runGit(ctx context.Context, extraEnv []string, args ...string) error { + cmd := exec.CommandContext(ctx, "git", args...) + cmd.Dir = t.Workspace + cmd.Env = append(cmd.Environ(), extraEnv...) + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out))) + } + return nil +} + +func (t *RunReconcileTool) result(verdict, summary string, drifts []slicer.Drift) *agent.ToolResult { + out := map[string]any{"summary": summary} + if drifts != nil { + out["drifts"] = drifts + } + return &agent.ToolResult{Terminal: true, Verdict: verdict, Summary: summary, Output: out} +} diff --git a/pkg/foreman/agent/tools/run_reconcile_test.go b/pkg/foreman/agent/tools/run_reconcile_test.go new file mode 100644 index 00000000..5f402f54 --- /dev/null +++ b/pkg/foreman/agent/tools/run_reconcile_test.go @@ -0,0 +1,109 @@ +/* +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 tools + +import ( + "context" + "encoding/json" + "path/filepath" + "testing" +) + +// reconcileFixture builds a bare origin with an integration branch that holds +// the given files, and clones it into a workspace. Returns the workspace path. +func reconcileFixture(t *testing.T, integBranch string, files map[string]string) string { + t.Helper() + root := t.TempDir() + origin := filepath.Join(root, "origin.git") + git(t, root, "init", "-q", "--bare", "-b", "main", origin) + + seed := filepath.Join(root, "seed") + git(t, root, "clone", "-q", origin, seed) + git(t, seed, "config", "user.email", "t@e.com") + git(t, seed, "config", "user.name", "T") + git(t, seed, "config", "commit.gpgsign", "false") + git(t, seed, "checkout", "-q", "-B", integBranch) + for p, c := range files { + writeFile(t, seed, p, c) + } + git(t, seed, "add", "-A") + git(t, seed, "commit", "-qm", "union") + git(t, seed, "push", "-q", "origin", integBranch) + + ws := filepath.Join(root, "ws") + git(t, root, "clone", "-q", origin, ws) + return ws +} + +func TestRunReconcile_CleanIsGatePass(t *testing.T) { + ws := reconcileFixture(t, "foreman/s/integ", map[string]string{ + "config/exp.yaml": "emit rocm_smi_gpu_temp here", + "config/dash.json": `{"expr":"rocm_smi_gpu_temp"}`, + }) + tool := &RunReconcileTool{Workspace: ws} + args, _ := json.Marshal(map[string]any{ + "branch": "foreman/s/integ", + "slices": []map[string]any{ + {"name": "exp", "files": []string{"config/exp.yaml"}}, + {"name": "dash", "files": []string{"config/dash.json"}}, + }, + "sharedIdentifiers": []map[string]any{ + {"id": "rocm_smi_gpu_temp", "definedBy": "exp", "referencedBy": []string{"dash"}}, + }, + }) + res, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Verdict != VerdictGatePass { + t.Fatalf("verdict = %q (%s), want GATE-PASS", res.Verdict, res.Summary) + } +} + +func TestRunReconcile_DriftIsGateFail(t *testing.T) { + // the exporter emits a DIFFERENT metric than the dashboard queries. + ws := reconcileFixture(t, "foreman/s/integ", map[string]string{ + "config/exp.yaml": "emit rocm_smi_temperature_edge here", + "config/dash.json": `{"expr":"rocm_smi_gpu_temp"}`, + }) + tool := &RunReconcileTool{Workspace: ws} + args, _ := json.Marshal(map[string]any{ + "branch": "foreman/s/integ", + "slices": []map[string]any{ + {"name": "exp", "files": []string{"config/exp.yaml"}}, + {"name": "dash", "files": []string{"config/dash.json"}}, + }, + "sharedIdentifiers": []map[string]any{ + {"id": "rocm_smi_gpu_temp", "definedBy": "exp", "referencedBy": []string{"dash"}}, + }, + }) + res, err := tool.Execute(context.Background(), args) + if err != nil { + t.Fatalf("Execute: %v", err) + } + if res.Verdict != VerdictGateFail { + t.Fatalf("verdict = %q (%s), want GATE-FAIL for drift", res.Verdict, res.Summary) + } +} + +func TestRunReconcile_ArgvInjectionRejected(t *testing.T) { + tool := &RunReconcileTool{Workspace: t.TempDir()} + res, _ := tool.Execute(context.Background(), json.RawMessage(`{"branch":"--upload-pack=x"}`)) + if res.Verdict != VerdictGateError { + t.Fatalf("verdict = %q, want GATE-ERROR for option-looking branch", res.Verdict) + } +}