From fae40335f0f0a107dbd7bfaa50ac851dff33a1c0 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan Date: Tue, 21 Jul 2026 16:06:57 -0400 Subject: [PATCH 1/8] Rewrite harness as thin single-stage runner with SkillCard-based skills Replace multi-step CLI harness with thin ACP wrapper that runs a single migration stage (plan/execute/verify) per pod invocation, managed by the AgentPlaybookRun reconciler. Add per-stage skill OCI images, full javaee-to-quarkus skill from migration-skills repo (SKILL.md + 6 modules + 6 references), handoff.md generation with agent summaries and plan steps, filesystem watcher for incremental commits, and e2e test scripts with timestamped branch names. Assisted-By: Claude Code Signed-off-by: Savitha Raghunathan --- Makefile | 40 +- config/samples/kustomization.yaml | 2 + .../samples/skillcard_javaee_to_quarkus.yaml | 18 + .../skillcard_javax_to_jakarta_ee.yaml | 18 + .../skillcollection_java_migration.yaml | 6 +- .../2026-07-15-harness-agentplaybookrun.md | 1701 +++++++++++++++++ ...6-07-15-harness-agentplaybookrun-design.md | 343 ++++ hack/harness-test/playbook-resources.yaml | 173 ++ hack/harness-test/resources.yaml | 46 +- hack/harness-test/setup.sh | 79 +- harness/cmd/migration-harness/main.go | 620 +++--- harness/go.mod | 4 +- harness/go.sum | 2 + harness/internal/acp/session.go | 28 +- harness/internal/config/config.go | 99 - harness/internal/config/config_test.go | 155 -- harness/internal/detect/detect.go | 251 --- harness/internal/detect/detect_test.go | 157 -- harness/internal/execute/execute.go | 234 --- harness/internal/execute/execute_test.go | 117 -- harness/internal/fixloop/fixloop.go | 206 -- harness/internal/fixloop/fixloop_test.go | 78 - harness/internal/git/credentials.go | 3 +- harness/internal/git/git.go | 36 +- harness/internal/git/git_test.go | 45 +- harness/internal/goose/acp_runner.go | 202 -- harness/internal/goose/acp_runner_test.go | 96 - harness/internal/goose/goose.go | 12 - harness/internal/goose/lifecycle.go | 20 +- harness/internal/goose/recipe.go | 139 -- harness/internal/goose/recipe_test.go | 118 -- harness/internal/handoff/handoff.go | 203 -- harness/internal/handoff/handoff_test.go | 150 -- harness/internal/metrics/metrics.go | 91 - harness/internal/metrics/metrics_test.go | 111 -- harness/internal/plan/approval.go | 72 - harness/internal/plan/context.go | 147 -- harness/internal/plan/context_test.go | 105 - harness/internal/plan/hints.go | 55 - harness/internal/plan/hints_test.go | 68 - harness/internal/plan/parser.go | 246 --- harness/internal/plan/parser_test.go | 193 -- harness/internal/plan/plan.go | 129 -- harness/internal/plan/recipe.go | 113 -- harness/internal/plan/turns.go | 102 - harness/internal/plan/turns_test.go | 79 - harness/internal/plan/types.go | 17 - harness/internal/rundir/rundir.go | 52 - harness/internal/rundir/rundir_test.go | 52 - harness/internal/verify/verify.go | 158 -- harness/internal/verify/verify_test.go | 75 - harness/internal/watcher/patterns.go | 42 + harness/internal/watcher/patterns_test.go | 32 + harness/internal/watcher/watcher.go | 197 ++ harness/internal/watcher/watcher_test.go | 112 ++ harness/recipes/execute.yaml | 105 - harness/recipes/fix.yaml | 79 - harness/recipes/verify.yaml | 104 - harness/skill-bundle/goose-migration/SKILL.md | 85 - .../goose-migration/references/README.md | 301 --- .../references/REFERENCES_SUMMARY.md | 324 ---- .../references/dotnet-framework-to-core.md | 926 --------- .../references/javaee-quarkus.md | 337 ---- .../references/python2-to-python3.md | 579 ------ .../references/springboot-2-to-3.md | 554 ------ .../skills/javaee-quarkus/SKILL.md | 142 -- .../MIGRATION_PLAN_SKILL_UPDATES.md | 236 --- .../skills/python2-to-python3/SKILL.md | 71 - images/agent-base-goose-java/Containerfile | 87 - images/agent-base/Containerfile | 52 + images/agent-execute/Containerfile | 4 + images/agent-java-base/Containerfile | 24 + images/agent-plan/Containerfile | 25 + images/agent-verify/Containerfile | 4 + skills/execute/SKILL.md | 80 + skills/execute/skill.yaml | 15 + skills/javaee-to-quarkus/SKILL.md | 44 + .../javaee-to-quarkus/modules/app-config.md | 68 + .../javaee-to-quarkus/modules/build-config.md | 100 + skills/javaee-to-quarkus/modules/cleanup.md | 61 + .../javaee-to-quarkus/modules/ejb-to-cdi.md | 91 + skills/javaee-to-quarkus/modules/lifecycle.md | 72 + skills/javaee-to-quarkus/modules/messaging.md | 100 + .../references/annotation-map.md | 56 + .../references/config-map.md | 76 + .../references/dependency-map.md | 59 + .../references/pattern-map.md | 168 ++ .../javaee-to-quarkus/references/sources.md | 21 + .../references/verify-errors.md | 59 + skills/plan/SKILL.md | 257 +++ .../plan}/references/migration-phases.md | 0 .../plan/references/migration-plan-skill.md | 22 +- skills/plan/skill.yaml | 15 + skills/verify/SKILL.md | 102 + skills/verify/skill.yaml | 14 + 95 files changed, 4675 insertions(+), 8493 deletions(-) create mode 100644 config/samples/skillcard_javaee_to_quarkus.yaml create mode 100644 config/samples/skillcard_javax_to_jakarta_ee.yaml create mode 100644 docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md create mode 100644 docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md create mode 100644 hack/harness-test/playbook-resources.yaml delete mode 100644 harness/internal/detect/detect.go delete mode 100644 harness/internal/detect/detect_test.go delete mode 100644 harness/internal/execute/execute.go delete mode 100644 harness/internal/execute/execute_test.go delete mode 100644 harness/internal/fixloop/fixloop.go delete mode 100644 harness/internal/fixloop/fixloop_test.go delete mode 100644 harness/internal/goose/acp_runner.go delete mode 100644 harness/internal/goose/acp_runner_test.go delete mode 100644 harness/internal/goose/goose.go delete mode 100644 harness/internal/goose/recipe.go delete mode 100644 harness/internal/goose/recipe_test.go delete mode 100644 harness/internal/handoff/handoff.go delete mode 100644 harness/internal/handoff/handoff_test.go delete mode 100644 harness/internal/metrics/metrics.go delete mode 100644 harness/internal/metrics/metrics_test.go delete mode 100644 harness/internal/plan/approval.go delete mode 100644 harness/internal/plan/context.go delete mode 100644 harness/internal/plan/context_test.go delete mode 100644 harness/internal/plan/hints.go delete mode 100644 harness/internal/plan/hints_test.go delete mode 100644 harness/internal/plan/parser.go delete mode 100644 harness/internal/plan/parser_test.go delete mode 100644 harness/internal/plan/plan.go delete mode 100644 harness/internal/plan/recipe.go delete mode 100644 harness/internal/plan/turns.go delete mode 100644 harness/internal/plan/turns_test.go delete mode 100644 harness/internal/plan/types.go delete mode 100644 harness/internal/rundir/rundir.go delete mode 100644 harness/internal/rundir/rundir_test.go delete mode 100644 harness/internal/verify/verify.go delete mode 100644 harness/internal/verify/verify_test.go create mode 100644 harness/internal/watcher/patterns.go create mode 100644 harness/internal/watcher/patterns_test.go create mode 100644 harness/internal/watcher/watcher.go create mode 100644 harness/internal/watcher/watcher_test.go delete mode 100644 harness/recipes/execute.yaml delete mode 100644 harness/recipes/fix.yaml delete mode 100644 harness/recipes/verify.yaml delete mode 100644 harness/skill-bundle/goose-migration/SKILL.md delete mode 100644 harness/skill-bundle/goose-migration/references/README.md delete mode 100644 harness/skill-bundle/goose-migration/references/REFERENCES_SUMMARY.md delete mode 100644 harness/skill-bundle/goose-migration/references/dotnet-framework-to-core.md delete mode 100644 harness/skill-bundle/goose-migration/references/javaee-quarkus.md delete mode 100644 harness/skill-bundle/goose-migration/references/python2-to-python3.md delete mode 100644 harness/skill-bundle/goose-migration/references/springboot-2-to-3.md delete mode 100644 harness/skill-bundle/goose-migration/skills/javaee-quarkus/SKILL.md delete mode 100644 harness/skill-bundle/goose-migration/skills/migration-plan/MIGRATION_PLAN_SKILL_UPDATES.md delete mode 100644 harness/skill-bundle/goose-migration/skills/python2-to-python3/SKILL.md delete mode 100644 images/agent-base-goose-java/Containerfile create mode 100644 images/agent-base/Containerfile create mode 100644 images/agent-execute/Containerfile create mode 100644 images/agent-java-base/Containerfile create mode 100644 images/agent-plan/Containerfile create mode 100644 images/agent-verify/Containerfile create mode 100644 skills/execute/SKILL.md create mode 100644 skills/execute/skill.yaml create mode 100644 skills/javaee-to-quarkus/SKILL.md create mode 100644 skills/javaee-to-quarkus/modules/app-config.md create mode 100644 skills/javaee-to-quarkus/modules/build-config.md create mode 100644 skills/javaee-to-quarkus/modules/cleanup.md create mode 100644 skills/javaee-to-quarkus/modules/ejb-to-cdi.md create mode 100644 skills/javaee-to-quarkus/modules/lifecycle.md create mode 100644 skills/javaee-to-quarkus/modules/messaging.md create mode 100644 skills/javaee-to-quarkus/references/annotation-map.md create mode 100644 skills/javaee-to-quarkus/references/config-map.md create mode 100644 skills/javaee-to-quarkus/references/dependency-map.md create mode 100644 skills/javaee-to-quarkus/references/pattern-map.md create mode 100644 skills/javaee-to-quarkus/references/sources.md create mode 100644 skills/javaee-to-quarkus/references/verify-errors.md create mode 100644 skills/plan/SKILL.md rename {harness/skill-bundle/goose-migration => skills/plan}/references/migration-phases.md (100%) rename harness/skill-bundle/goose-migration/skills/migration-plan/SKILL.md => skills/plan/references/migration-plan-skill.md (95%) create mode 100644 skills/plan/skill.yaml create mode 100644 skills/verify/SKILL.md create mode 100644 skills/verify/skill.yaml diff --git a/Makefile b/Makefile index 8da8633..e30e334 100644 --- a/Makefile +++ b/Makefile @@ -100,7 +100,11 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration ##@ Build CONTROLLER_AGENT_IMG ?= quay.io/konveyor/agentic-controller-agent:latest -AGENT_JAVA_GOOSE_IMG ?= quay.io/konveyor/agent-base-goose-java:latest +AGENT_BASE_IMG ?= quay.io/konveyor/agent-base:latest +AGENT_JAVA_BASE_IMG ?= quay.io/konveyor/agent-java-base:latest +AGENT_PLAN_IMG ?= quay.io/konveyor/agent-plan:latest +AGENT_EXECUTE_IMG ?= quay.io/konveyor/agent-execute:latest +AGENT_VERIFY_IMG ?= quay.io/konveyor/agent-verify:latest .PHONY: build build: manifests generate fmt vet ## Build manager binary. @@ -114,13 +118,35 @@ controller-agent-build: ## Build the controller's test/verification agent image. controller-agent-push: controller-agent-build ## Build and push the controller's test/verification agent image. $(CONTAINER_TOOL) push $(CONTROLLER_AGENT_IMG) -.PHONY: agent-java-goose-build -agent-java-goose-build: ## Build the Java migration agent image (Goose + JDK 21 + harness). - $(CONTAINER_TOOL) build -t $(AGENT_JAVA_GOOSE_IMG) -f images/agent-base-goose-java/Containerfile . +.PHONY: agent-base-build +agent-base-build: ## Build the base agent image (goose + git + harness binary). + $(CONTAINER_TOOL) build -t $(AGENT_BASE_IMG) -f images/agent-base/Containerfile . -.PHONY: agent-java-goose-push -agent-java-goose-push: agent-java-goose-build ## Build and push the Java migration agent image. - $(CONTAINER_TOOL) push $(AGENT_JAVA_GOOSE_IMG) +.PHONY: agent-java-base-build +agent-java-base-build: agent-base-build ## Build the shared Java base image (JDK 21 + Maven). + $(CONTAINER_TOOL) build -t $(AGENT_JAVA_BASE_IMG) -f images/agent-java-base/Containerfile . + +.PHONY: agent-plan-build +agent-plan-build: agent-base-build ## Build the plan stage agent image. + $(CONTAINER_TOOL) build -t $(AGENT_PLAN_IMG) -f images/agent-plan/Containerfile . + +.PHONY: agent-execute-build +agent-execute-build: agent-java-base-build ## Build the execute stage agent image. + $(CONTAINER_TOOL) build -t $(AGENT_EXECUTE_IMG) -f images/agent-execute/Containerfile . + +.PHONY: agent-verify-build +agent-verify-build: agent-java-base-build ## Build the verify stage agent image. + $(CONTAINER_TOOL) build -t $(AGENT_VERIFY_IMG) -f images/agent-verify/Containerfile . + +.PHONY: agent-images-build +agent-images-build: agent-plan-build agent-execute-build agent-verify-build ## Build all stage agent images. + +.PHONY: agent-images-push +agent-images-push: agent-images-build ## Build and push all stage agent images. + $(CONTAINER_TOOL) push $(AGENT_BASE_IMG) + $(CONTAINER_TOOL) push $(AGENT_PLAN_IMG) + $(CONTAINER_TOOL) push $(AGENT_EXECUTE_IMG) + $(CONTAINER_TOOL) push $(AGENT_VERIFY_IMG) .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/config/samples/kustomization.yaml b/config/samples/kustomization.yaml index d462498..394b941 100644 --- a/config/samples/kustomization.yaml +++ b/config/samples/kustomization.yaml @@ -2,8 +2,10 @@ # These provide out-of-the-box migration skills backed by # OCI images pushed to quay.io/konveyor/skills/. resources: + - skillcard_javax_to_jakarta_ee.yaml - skillcard_maven_migration.yaml - skillcard_no_javax_imports.yaml - skillcard_ejb_to_cdi.yaml + - skillcard_javaee_to_quarkus.yaml - skillcollection_java_migration.yaml #+kubebuilder:scaffold:manifestskustomizesamples diff --git a/config/samples/skillcard_javaee_to_quarkus.yaml b/config/samples/skillcard_javaee_to_quarkus.yaml new file mode 100644 index 0000000..fabc30f --- /dev/null +++ b/config/samples/skillcard_javaee_to_quarkus.yaml @@ -0,0 +1,18 @@ +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javaee-to-quarkus +spec: + image: quay.io/konveyor/skills:javaee-to-quarkus + displayName: Java EE to Quarkus Migration + version: "1.0.0" + description: > + Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. + Covers EJB-to-CDI, JMS/MDB-to-SmallRye Reactive Messaging, WAR-to-JAR packaging, + persistence.xml-to-application.properties, JNDI removal, and server lifecycle replacement. + type: skill + tags: + - java + - javaee + - quarkus + - migration diff --git a/config/samples/skillcard_javax_to_jakarta_ee.yaml b/config/samples/skillcard_javax_to_jakarta_ee.yaml new file mode 100644 index 0000000..9c52d42 --- /dev/null +++ b/config/samples/skillcard_javax_to_jakarta_ee.yaml @@ -0,0 +1,18 @@ +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javax-to-jakarta-ee +spec: + image: quay.io/konveyor/skills:javax-to-jakarta-ee + displayName: javax to Jakarta EE Migration + version: "1.0.0" + description: > + Migrates Java EE 8 (javax) applications to Jakarta EE 9/10 (jakarta). + Covers dependency changes, package-level renames, XML namespace updates, + ServiceLoader file renames, and removed specifications. + type: skill + tags: + - java + - javaee + - jakarta + - migration diff --git a/config/samples/skillcollection_java_migration.yaml b/config/samples/skillcollection_java_migration.yaml index 507ce2e..ea0e720 100644 --- a/config/samples/skillcollection_java_migration.yaml +++ b/config/samples/skillcollection_java_migration.yaml @@ -3,11 +3,15 @@ kind: SkillCollection metadata: name: java-migration-skills spec: - version: "1.0.0" + version: "2.0.0" skills: + - name: javax-to-jakarta-ee + skillCardRef: javax-to-jakarta-ee - name: maven-migration skillCardRef: maven-migration - name: no-javax-imports skillCardRef: no-javax-imports - name: ejb-to-cdi skillCardRef: ejb-to-cdi + - name: javaee-to-quarkus + skillCardRef: javaee-to-quarkus diff --git a/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md b/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md new file mode 100644 index 0000000..211d5ca --- /dev/null +++ b/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md @@ -0,0 +1,1701 @@ +# Harness + AgentPlaybookRun Integration — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Restructure the migration harness from a monolithic 5-stage orchestrator into a thin, uniform git plumbing wrapper so that AgentPlaybookRun sequences stages and goose skills carry migration intelligence. + +**Architecture:** The harness binary becomes identical in every stage image: git clone → goose serve → single ACP prompt → filesystem watcher → final commit+push → exit. The AgentPlaybookRun controller creates one AgentRun per stage sequentially. Each stage image bakes in exactly one skill at `/opt/skills//SKILL.md`. A new `watcher` package uses fsnotify to commit+push progress in the background while goose works. + +**Tech Stack:** Go 1.26, fsnotify, go-git/v5, cobra, gorilla/websocket, Containerfile (UBI 10), YAML CRDs + +**Spec:** `docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md` + +## Global Constraints + +- Go 1.26.2 (harness), Go 1.24.2 (controller — per go.mod) +- fsnotify must be added to `harness/go.mod` +- Git credentials must never be visible to goose — harness only +- Skills live at `/opt/skills//SKILL.md` — exactly one per image +- `git add -A` is forbidden in the watcher; use `git add -u` + known patterns +- Java-only scope — no other language images in this plan +- Existing controller and ACP tests must continue to pass + +--- + +## File Structure + +### New files + +| File | Responsibility | +|---|---| +| `harness/internal/watcher/watcher.go` | Filesystem watcher: fsnotify → quiet-period detection → git add/commit/push | +| `harness/internal/watcher/watcher_test.go` | Unit tests for watcher | +| `harness/internal/watcher/patterns.go` | Known source file patterns for safe staging | +| `harness/internal/watcher/patterns_test.go` | Tests for pattern matching | +| `images/agent-base/Containerfile` | Base image: UBI + goose + git + harness binary | +| `images/agent-plan/Containerfile` | Plan image: agent-base + graphify | +| `images/agent-execute-java/Containerfile` | Execute image: agent-base + JDK 21 + Maven | +| `images/agent-verify-java/Containerfile` | Verify image: agent-base + JDK 21 + Maven | +| `skills/plan/SKILL.md` | Plan skill (detect+plan) | +| `skills/execute-java/SKILL.md` | Java execute skill | +| `skills/execute-java/references/javaee-quarkus.md` | Java EE → Quarkus reference (copied from existing) | +| `skills/verify-java/SKILL.md` | Java verify+fix skill | +| `hack/harness-test/playbook-resources.yaml` | Example AgentPlaybook + AgentPlaybookRun CRs | + +### Modified files + +| File | What changes | +|---|---| +| `api/v1alpha1/agentplaybookrun_types.go` | Add `TargetBranch` field to spec | +| `internal/controller/agentplaybookrun_controller.go` | Inject `GIT_TARGET_BRANCH` env var | +| `harness/cmd/migration-harness/main.go` | Gut to single `run` command with thin wrapper flow | +| `harness/internal/config/config.go` | Remove file-based config, keep `LoadFromEnv` | +| `harness/go.mod` | Add fsnotify dependency | + +### Deleted files/directories + +| Path | Reason | +|---|---| +| `harness/internal/detect/` | Logic moves to plan skill | +| `harness/internal/plan/` | Logic moves to plan skill | +| `harness/internal/execute/` | Logic moves to execute skill | +| `harness/internal/verify/` | Logic moves to verify skill | +| `harness/internal/fixloop/` | Logic moves to verify skill | +| `harness/internal/handoff/` | Controller tracks stage status | +| `harness/internal/metrics/` | Controller records timing | +| `harness/internal/rundir/` | No multi-run tracking needed | +| `harness/internal/goose/goose.go` | Runner interface no longer needed | +| `harness/internal/goose/acp_runner.go` | Multi-turn recipe runner no longer needed | +| `harness/internal/goose/acp_runner_test.go` | Tests for deleted code | +| `harness/internal/goose/recipe.go` | Recipe system no longer needed | +| `harness/internal/goose/recipe_test.go` | Tests for deleted code | +| `harness/recipes/` | Recipe content folded into skills | +| `harness/skill-bundle/` | Skills now baked into per-stage images | +| `images/agent-base-goose-java/Containerfile` | Replaced by 4 new Containerfiles | + +--- + +### Task 1: Make GIT_TARGET_BRANCH required (remove fallback) + +**Files:** +- Modify: `harness/internal/git/credentials.go` + +**Interfaces:** +- Consumes: `GIT_TARGET_BRANCH` env var (set by user in `AgentPlaybookRun.spec.env`) +- Produces: error if `GIT_TARGET_BRANCH` is not set (previously fell back to timestamp) + +**Decision:** `GIT_TARGET_BRANCH` is a plain env var passed through `spec.env` on the AgentPlaybookRun. The controller already forwards `pbRun.Spec.Env` to every child AgentRun. No CRD field needed. If the user forgets to set it, the harness fails immediately. + +- [x] **Step 1: Remove timestamp fallback in credentials.go** + +In `harness/internal/git/credentials.go`, replace the fallback branch generation with an error: + +```go +branch := os.Getenv("GIT_TARGET_BRANCH") +if branch == "" { + return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TARGET_BRANCH is missing") +} +``` + +Remove the unused `time` import. + +- [x] **Step 2: Verify build** + +Run: `cd harness && go build ./... && go vet ./...` + +--- + +### Task 2: Create filesystem watcher package + +**Files:** +- Create: `harness/internal/watcher/patterns.go` +- Create: `harness/internal/watcher/patterns_test.go` +- Create: `harness/internal/watcher/watcher.go` +- Create: `harness/internal/watcher/watcher_test.go` +- Modify: `harness/go.mod` (add fsnotify) + +**Interfaces:** +- Consumes: `git.CommitAll(repo, msg)`, `git.Push(ctx, creds, repo, branch)` from `harness/internal/git` +- Produces: `watcher.New(dir string, commitFn func() error) *Watcher`, `(*Watcher).Start()`, `(*Watcher).Stop()` + +- [ ] **Step 1: Add fsnotify dependency** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go get github.com/fsnotify/fsnotify` + +Expected: fsnotify added to go.mod and go.sum + +- [ ] **Step 2: Write pattern matching (patterns.go)** + +```go +package watcher + +import ( + "path/filepath" + "strings" +) + +var sourceExts = map[string]bool{ + ".java": true, ".xml": true, ".properties": true, + ".md": true, ".json": true, ".yaml": true, ".yml": true, + ".gradle": true, ".kt": true, ".groovy": true, +} + +var excludeDirs = map[string]bool{ + ".goose": true, "__pycache__": true, ".git": true, + "node_modules": true, "target": true, +} + +var excludeExts = map[string]bool{ + ".tmp": true, ".swp": true, ".bak": true, +} + +func ShouldStageNewFile(path string) bool { + base := filepath.Base(path) + + if base == "pom.xml" || base == "result.json" { + return true + } + + for _, part := range strings.Split(filepath.Dir(path), string(filepath.Separator)) { + if excludeDirs[part] { + return false + } + } + + ext := filepath.Ext(base) + if excludeExts[ext] { + return false + } + + return sourceExts[ext] +} +``` + +- [ ] **Step 3: Write pattern tests (patterns_test.go)** + +```go +package watcher + +import "testing" + +func TestShouldStageNewFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"src/main/java/com/example/App.java", true}, + {"pom.xml", true}, + {"src/main/resources/application.properties", true}, + {".konveyor/result.json", true}, + {"PLAN.md", true}, + {"graph.json", true}, + {".goose/cache/foo.txt", false}, + {"__pycache__/mod.pyc", false}, + {"target/classes/App.class", false}, + {"scratch.tmp", false}, + {"file.swp", false}, + {"random.txt", false}, + {"src/main/java/.goose/internal.java", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := ShouldStageNewFile(tt.path); got != tt.want { + t.Errorf("ShouldStageNewFile(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} +``` + +- [ ] **Step 4: Run pattern tests** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./internal/watcher/ -run TestShouldStage -v` + +Expected: PASS + +- [ ] **Step 5: Write the watcher (watcher.go)** + +```go +package watcher + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/konveyor/migration-harness/internal/logging" +) + +const quietPeriod = 30 * time.Second + +type CommitPushFn func() error + +type Watcher struct { + dir string + commitFn CommitPushFn + fsw *fsnotify.Watcher + cancel context.CancelFunc + wg sync.WaitGroup +} + +func New(dir string, commitFn CommitPushFn) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + return &Watcher{ + dir: dir, + commitFn: commitFn, + fsw: fsw, + }, nil +} + +func (w *Watcher) Start(ctx context.Context) error { + if err := w.addDirRecursive(w.dir); err != nil { + return err + } + + ctx, w.cancel = context.WithCancel(ctx) + w.wg.Add(1) + go w.loop(ctx) + logging.Info("filesystem watcher started (quiet period: %s)", quietPeriod) + return nil +} + +func (w *Watcher) Stop() { + if w.cancel != nil { + w.cancel() + } + w.wg.Wait() + w.fsw.Close() +} + +func (w *Watcher) loop(ctx context.Context) { + defer w.wg.Done() + timer := time.NewTimer(quietPeriod) + timer.Stop() + dirty := false + + for { + select { + case <-ctx.Done(): + timer.Stop() + return + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) == 0 { + continue + } + rel, err := filepath.Rel(w.dir, event.Name) + if err != nil { + continue + } + if !isRelevantChange(rel) { + continue + } + if event.Op&fsnotify.Create != 0 { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + w.fsw.Add(event.Name) + } + } + dirty = true + timer.Reset(quietPeriod) + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + logging.Warn("watcher error: %v", err) + case <-timer.C: + if dirty { + w.doCommit() + dirty = false + } + } + } +} + +func isRelevantChange(relPath string) bool { + for _, part := range strings.Split(filepath.Dir(relPath), string(filepath.Separator)) { + if excludeDirs[part] { + return false + } + } + base := filepath.Base(relPath) + ext := filepath.Ext(base) + return !excludeExts[ext] +} + +func (w *Watcher) doCommit() { + if err := w.stageFiles(); err != nil { + logging.Warn("watcher stage: %v", err) + return + } + if err := w.commitFn(); err != nil { + logging.Warn("watcher commit+push: %v", err) + } +} + +func (w *Watcher) stageFiles() error { + cmd := exec.Command("git", "-C", w.dir, "add", "-u") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add -u: %s: %w", out, err) + } + + entries, err := w.findNewFiles() + if err != nil { + return err + } + for _, f := range entries { + cmd := exec.Command("git", "-C", w.dir, "add", "--", f) + if out, err := cmd.CombinedOutput(); err != nil { + logging.Warn("git add %s: %s", f, out) + } + } + return nil +} + +func (w *Watcher) findNewFiles() ([]string, error) { + cmd := exec.Command("git", "-C", w.dir, "ls-files", "--others", "--exclude-standard") + out, err := cmd.Output() + if err != nil { + return nil, err + } + var staged []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" && ShouldStageNewFile(line) { + staged = append(staged, line) + } + } + return staged, nil +} + +func (w *Watcher) addDirRecursive(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if excludeDirs[name] || name == ".konveyor" { + return filepath.SkipDir + } + return w.fsw.Add(path) + } + return nil + }) +} +``` + +- [ ] **Step 6: Write watcher unit tests (watcher_test.go)** + +```go +package watcher + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +func TestWatcherDetectsFileChange(t *testing.T) { + dir := t.TempDir() + + // Initialize a git repo so git add -u works + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Modify a tracked file + writeFile(t, filepath.Join(dir, "App.java"), "class App { int x; }") + + // Wait for quiet period + buffer + time.Sleep(quietPeriod + 5*time.Second) + + if commitCount.Load() == 0 { + t.Error("expected at least one commit after quiet period") + } +} + +func TestWatcherIgnoresExcludedDirs(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Write to an excluded dir — should NOT trigger commit + gooseDir := filepath.Join(dir, ".goose") + os.MkdirAll(gooseDir, 0755) + writeFile(t, filepath.Join(gooseDir, "cache.db"), "data") + + time.Sleep(quietPeriod + 5*time.Second) + + if commitCount.Load() != 0 { + t.Error("expected no commits for changes in excluded dirs") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0755) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } +} +``` + +- [ ] **Step 7: Run watcher tests** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./internal/watcher/ -v -timeout 120s` + +Expected: PASS (tests take ~35s each due to quiet period) + +- [ ] **Step 8: Commit** + +```bash +git add harness/go.mod harness/go.sum harness/internal/watcher/ +git commit -m "feat: add filesystem watcher with quiet-period commit+push" +``` + +--- + +### Task 3: Gut main.go to thin wrapper + +**Files:** +- Modify: `harness/cmd/migration-harness/main.go` (full rewrite) +- Modify: `harness/internal/config/config.go` (remove file-based config, Save, DefaultConfigPath) + +**Interfaces:** +- Consumes: `config.LoadFromEnv()`, `git.ReadFromEnv()`, `git.Clone()`, `git.StripCredentials()`, `git.ClearEnvCredentials()`, `git.CheckoutBranch()`, `git.CommitAll()`, `git.Push()`, `goose.StartServe()`, `acp.WaitReadyDial()`, `acp.NewSessionClient()`, `(*SessionClient).CreateSession()`, `(*SessionClient).SendPrompt()`, `watcher.New()`, `(*Watcher).Start()`, `(*Watcher).Stop()` +- Produces: `main()` — single `run` command entrypoint for the harness binary + +- [ ] **Step 1: Simplify config.go — remove file-based config** + +Remove `DefaultHome()`, `DefaultConfigPath()`, `Load()`, `Save()`, `parseConfigLine()` from `harness/internal/config/config.go`. Keep only `LoadFromEnv()`, `Config` struct, and the `Default*` constants. + +```go +package config + +import ( + "os" + "strconv" +) + +const ( + DefaultMaxTurns = 200 + DefaultMaxFixIterations = 3 +) + +type Config struct { + Model string + Provider string + Endpoint string + APIKey string + MaxTurns int + MaxFixIterations int +} + +func LoadFromEnv() *Config { + model := os.Getenv("KONVEYOR_MODEL_PRIMARY_MODEL") + provider := os.Getenv("KONVEYOR_MODEL_PRIMARY_PROVIDER") + if model == "" || provider == "" { + return nil + } + + cfg := &Config{ + Model: model, + Provider: provider, + Endpoint: os.Getenv("KONVEYOR_MODEL_PRIMARY_ENDPOINT"), + APIKey: os.Getenv("KONVEYOR_MODEL_PRIMARY_API_KEY"), + MaxTurns: DefaultMaxTurns, + MaxFixIterations: DefaultMaxFixIterations, + } + + if n, err := strconv.Atoi(os.Getenv("KONVEYOR_PARAM_MAX_TURNS")); err == nil && n > 0 { + cfg.MaxTurns = n + } + if n, err := strconv.Atoi(os.Getenv("KONVEYOR_PARAM_MAX_FIX_ITERATIONS")); err == nil && n > 0 { + cfg.MaxFixIterations = n + } + + return cfg +} +``` + +- [ ] **Step 2: Rewrite main.go** + +Replace the entire `harness/cmd/migration-harness/main.go` with the thin wrapper flow. The new `main.go` has a single `run` command: + +```go +package main + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/konveyor/migration-harness/internal/acp" + "github.com/konveyor/migration-harness/internal/config" + "github.com/konveyor/migration-harness/internal/git" + "github.com/konveyor/migration-harness/internal/goose" + "github.com/konveyor/migration-harness/internal/logging" + "github.com/konveyor/migration-harness/internal/watcher" +) + +var rootCmd = &cobra.Command{ + Use: "migration-harness", + Short: "Thin git plumbing wrapper for goose-based migration stages", +} + +var runCmd = &cobra.Command{ + Use: "run", + Short: "Run a single migration stage (plan, execute, or verify)", + RunE: runStage, +} + +func init() { + rootCmd.AddCommand(runCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + os.Exit(1) + } +} + +func runStage(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + + // 1. Load config from env + cfg := config.LoadFromEnv() + if cfg == nil { + return fmt.Errorf("KONVEYOR_MODEL_PRIMARY_MODEL and KONVEYOR_MODEL_PRIMARY_PROVIDER are required") + } + + // 2. Read git creds + creds, err := git.ReadFromEnv() + if err != nil { + return fmt.Errorf("git credentials: %w", err) + } + if creds == nil { + return fmt.Errorf("GIT_REPO_URL is required") + } + + // 3. Clone, strip creds, checkout branch + logging.Header("Git Setup") + logging.Info("cloning %s...", creds.RepoURL) + + workDir := os.Getenv("HARNESS_WORK_DIR") + if workDir == "" { + workDir = "/workspace" + } + cloneDir := filepath.Join(workDir, "repo") + + repo, err := git.Clone(ctx, creds, cloneDir) + if err != nil { + return fmt.Errorf("clone: %w", err) + } + + if err := git.StripCredentials(repo); err != nil { + return fmt.Errorf("strip credentials: %w", err) + } + git.ClearEnvCredentials() + + if err := git.CheckoutBranch(repo, creds.Branch); err != nil { + return fmt.Errorf("checkout branch %s: %w", creds.Branch, err) + } + logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) + + // 4. Start goose serve + logging.Header("Goose Setup") + srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) + if err != nil { + return fmt.Errorf("start goose serve: %w", err) + } + defer srv.Stop() + + // 5. Connect ACP, create session + wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) + if err != nil { + return fmt.Errorf("connect to goose: %w", err) + } + defer wsClient.Close() + + session := acp.NewSessionClient(wsClient) + sessionID, err := session.CreateSession(ctx, cloneDir, nil) + if err != nil { + return fmt.Errorf("create session: %w", err) + } + + // 6. Discover skill + skillContent, err := discoverSkill() + if err != nil { + return fmt.Errorf("discover skill: %w", err) + } + + // 7. Build prompt from 4 context layers + prompt := buildPrompt(skillContent) + + // 8. Start filesystem watcher BEFORE blocking prompt + commitPush := func() error { + if _, err := git.CommitAll(repo, "konveyor: auto-commit progress"); err != nil { + return err + } + return git.Push(ctx, creds, repo, creds.Branch) + } + w, err := watcher.New(cloneDir, commitPush) + if err != nil { + return fmt.Errorf("create watcher: %w", err) + } + if err := w.Start(ctx); err != nil { + return fmt.Errorf("start watcher: %w", err) + } + defer w.Stop() + + // 9. Send single ACP prompt (blocks until goose finishes) + // NOTE: KONVEYOR_PARAM_MAX_TURNS is parsed by config but not enforced + // here yet. The spec flags this as needing investigation: goose serve + // may accept a turn limit via CLI flag, env var, or ACP session param. + // If none exist natively, count tool_call notifications from the ACP + // stream and terminate. This is deferred to a follow-up task. + logging.Header("Running Stage") + _, err = session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ + {Type: "text", Text: prompt}, + }) + + if err != nil { + logging.Err("prompt failed: %v", err) + } + + if !srv.Alive() { + logging.Err("goose serve crashed") + } + + // 10. Stop watcher + w.Stop() + + // 11. Read result.json for exit status + exitCode := readResultStatus(cloneDir) + + // 12. Final commit + push + logging.Header("Final Push") + if _, err := git.CommitAll(repo, "konveyor: stage complete"); err != nil { + logging.Warn("final commit: %v", err) + } + if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { + logging.Warn("final push: %v", err) + } + + // 13. Exit + if exitCode != 0 { + logging.Err("stage failed (result.json)") + os.Exit(1) + } + logging.Ok("stage succeeded") + return nil +} + +func discoverSkill() (string, error) { + matches, err := filepath.Glob("/opt/skills/*/SKILL.md") + if err != nil { + return "", err + } + if len(matches) == 0 { + return "", fmt.Errorf("no skill found at /opt/skills/*/SKILL.md") + } + if len(matches) > 1 { + return "", fmt.Errorf("expected exactly one skill, found %d: %v", len(matches), matches) + } + content, err := os.ReadFile(matches[0]) + if err != nil { + return "", fmt.Errorf("read skill %s: %w", matches[0], err) + } + logging.Info("discovered skill: %s", matches[0]) + return string(content), nil +} + +func buildPrompt(skillContent string) string { + var b strings.Builder + + if v := os.Getenv("KONVEYOR_PROMPT"); v != "" { + b.WriteString(v) + b.WriteString("\n\n") + } + + if v := os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS"); v != "" { + b.WriteString("## Migration Context\n\n") + b.WriteString(v) + b.WriteString("\n\n") + } + + b.WriteString("## Skill Instructions\n\n") + b.WriteString(skillContent) + b.WriteString("\n\n") + + if v := os.Getenv("KONVEYOR_INSTRUCTIONS"); v != "" { + b.WriteString("## Stage Task\n\n") + b.WriteString(v) + } + + return b.String() +} + +type stageResult struct { + Stage string `json:"stage"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` +} + +func readResultStatus(workDir string) int { + path := filepath.Join(workDir, ".konveyor", "result.json") + data, err := os.ReadFile(path) + if err != nil { + logging.Warn("no result.json found — treating as failure") + return 1 + } + + var results []stageResult + if err := json.Unmarshal(data, &results); err != nil { + logging.Warn("invalid result.json: %v", err) + return 1 + } + + if len(results) == 0 { + logging.Warn("result.json is empty — treating as failure") + return 1 + } + + last := results[len(results)-1] + if last.Status == "succeeded" { + return 0 + } + + logging.Err("stage %s failed: %s", last.Stage, last.Reason) + return 1 +} +``` + +- [ ] **Step 3: Verify the harness builds** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go build ./cmd/migration-harness/` + +Expected: builds without errors + +- [ ] **Step 4: Commit** + +```bash +git add harness/cmd/migration-harness/main.go harness/internal/config/config.go +git commit -m "feat: rewrite harness as thin wrapper with single run command" +``` + +--- + +### Task 4: Delete obsolete harness packages and files + +**Files:** +- Delete: `harness/internal/detect/` (entire directory) +- Delete: `harness/internal/plan/` (entire directory) +- Delete: `harness/internal/execute/` (entire directory) +- Delete: `harness/internal/verify/` (entire directory) +- Delete: `harness/internal/fixloop/` (entire directory) +- Delete: `harness/internal/handoff/` (entire directory) +- Delete: `harness/internal/metrics/` (entire directory) +- Delete: `harness/internal/rundir/` (entire directory) +- Delete: `harness/internal/goose/goose.go` +- Delete: `harness/internal/goose/acp_runner.go` +- Delete: `harness/internal/goose/acp_runner_test.go` +- Delete: `harness/internal/goose/recipe.go` +- Delete: `harness/internal/goose/recipe_test.go` +- Delete: `harness/recipes/` (entire directory) +- Delete: `harness/skill-bundle/` (entire directory) +- Delete: `images/agent-base-goose-java/Containerfile` + +**Interfaces:** +- Consumes: nothing (pure deletion) +- Produces: clean harness codebase with only: `git/`, `goose/lifecycle.go`, `config/`, `logging/`, `acp/`, `watcher/` + +- [ ] **Step 1: Verify no imports of deleted packages remain in main.go** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && grep -rn 'internal/detect\|internal/plan\|internal/execute\|internal/verify\|internal/fixloop\|internal/handoff\|internal/metrics\|internal/rundir\|goose\.Runner\|goose\.NewACPRunner\|goose\.ParseRecipe' cmd/ internal/` + +Expected: no matches (main.go was rewritten in Task 3) + +- [ ] **Step 2: Delete obsolete packages** + +```bash +rm -rf harness/internal/detect \ + harness/internal/plan \ + harness/internal/execute \ + harness/internal/verify \ + harness/internal/fixloop \ + harness/internal/handoff \ + harness/internal/metrics \ + harness/internal/rundir +``` + +- [ ] **Step 3: Delete obsolete goose files** + +```bash +rm harness/internal/goose/goose.go \ + harness/internal/goose/acp_runner.go \ + harness/internal/goose/acp_runner_test.go \ + harness/internal/goose/recipe.go \ + harness/internal/goose/recipe_test.go +``` + +- [ ] **Step 4: Delete recipes and skill-bundle** + +```bash +rm -rf harness/recipes \ + harness/skill-bundle +``` + +- [ ] **Step 5: Delete the monolithic Containerfile** + +```bash +rm images/agent-base-goose-java/Containerfile +rmdir images/agent-base-goose-java 2>/dev/null || true +``` + +- [ ] **Step 6: Run go mod tidy to clean unused dependencies** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go mod tidy` + +Expected: unused dependencies (e.g., yaml.v3 from recipe.go) are removed from go.mod + +- [ ] **Step 7: Verify harness builds and tests pass** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go build ./cmd/migration-harness/ && go test ./...` + +Expected: builds cleanly, all remaining tests pass + +- [ ] **Step 8: Commit** + +```bash +git add -u harness/ images/ +git commit -m "chore: delete obsolete harness packages, recipes, skill-bundle, and monolithic image" +``` + +--- + +### Task 5: Create stage images (Containerfiles) + +**Files:** +- Create: `images/agent-base/Containerfile` +- Create: `images/agent-plan/Containerfile` +- Create: `images/agent-execute-java/Containerfile` +- Create: `images/agent-verify-java/Containerfile` + +**Interfaces:** +- Consumes: harness binary from `harness/cmd/migration-harness/`, skills from `skills/` directory (Task 6) +- Produces: 4 container images buildable with `podman build` + +- [ ] **Step 1: Create agent-base Containerfile** + +Create `images/agent-base/Containerfile`: + +```dockerfile +# agent-base: Foundation image with goose CLI, git, and the migration harness binary. +# All stage images extend this base. +# +# Build context must be the repository root: +# podman build -t agent-base -f images/agent-base/Containerfile . + +# --- Build stage --- +FROM golang:1.26 AS builder + +WORKDIR /src +COPY harness/go.mod harness/go.sum ./ +RUN go mod download +COPY harness/cmd/ cmd/ +COPY harness/internal/ internal/ +RUN CGO_ENABLED=0 go build -o /migration-harness ./cmd/migration-harness/ + +# --- Runtime stage --- +FROM registry.access.redhat.com/ubi10/ubi:latest + +RUN dnf install -y \ + curl \ + git \ + ca-certificates \ + && dnf clean all + +# Install Goose +RUN ARCH=$(uname -m) \ + && if [ "$ARCH" = "x86_64" ]; then GOOSE_ARCH="x86_64-unknown-linux-gnu"; \ + elif [ "$ARCH" = "aarch64" ]; then GOOSE_ARCH="aarch64-unknown-linux-gnu"; \ + else echo "Unsupported architecture: $ARCH" && exit 1; fi \ + && curl -fsSL "https://github.com/block/goose/releases/download/stable/goose-${GOOSE_ARCH}.tar.bz2" -o /tmp/goose.tar.bz2 \ + && tar -xjf /tmp/goose.tar.bz2 -C /tmp \ + && mv /tmp/goose /usr/local/bin/goose \ + && chmod +x /usr/local/bin/goose \ + && rm -f /tmp/goose.tar.bz2 + +ENV PATH="/opt/migration-harness/bin:${PATH}" +ENV HARNESS_WORK_DIR=/workspace + +COPY --from=builder /migration-harness /opt/migration-harness/bin/migration-harness + +RUN mkdir -p /opt/skills /workspace /home/harness/.migration-harness \ + && useradd -r -d /home/harness -s /sbin/nologin harness \ + && chown -R harness:harness /home/harness /workspace /tmp + +WORKDIR /workspace +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] +``` + +- [ ] **Step 2: Create agent-plan Containerfile** + +Create `images/agent-plan/Containerfile`: + +```dockerfile +# agent-plan: Plan stage image. Adds graphify for code graph generation. +# +# Build context must be the repository root: +# podman build -t agent-plan -f images/agent-plan/Containerfile . + +FROM agent-base AS base + +USER root + +RUN dnf install -y \ + python3 \ + python3-pip \ + python3-devel \ + gcc \ + gcc-c++ \ + && dnf clean all + +RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 + +ENV PYTHONUNBUFFERED=1 + +COPY skills/plan/ /opt/skills/plan/ + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] +``` + +- [ ] **Step 3: Create agent-execute-java Containerfile** + +Create `images/agent-execute-java/Containerfile`: + +```dockerfile +# agent-execute-java: Execute stage image for Java migrations. +# Adds JDK 21 and Maven for building/compiling Java projects. +# +# Build context must be the repository root: +# podman build -t agent-execute-java -f images/agent-execute-java/Containerfile . + +FROM agent-base AS base + +USER root + +RUN dnf install -y \ + java-21-openjdk-devel \ + maven \ + && dnf clean all + +RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ + && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current +ENV JAVA_HOME=/usr/lib/jvm/java-current +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +COPY skills/execute-java/ /opt/skills/execute/ + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] +``` + +- [ ] **Step 4: Create agent-verify-java Containerfile** + +Create `images/agent-verify-java/Containerfile`: + +```dockerfile +# agent-verify-java: Verify stage image for Java migrations. +# Same toolchain as execute (JDK + Maven) for running builds and tests. +# +# Build context must be the repository root: +# podman build -t agent-verify-java -f images/agent-verify-java/Containerfile . + +FROM agent-base AS base + +USER root + +RUN dnf install -y \ + java-21-openjdk-devel \ + maven \ + && dnf clean all + +RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ + && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current +ENV JAVA_HOME=/usr/lib/jvm/java-current +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +COPY skills/verify-java/ /opt/skills/verify/ + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] +``` + +- [ ] **Step 5: Verify Containerfile syntax** + +Run: `for f in images/agent-base/Containerfile images/agent-plan/Containerfile images/agent-execute-java/Containerfile images/agent-verify-java/Containerfile; do echo "--- $f ---"; podman build --no-cache --layers=false -f "$f" --target=builder . 2>&1 | tail -5 || echo "syntax ok"; done` + +Expected: builder stages parse without Dockerfile syntax errors (full builds require skills from Task 6) + +- [ ] **Step 6: Commit** + +```bash +git add images/agent-base/ images/agent-plan/ images/agent-execute-java/ images/agent-verify-java/ +git commit -m "feat: add stage-specific Containerfiles (base, plan, execute-java, verify-java)" +``` + +--- + +### Task 6: Write skills (plan, execute-java, verify-java) + +**Files:** +- Create: `skills/plan/SKILL.md` +- Create: `skills/execute-java/SKILL.md` +- Create: `skills/execute-java/references/javaee-quarkus.md` (copy from `harness/skill-bundle/goose-migration/references/javaee-quarkus.md`) +- Create: `skills/verify-java/SKILL.md` + +**Interfaces:** +- Consumes: knowledge from deleted packages (`detect/`, `plan/`, `execute/`, `verify/`, `fixloop/`) and deleted recipes (`recipes/*.yaml`) +- Produces: 3 skill files baked into stage images, each instructing goose how to perform one stage + +- [ ] **Step 1: Write plan skill** + +Create `skills/plan/SKILL.md`: + +```markdown +--- +name: migration-plan +description: > + Runs graphify to generate the code graph, analyzes the project, and produces + PLAN.md with structured migration items. Does NOT modify source files. +--- + +# Plan Stage Skill + +You are a migration planner. Your job is to analyze a project and produce +a detailed `PLAN.md` at the repo root. Do NOT modify any source files. + +## Steps + +### 1. Generate the code graph + +Run graphify on the project: + +```bash +graphify update +``` + +This produces `graph.json` in the repo root. + +### 2. Read project structure + +- Read `graph.json` to understand the project architecture: + - Communities (architectural layers) + - God nodes (high-degree, high-risk files) + - File relationships (edges) +- Read the build manifest (`pom.xml`, `package.json`, etc.) — always one file +- Read 5-8 complex source files that need structural changes (MDB classes, + security config, lifecycle listeners) + +### 3. Read the migration context + +Your overall migration goal is provided in the prompt above under +"Migration Context" and "Stage Task". Use these to understand: +- What needs to change (e.g., Java EE → Quarkus) +- Target state +- Constraints + +### 4. Write PLAN.md + +Write `PLAN.md` to the repo root with this structure: + +```markdown +# PLAN.md + +## Goal + + +## Project Summary +- Type: +- Files affected: +- Estimated complexity: +- Hardest steps: + +## Steps + +### Step 1: +- File: <exact path> +- Action: CREATE | MODIFY | DELETE +- What to do: <specific instructions> +- Why: <reason> +- Depends on: <step numbers or "none"> +- Verify: <how to confirm> + +### Step 2: ... +``` + +#### Rules for steps + +1. **One file per step** — never combine two files +2. **Exact paths** — real paths from graph.json, not placeholders +3. **Layer order** — build config → app config → utils → persistence → models → services → REST → cleanup +4. **Flag hard steps** — prefix with `⚠️ COMPLEX:` for MDB, JNDI, architectural changes +5. **DELETE steps last** — after all modifications +6. **Dependency order** — steps that others depend on come first + +### 5. Write result + +After writing PLAN.md, append your result to `.konveyor/result.json`: + +```bash +mkdir -p .konveyor +``` + +If the file exists, read it, parse the JSON array, append your entry, +and write it back. If it doesn't exist, create it with a single-entry array. + +Your entry: + +```json +{"stage": "plan", "status": "succeeded"} +``` + +Or on failure: + +```json +{"stage": "plan", "status": "failed", "reason": "<what went wrong>"} +``` + +## Important + +- Do NOT modify source files — planning only +- Do NOT execute any migration steps +- Do NOT skip graphify — the graph is essential for later stages +- Read selectively — the graph gives you most of what you need +``` + +- [ ] **Step 2: Copy the Java EE → Quarkus reference** + +```bash +mkdir -p skills/execute-java/references +cp harness/skill-bundle/goose-migration/references/javaee-quarkus.md skills/execute-java/references/ +``` + +- [ ] **Step 3: Write execute-java skill** + +Create `skills/execute-java/SKILL.md`: + +```markdown +--- +name: migration-execute-java +description: > + Reads PLAN.md and executes each migration step sequentially. Applies + Java EE → Quarkus transformations file by file. Writes modified source + files to disk. +--- + +# Execute Stage Skill (Java) + +You are a migration executor. Read `PLAN.md` (produced by the plan stage) +and execute every step in order. The reference file at +`/opt/skills/execute/references/javaee-quarkus.md` contains the pattern +catalog for Java EE → Quarkus transformations. + +## Startup + +1. Read `PLAN.md` from the repo root — read it ONCE, work through the list +2. Read `/opt/skills/execute/references/javaee-quarkus.md` for transformation patterns +3. Begin executing steps in order + +## Execution Loop + +For each step in PLAN.md: + +1. Read the target file +2. Apply the transformation described in the step +3. Write the modified file +4. Move to the next step immediately — do NOT wait for confirmation + +### Guardrails + +- You MUST attempt every item in PLAN.md in order. Do not skip items. +- After completing each item, note it mentally before moving to the next. +- Do not re-read PLAN.md after every item — read it once, work through the list. +- If you cannot complete an item, note the reason and move to the next. + Do not get stuck on one item. + +## Common Java EE → Quarkus Transformations + +### Import replacements +- `javax.ejb.*` → `jakarta.enterprise.context.*` + `jakarta.inject.*` +- `javax.persistence.*` → `jakarta.persistence.*` +- `javax.ws.rs.*` → `jakarta.ws.rs.*` +- `javax.inject.*` → `jakarta.inject.*` + +### Annotation replacements +- `@Stateless` → `@ApplicationScoped` +- `@Stateful` → `@ApplicationScoped` +- `@EJB` → `@Inject` +- Remove `@Local`, `@Remote` + +### MDB conversions (⚠️ COMPLEX) +See the reference file for before/after patterns. These require +structural changes, not just import swaps. + +### pom.xml +- Change `<packaging>war</packaging>` → `<packaging>jar</packaging>` +- Remove `javaee-api` dependency +- Add Quarkus BOM and extensions + +### Config files +- Create `application.properties` from `persistence.xml` and `web.xml` settings +- Delete legacy XML config files (`persistence.xml`, `web.xml`, `beans.xml`) + +## Completion + +After executing all steps, append your result to `.konveyor/result.json`: + +Read the existing file (it should have the plan stage entry), parse the +JSON array, append your entry, and write it back. + +Your entry: + +```json +{"stage": "execute", "status": "succeeded"} +``` + +Or on failure: + +```json +{"stage": "execute", "status": "failed", "reason": "<what went wrong>"} +``` + +## Important + +- Work through ALL items — completeness matters more than perfection +- Follow the reference file for complex patterns (MDB, JNDI) +- Do NOT run builds or tests — that is the verify stage's job +- Do NOT modify PLAN.md +``` + +- [ ] **Step 4: Write verify-java skill** + +Create `skills/verify-java/SKILL.md`: + +```markdown +--- +name: migration-verify-java +description: > + Runs mvn clean compile, parses errors, applies conservative fixes, + and iterates until the build passes or max iterations are reached. +--- + +# Verify Stage Skill (Java) + +You are a migration verifier. Your job is to run the build, identify +errors, and fix them iteratively until the build passes. + +## Steps + +### 1. Run the build + +```bash +mvn clean compile 2>&1 | tail -50 +``` + +If the build succeeds (exit code 0), skip to step 4. + +### 2. Fix errors + +For each compiler error: + +1. Read the error message to identify the file and issue +2. Read the source file +3. Apply a minimal, conservative fix +4. Do NOT change code that isn't related to the error + +Common errors and fixes: + +| Error | Fix | +|---|---| +| `package javax.* does not exist` | Replace remaining `javax.*` import with `jakarta.*` | +| `cannot find symbol` for CDI annotations | Add missing Quarkus extension to pom.xml | +| `cannot find symbol` for removed class | Check if a deleted interface/class is still referenced; update the reference | +| Missing `application.properties` keys | Add the required config property | + +### 3. Re-verify + +After fixing errors, run the build again: + +```bash +mvn clean compile 2>&1 | tail -50 +``` + +Repeat steps 2-3 up to the number of iterations specified by +`KONVEYOR_PARAM_MAX_FIX_ITERATIONS` (read from environment, default 3). + +If the build still fails after max iterations, report failure. + +### 4. Run tests (if build passes) + +```bash +mvn test 2>&1 | tail -80 +``` + +Report test results but do NOT attempt to fix failing tests. + +### 5. Write result + +Append your result to `.konveyor/result.json`: + +Read the existing file (it should have plan and execute entries), +parse the JSON array, append your entry, and write it back. + +Your entry on success: + +```json +{"stage": "verify", "status": "succeeded"} +``` + +On failure: + +```json +{"stage": "verify", "status": "failed", "reason": "mvn compile failed after N fix iterations"} +``` + +## Important + +- Fixes must be minimal and conservative — don't rewrite working code +- Only fix compiler errors, not warnings +- Do NOT modify PLAN.md or files unrelated to the error +- Read KONVEYOR_PARAM_MAX_FIX_ITERATIONS from environment for iteration cap +``` + +- [ ] **Step 5: Verify skill files exist and are valid** + +Run: `for f in skills/plan/SKILL.md skills/execute-java/SKILL.md skills/execute-java/references/javaee-quarkus.md skills/verify-java/SKILL.md; do echo "--- $f ---"; head -5 "$f"; done` + +Expected: all 4 files exist with correct frontmatter + +- [ ] **Step 6: Commit** + +```bash +git add skills/ +git commit -m "feat: add plan, execute-java, and verify-java skills" +``` + +--- + +### Task 7: Update hack/ with AgentPlaybook + AgentPlaybookRun example resources + +**Files:** +- Create: `hack/harness-test/playbook-resources.yaml` +- Modify: `hack/harness-test/resources.yaml` (update Agent CRs for new images) + +**Interfaces:** +- Consumes: CRD types from `api/v1alpha1/` (Agent, AgentPlaybook, AgentPlaybookRun) +- Produces: example YAML resources for testing the full playbook flow + +- [ ] **Step 1: Create playbook example resources** + +Create `hack/harness-test/playbook-resources.yaml`: + +```yaml +# Example resources for testing the AgentPlaybook + AgentPlaybookRun flow. +# Migrates the coolstore Java EE app to Quarkus using 3 stages. +# +# Prerequisites: +# - LLMProvider "gcp-vertex-ai" from resources.yaml +# - git-credentials Secret from setup.sh +# +# Usage: +# kubectl apply -f hack/harness-test/resources.yaml +# kubectl apply -f hack/harness-test/playbook-resources.yaml + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-plan-agent +spec: + image: quay.io/konveyor/agent-plan:dev + providers: + - ref: gcp-vertex-ai + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-execute-java +spec: + image: quay.io/konveyor/agent-execute-java:dev + providers: + - ref: gcp-vertex-ai + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-verify-java +spec: + image: quay.io/konveyor/agent-verify-java:dev + providers: + - ref: gcp-vertex-ai + params: + - name: max_turns + type: number + default: "200" + - name: max_fix_iterations + type: number + default: "3" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybook +metadata: + name: java-ee-to-quarkus +spec: + guide: "Migrate a Java EE application to Quarkus 3" + stages: + - name: plan + agentRef: migration-plan-agent + instructions: "Analyze the project structure using graphify and produce PLAN.md with migration steps" + - name: execute + agentRef: migration-execute-java + instructions: "Execute each step in PLAN.md to migrate the code from Java EE to Quarkus" + - name: verify + agentRef: migration-verify-java + instructions: "Run mvn clean compile, fix any compilation errors, then run tests" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybookRun +metadata: + name: coolstore-migration +spec: + playbookRef: java-ee-to-quarkus + models: + - role: primary + provider: gcp-vertex-ai + model: claude-sonnet-4-5 + env: + - name: GIT_REPO_URL + value: "https://github.com/savitharaghunathan/coolstore.git" + - name: GIT_TOKEN + valueFrom: + secretKeyRef: + name: git-credentials + key: token + - name: GIT_TARGET_BRANCH + value: "konveyor/coolstore-migration" + - name: GCP_PROJECT_ID + value: "__GCP_PROJECT_ID__" + - name: GCP_LOCATION + value: "global" +``` + +- [ ] **Step 2: Update resources.yaml — keep LLMProvider, keep legacy Agent for backwards compat** + +The existing `resources.yaml` has LLMProvider and the monolithic Agent CR. Keep +the LLMProvider (it's used by both old and new flows). Add a comment noting the +new agents are in `playbook-resources.yaml`. Remove the old AgentRun CR since +users should now use AgentPlaybookRun. + +In `hack/harness-test/resources.yaml`, remove the `AgentRun` CR (lines 39-60 starting at `---` before the AgentRun). Keep the LLMProvider and Agent. Add a comment at the top: + +```yaml +# Harness integration test resources for Kind. +# Creates: Secret → LLMProvider → Agent (legacy monolithic agent) +# +# For the new playbook flow, also apply playbook-resources.yaml +# which creates per-stage Agents + AgentPlaybook + AgentPlaybookRun. +# +# Usage: +# hack/harness-test/setup.sh +``` + +- [ ] **Step 3: Verify YAML syntax** + +Run: `python3 -c "import yaml; [yaml.safe_load_all(open(f)) for f in ['hack/harness-test/playbook-resources.yaml', 'hack/harness-test/resources.yaml']]" && echo "YAML OK"` + +Expected: "YAML OK" + +- [ ] **Step 4: Commit** + +```bash +git add hack/harness-test/playbook-resources.yaml hack/harness-test/resources.yaml +git commit -m "feat: add AgentPlaybook + AgentPlaybookRun example resources for coolstore" +``` + +--- + +### Task 8: End-to-end build verification + +**Files:** +- No new files — verification only + +**Interfaces:** +- Consumes: all files from Tasks 1-7 +- Produces: confirmation that everything compiles, tests pass, and images build + +- [ ] **Step 1: Run full controller tests** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && go test ./... -count=1` + +Expected: all tests pass + +- [ ] **Step 2: Run full harness tests** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./... -count=1` + +Expected: all tests pass + +- [ ] **Step 3: Run go vet on both modules** + +Run: +```bash +cd /Users/sraghuna/local_dev/konveyor/agentic-controller && go vet ./... +cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go vet ./... +``` + +Expected: no issues + +- [ ] **Step 4: Build agent-base image** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-base -f images/agent-base/Containerfile .` + +Expected: image builds successfully + +- [ ] **Step 5: Build agent-plan image** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-plan -f images/agent-plan/Containerfile .` + +Expected: image builds successfully (depends on agent-base) + +- [ ] **Step 6: Build agent-execute-java image** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-execute-java -f images/agent-execute-java/Containerfile .` + +Expected: image builds successfully (depends on agent-base) + +- [ ] **Step 7: Build agent-verify-java image** + +Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-verify-java -f images/agent-verify-java/Containerfile .` + +Expected: image builds successfully (depends on agent-base) + +- [ ] **Step 8: Verify skill discovery in the built images** + +Run: +```bash +podman run --rm agent-plan ls /opt/skills/plan/SKILL.md +podman run --rm agent-execute-java ls /opt/skills/execute/SKILL.md +podman run --rm agent-verify-java ls /opt/skills/verify/SKILL.md +``` + +Expected: each file exists + +- [ ] **Step 9: Commit any fixes from verification** + +If any issues were found and fixed, commit them: + +```bash +git add -u +git commit -m "fix: address issues found during end-to-end verification" +``` diff --git a/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md b/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md new file mode 100644 index 0000000..b52949e --- /dev/null +++ b/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md @@ -0,0 +1,343 @@ +# Harness + AgentPlaybookRun Integration Design + +## Overview + +Restructure the migration harness from a monolithic 5-stage orchestrator into a thin, uniform wrapper that runs inside AgentPlaybookRun-managed pods. Stage sequencing moves up to the controller (AgentPlaybookRun), migration intelligence moves down to goose skills, and the harness becomes pure git plumbing + goose lifecycle management. + +## Principles (carried forward from AGENTS.md) + +- **Git credentials stay in the harness** — goose never sees push credentials. +- **Constant pushing to git** — progress is committed and pushed regularly, not just at stage boundaries. +- **Proper handoff** — each stage reads prior-stage artifacts from the git repo (PLAN.md, migrated files, etc.). +- **Controller is domain-agnostic** — it sequences stages, it doesn't interpret migration logic. + +## Sequence + +``` +User creates AgentPlaybookRun + └─ AgentPlaybookRun controller + ├─ Stage 1 (plan): + │ ├─ Creates AgentRun (agentRef: migration-plan-agent) + │ ├─ AgentRun controller creates Sandbox pod + │ ├─ Pod: harness → git clone → goose serve → ACP prompt → watcher → commit+push → exit 0 + │ └─ Controller sees Succeeded → advances to stage 2 + ├─ Stage 2 (execute): + │ ├─ Creates AgentRun (agentRef: migration-execute-java) + │ ├─ Pod: harness → git clone (picks up PLAN.md) → goose → watcher → commit+push → exit + │ └─ Controller sees Succeeded → advances to stage 3 + └─ Stage 3 (verify): + ├─ Creates AgentRun (agentRef: migration-verify-java) + ├─ Pod: harness → git clone (picks up migrated code) → goose → watcher → commit+push → exit + └─ Controller sees Succeeded → marks run Succeeded +``` + +## Architecture + +### Stage Sequencing + +AgentPlaybookRun controller creates one AgentRun per stage, sequentially. Each AgentRun creates a Sandbox (pod) with the stage's agent image. The harness binary is the entrypoint in every image. + +Three playbook stages: + +1. **Plan** — runs graphify (detect), feeds output into planner skill, produces PLAN.md +2. **Execute** — goose iterates PLAN.md items, migrates each file/component +3. **Verify** — goose runs build/test, fixes errors iteratively + +### Image Hierarchy (Java only) + +All stage images extend a common base: + +| Image | Base | Adds | Baked-in Skill | +|---|---|---|---| +| `agent-base` | ubi | goose CLI, git, harness binary, cred wiring | (none) | +| `agent-plan` | agent-base | graphify | `/opt/skills/plan/SKILL.md` | +| `agent-execute-java` | agent-base | JDK 21, Maven | `/opt/skills/execute/SKILL.md` | +| `agent-verify-java` | agent-base | JDK 21, Maven | `/opt/skills/verify/SKILL.md` | + +Convention: each image places exactly one skill at `/opt/skills/<stage>/SKILL.md`. The harness discovers it via glob (`/opt/skills/*/SKILL.md`). + +Adding a new language = new execute + verify images with language-specific toolchain and skills. No harness or controller changes. + +### Harness Binary (Thin Wrapper) + +Same binary, same behavior in every image. Does not know what stage it's running. + +**Entrypoint flow (`migration-harness run`):** + +1. Load config from env vars (`KONVEYOR_MODEL_PRIMARY_*`) +2. Read git creds from env (`GIT_REPO_URL`, `GIT_TOKEN`, `GIT_TARGET_BRANCH`) +3. Git clone, strip credentials from remote, checkout branch +4. Start goose serve with `--with-builtin developer` (gives goose shell access, file editing) and providerEnv wiring for LLM credentials +5. Connect via ACP WebSocket, create session (passing cloned repo path as `cwd` so goose operates in the right directory) +6. Discover skill: glob `/opt/skills/*/SKILL.md` (exactly one match) +7. Read env vars: `KONVEYOR_INSTRUCTIONS` (stage task), `KONVEYOR_PLAYBOOK_INSTRUCTIONS` (overall guide), `KONVEYOR_PROMPT` (agent-level standing instructions) +8. Start filesystem watcher (background goroutine) — must start BEFORE the blocking prompt call +9. Send single ACP prompt combining all four context layers: + - `KONVEYOR_PROMPT` — agent-level standing instructions (from Agent CR) + - `KONVEYOR_PLAYBOOK_INSTRUCTIONS` — overall migration context (from playbook Guide) + - Skill content (harness reads `/opt/skills/*/SKILL.md` file and embeds it inline in the prompt) + - `KONVEYOR_INSTRUCTIONS` — specific stage task (from playbook stage) +10. `SendPrompt()` blocks until goose finishes (see Session Completion below) +11. Stop watcher +12. Read `.konveyor/result.json` for exit status (see Failure Propagation below) +13. Final commit + push +14. Exit 0 (success) or exit 1 (failure based on result.json) + +Controller determines Succeeded/Failed from pod exit code. No session.json or handoff files needed. + +### Session Completion + +The harness needs to know when goose has finished its autonomous work. +The ACP WebSocket session delivers messages as goose works. When the +session prompt completes, the ACP protocol returns a final response to +the `session/prompt` JSON-RPC call. The harness's blocking +`SessionClient.Prompt()` call returns at that point. This is the same +mechanism used today — the difference is there's only one prompt call +instead of many. + +If goose serve crashes (process exits), the harness detects this via +`ServeProcess.Alive()` and exits with code 1. + +### Failure Propagation + +Each skill appends its result to `.konveyor/result.json` as its final +action. The file is a JSON array — each stage adds an entry: + +```json +[ + {"stage": "plan", "status": "succeeded"}, + {"stage": "execute", "status": "succeeded"}, + {"stage": "verify", "status": "failed", "reason": "mvn compile failed after 3 fix iterations"} +] +``` + +The harness reads the last entry after the ACP session completes. If the +file is missing or the last entry contains `"failed"`, the harness exits +with code 1. This gives the controller a clear Succeeded/Failed signal +without the harness needing to interpret skill-specific output. + +The file accumulates across stages (each stage clones the repo and picks +up prior entries), providing a complete run history in a single file. + +Skills must append to this file even on failure — it's part of the skill +contract. + +### Filesystem Watcher (New Component) + +Runs as a background goroutine while goose works. Provides the "constant +pushing to git" guarantee without goose needing git credentials. + +**Behavior:** +- Watches working directory for file changes using `fsnotify` +- After detecting changes, waits for a quiet period (~30 seconds of no new writes). The longer quiet period avoids committing mid-write — goose may pause between file writes while waiting for LLM responses, and a short quiet period could fire during that gap. +- On quiet: `git add` (tracked files + known source patterns) → `git commit` → `git push` +- Commit message: `"konveyor: auto-commit progress"` +- If push fails, logs warning and retries on next quiet period +- Final commit+push after goose exits catches anything the watcher missed + +**Safe staging (not `git add -A`):** +The watcher must not blindly stage everything. Goose and its extensions +may create temp files, cache directories, or internal state in the +working directory. The watcher uses a targeted approach: +- Stage changes to files already tracked by git (`git add -u`) +- Stage new files only if they match known source patterns (`.java`, `.xml`, `.properties`, `.md`, `pom.xml`, etc.) +- Respect `.gitignore` — the repo's `.gitignore` (and a harness-managed `.gitignore` entry for `.konveyor/tmp/`) excludes goose internals and harness temp files +- Never stage files matching: `*.tmp`, `*.swp`, `.goose/`, `__pycache__/` +- `.konveyor/result.json` IS staged (it's a tracked contract file). Only `.konveyor/tmp/` is excluded. + +### Inter-Stage State Passing + +Git repo is the shared state boundary. Each stage clones the repo and reads artifacts left by prior stages: + +- Plan stage writes: `PLAN.md`, `graph.json`, `.konveyor/result.json` +- Execute stage reads: `PLAN.md`, `graph.json` (for project structure context). Writes: migrated source files, `.konveyor/result.json` +- Verify stage reads: `PLAN.md` (to know what was migrated and the migration type), migrated source files. Writes: fix patches, `.konveyor/result.json` + +Each stage can read any artifact from prior stages via git. The key +contract files are `PLAN.md` (migration plan) and `graph.json` (project +structure). Skills should reference these explicitly in their +instructions. + +No PVCs, no shared volumes. Clean, auditable, recoverable. + +### Branch Strategy + +All stages must operate on the same git branch. `GIT_TARGET_BRANCH` is +a plain environment variable set by the user in `AgentPlaybookRun.spec.env`. +The controller forwards it to every child AgentRun unchanged (via +`pbRun.Spec.Env`). No CRD field — if the user forgets to set it, the +harness fails immediately. + +**Full env var injection flow in `createAgentRunForStage()`:** + +``` +AgentPlaybookRun + ├─ KONVEYOR_PLAYBOOK_INSTRUCTIONS ← playbook.Spec.Guide + ├─ pbRun.Spec.Env ← user env vars (GIT_REPO_URL, GIT_TOKEN, GIT_TARGET_BRANCH, etc.) + └─ All set on → AgentRun.spec.env + └─ AgentRun controller buildEnvVars() + ├─ KONVEYOR_PARAM_* ← from Agent params + ├─ KONVEYOR_ACP_SECRET_KEY ← generated per run + ├─ KONVEYOR_INSTRUCTIONS ← stage instructions + ├─ KONVEYOR_PROMPT ← Agent CR prompt + ├─ KONVEYOR_MODEL_* ← LLM provider/model/endpoint/apiKey + └─ All forwarded env ← GIT_TARGET_BRANCH, GIT_REPO_URL, etc. + └─ Injected into Sandbox pod container +``` + +- Stage 1 (plan) creates the branch from the repo's default branch and + pushes. Stages 2+ clone and checkout the existing branch, picking up + prior-stage artifacts. +- The harness reads `GIT_TARGET_BRANCH` and fails if it's not set (no + timestamp fallback). +- Concurrent playbook runs against the same repo use different branches + (user sets different `GIT_TARGET_BRANCH` values). + +## Skills + +We author three skills. Each encodes the migration knowledge that was previously spread across harness Go packages and recipe YAML files. + +### Knowledge Migration + +| Deleted Harness Package | Knowledge Moves To | +|---|---| +| `detect/` | **Plan skill** — run graphify, read graph.json, identify manifests | +| `plan/` | **Plan skill** — context gathering, plan structure (items with path/action/risk/layer), PLAN.md format | +| `execute/` | **Execute skill** — iterate PLAN.md items, migration rules, per-item approach | +| `verify/` + `fixloop/` | **Verify skill** — run `mvn clean compile`, parse errors, iterative fix loop | +| `recipes/*.yaml` | Folded directly into corresponding skills as instructions | +| `handoff/` | Not needed — controller tracks stage status, git carries artifacts | +| `metrics/` | Not needed — controller records start/completion times per stage | + +### Plan Skill (`/opt/skills/plan/SKILL.md`) + +Absorbs: `detect/`, `plan/`, `recipes/plan.yaml` logic. + +Instructs goose to: +1. Run `graphify update` on the repo to generate the code graph +2. Read `graph.json`, identify manifests (pom.xml, etc.), count source files +3. Analyze the project structure and migration requirements +4. Produce `PLAN.md` with structured items (number, path, action, risk level, layer) +5. Append result to `.konveyor/result.json` + +### Execute Skill (`/opt/skills/execute/SKILL.md`) + +Absorbs: `execute/`, `recipes/execute.yaml` logic. + +Instructs goose to: +1. Read `PLAN.md` from the repo +2. For each plan item, migrate the file/component following migration rules +3. Work through items sequentially, one at a time +4. Apply language-specific migration patterns (Java EE → Quarkus for the Java variant) +5. Write `.konveyor/result.json` with final status + +Reference docs (javaee-quarkus.md, etc.) are bundled alongside the skill. + +**Iteration guardrails.** Moving plan-item iteration from deterministic +Go code to an LLM-driven skill is the riskiest change. The skill must +include explicit guardrails: +- "You MUST attempt every item in PLAN.md in order. Do not skip items." +- "After completing each item, mark it done in your working notes before moving to the next." +- "Do not re-read PLAN.md after every item — read it once, work through the list." +- "If you cannot complete an item, note the reason and move to the next. Do not get stuck." + +These rules prevent the LLM from skipping items, burning context on one +item, or losing track of progress. + +### Verify Skill (`/opt/skills/verify/SKILL.md`) + +Absorbs: `verify/`, `fixloop/`, `recipes/verify.yaml`, `recipes/fix.yaml` logic. + +Instructs goose to: +1. Run `mvn clean compile` (or equivalent build command) +2. If build succeeds, run tests +3. If errors found, apply minimal conservative fixes +4. Re-verify after each fix +5. Repeat up to N iterations (configurable via `KONVEYOR_PARAM_MAX_FIX_ITERATIONS`) +6. Append result to `.konveyor/result.json` + +## Harness Codebase Changes + +### Packages Kept + +| Package | What stays | +|---|---| +| `git/` | Clone, StripCredentials, CommitAll, Push, CheckoutBranch, ClearEnvCredentials | +| `goose/` | StartServe, Stop, providerEnv, writeADCFile (lifecycle only) | +| `config/` | LoadFromEnv | +| `logging/` | Header, Info, Ok, Warn, Err | +| `acp/` | WSClient, SessionClient (connect, session/new, single prompt) | + +### Packages Deleted + +`detect/`, `plan/`, `execute/`, `verify/`, `fixloop/`, `handoff/`, `metrics/`, `rundir/` + +### Files Deleted + +- `harness/recipes/` (all YAML recipe files) +- `harness/skill-bundle/` (replaced by per-image skills) +- `goose/recipe.go` (recipe rendering) +- `goose/acprunner.go` (multi-turn ACP runner) + +### New Package + +`watcher/` — filesystem watcher with quiet-period detection and git commit+push. + +### main.go + +Simplified to a single `run` command. Remove `init`, `status`, `resume`, `step` subcommands. Remove the 5-step pipeline. Replace with the thin wrapper flow described above. + +## Example Resources (`hack/`) + +Coolstore Java EE → Quarkus example showing the full CR chain: + +```yaml +# Agent CRs +Agent: migration-plan-agent (image: agent-plan) +Agent: migration-execute-java (image: agent-execute-java) +Agent: migration-verify-java (image: agent-verify-java) + +# Playbook +AgentPlaybook: java-ee-to-quarkus + guide: "Migrate a Java EE application to Quarkus" + stages: + - name: plan + agentRef: migration-plan-agent + instructions: "Analyze the project and produce PLAN.md" + - name: execute + agentRef: migration-execute-java + instructions: "Execute each step in PLAN.md" + - name: verify + agentRef: migration-verify-java + instructions: "Run mvn clean compile, fix any errors" + +# Run (user creates this) +AgentPlaybookRun: coolstore-migration + playbookRef: java-ee-to-quarkus + models: [...] + env: + - name: GIT_REPO_URL + value: "https://github.com/savitharaghunathan/coolstore.git" + - name: GIT_TOKEN + valueFrom: { secretKeyRef: { name: git-credentials, key: token } } + - name: GIT_TARGET_BRANCH + value: "konveyor/coolstore-migration" +``` + +## Turn Limits + +With autonomous goose, a runaway stage could burn through API credits. +`KONVEYOR_PARAM_MAX_TURNS` (default 200) should cap how long goose runs +per stage. **Needs investigation during implementation:** how does goose +serve accept a turn limit? Options include a CLI flag, env var, or ACP +session parameter. If none exist natively, the harness may need to count +`tool_call` notifications from the ACP stream and terminate the session +when the limit is reached. + +`KONVEYOR_PARAM_MAX_FIX_ITERATIONS` (default 3) is consumed by the +verify skill to cap its fix loop. It's not a harness concern — the skill +reads it and enforces it. + +## Scope + +This design covers Java only. Other languages follow the same pattern: new execute + verify images with language-specific toolchain and skills, new playbook YAML. No harness or controller changes required. diff --git a/hack/harness-test/playbook-resources.yaml b/hack/harness-test/playbook-resources.yaml new file mode 100644 index 0000000..3c987a3 --- /dev/null +++ b/hack/harness-test/playbook-resources.yaml @@ -0,0 +1,173 @@ +# Example resources for testing the AgentPlaybook + AgentPlaybookRun flow. +# Migrates the coolstore Java EE app to Quarkus using 3 stages. +# +# Prerequisites: +# - LLMProvider "gcp-vertex-ai" from resources.yaml +# - git-credentials Secret from setup.sh +# +# Usage: +# kubectl apply -f hack/harness-test/resources.yaml +# kubectl apply -f hack/harness-test/playbook-resources.yaml + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: plan +spec: + image: quay.io/konveyor/skills:plan + displayName: Plan Stage + version: "1.0.0" + description: Reads a project and produces PLAN.md with migration steps. + type: skill + tags: + - migration + - planning + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: execute +spec: + image: quay.io/konveyor/skills:execute + displayName: Execute Stage + version: "1.0.0" + description: Reads PLAN.md and executes each migration step sequentially. + type: skill + tags: + - migration + - execution + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: verify +spec: + image: quay.io/konveyor/skills:verify + displayName: Verify Stage + version: "1.0.0" + description: Runs the build command, fixes errors, and iterates until the build passes. + type: skill + tags: + - migration + - verification + +--- +apiVersion: konveyor.io/v1alpha1 +kind: SkillCard +metadata: + name: javaee-to-quarkus +spec: + image: quay.io/konveyor/skills:javaee-to-quarkus + displayName: Java EE to Quarkus Migration + version: "1.0.0" + description: > + Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. + Covers EJB-to-CDI, JMS/MDB-to-SmallRye, WAR-to-JAR, persistence.xml replacement, + JNDI removal, and server lifecycle replacement. + type: skill + tags: + - java + - javaee + - quarkus + - migration + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-plan-agent +spec: + image: quay.io/konveyor/agent-plan:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: plan + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-execute-agent +spec: + image: quay.io/konveyor/agent-execute:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: execute + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: Agent +metadata: + name: migration-verify-agent +spec: + image: quay.io/konveyor/agent-verify:dev + providers: + - ref: gcp-vertex-ai + skillCards: + - ref: verify + - ref: javaee-to-quarkus + params: + - name: max_turns + type: number + default: "200" + - name: max_fix_iterations + type: number + default: "3" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybook +metadata: + name: java-ee-to-quarkus +spec: + guide: "Migrate a Java EE application to Quarkus 3" + stages: + - name: plan + agentRef: migration-plan-agent + instructions: "Analyze the project structure using graphify and produce PLAN.md with migration steps" + - name: execute + agentRef: migration-execute-agent + instructions: "Execute each step in PLAN.md to migrate the code from Java EE to Quarkus" + - name: verify + agentRef: migration-verify-agent + instructions: "Run mvn clean compile, fix any compilation errors, then run tests" + +--- +apiVersion: konveyor.io/v1alpha1 +kind: AgentPlaybookRun +metadata: + name: coolstore-migration-__TIMESTAMP__ +spec: + playbookRef: java-ee-to-quarkus + models: + - role: primary + provider: gcp-vertex-ai + model: claude-sonnet-4-5 + env: + - name: GIT_REPO_URL + value: "https://github.com/savitharaghunathan/coolstore.git" + - name: GIT_TOKEN + valueFrom: + secretKeyRef: + name: git-credentials + key: token + - name: GIT_TARGET_BRANCH + value: "konveyor/playbook-__TIMESTAMP__" + - name: GCP_PROJECT_ID + value: "__GCP_PROJECT_ID__" + - name: GCP_LOCATION + value: "global" diff --git a/hack/harness-test/resources.yaml b/hack/harness-test/resources.yaml index 1049c19..2fd3dbb 100644 --- a/hack/harness-test/resources.yaml +++ b/hack/harness-test/resources.yaml @@ -1,5 +1,5 @@ -# Harness integration test resources for Kind. -# Creates the full CR chain: Secret → LLMProvider → Agent → AgentRun +# Shared resources for harness integration tests in Kind. +# Creates: LLMProvider (used by playbook-resources.yaml) # # Usage: # hack/harness-test/setup.sh @@ -17,45 +17,3 @@ spec: - name: claude-sonnet-4-5 contextWindow: 200000 tier: premium - ---- -apiVersion: konveyor.io/v1alpha1 -kind: Agent -metadata: - name: migration-harness -spec: - image: quay.io/konveyor/agent-base-goose-java:dev - providers: - - ref: gcp-vertex-ai - params: - - name: max_turns - type: number - default: "200" - - name: max_fix_iterations - type: number - default: "3" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: AgentRun -metadata: - name: coolstore-migration -spec: - agentRef: migration-harness - models: - - role: primary - provider: gcp-vertex-ai - model: claude-sonnet-4-5 - instructions: "Migrate this Java EE application to Quarkus" - env: - - name: GIT_REPO_URL - value: "https://github.com/savitharaghunathan/coolstore.git" - - name: GIT_TOKEN - valueFrom: - secretKeyRef: - name: git-credentials - key: token - - name: GCP_PROJECT_ID - value: "__GCP_PROJECT_ID__" - - name: GCP_LOCATION - value: "global" diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index cb33d24..abfee11 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -3,7 +3,6 @@ # # Prerequisites: # - Kind cluster running (make e2e-setup) -# - agent-base-goose-java image loaded into Kind # # Usage: # hack/harness-test/setup.sh @@ -13,6 +12,9 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" +CONTAINER_TOOL="${CONTAINER_TOOL:-podman}" +KIND_CLUSTER="${KIND_CLUSTER:-agentic-controller-e2e}" + echo "=== Creating secrets ===" # Vertex AI credentials from local ADC @@ -39,22 +41,59 @@ kubectl create secret generic git-credentials \ echo " git-credentials created" echo "" -echo "=== Loading harness image into Kind ===" -CONTAINER_TOOL="${CONTAINER_TOOL:-podman}" -KIND_CLUSTER="${KIND_CLUSTER:-agentic-controller-e2e}" +echo "=== Building agent images ===" +make -C "$REPO_ROOT" agent-images-build CONTAINER_TOOL="$CONTAINER_TOOL" -HARNESS_IMG="quay.io/konveyor/agent-base-goose-java" -make -C "$REPO_ROOT" agent-java-goose-build CONTAINER_TOOL="$CONTAINER_TOOL" -$CONTAINER_TOOL tag "${HARNESS_IMG}:latest" "${HARNESS_IMG}:dev" +echo "" +echo "=== Building skill images ===" -if [ "$CONTAINER_TOOL" = "podman" ]; then - $CONTAINER_TOOL save "${HARNESS_IMG}:dev" -o /tmp/harness-image.tar - KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/harness-image.tar --name "$KIND_CLUSTER" - rm -f /tmp/harness-image.tar -else - kind load docker-image "${HARNESS_IMG}:dev" --name "$KIND_CLUSTER" -fi -echo " image loaded" +SKILL_IMAGE="quay.io/konveyor/skills" +SKILL_DIRS=(plan execute verify javaee-to-quarkus) + +for SKILL in "${SKILL_DIRS[@]}"; do + SKILL_PATH="$REPO_ROOT/skills/$SKILL" + if [ ! -d "$SKILL_PATH" ]; then + echo " WARN: skill dir $SKILL_PATH not found, skipping" + continue + fi + echo "FROM scratch +COPY . /" | $CONTAINER_TOOL build -t "${SKILL_IMAGE}:${SKILL}" -f - "$SKILL_PATH" + echo " built ${SKILL_IMAGE}:${SKILL}" +done + +echo "" +echo "=== Loading images into Kind ===" + +IMAGES=( + "quay.io/konveyor/agent-plan" + "quay.io/konveyor/agent-execute" + "quay.io/konveyor/agent-verify" +) + +for IMG in "${IMAGES[@]}"; do + $CONTAINER_TOOL tag "${IMG}:latest" "${IMG}:dev" + if [ "$CONTAINER_TOOL" = "podman" ]; then + $CONTAINER_TOOL save "${IMG}:dev" -o /tmp/agent-image.tar + KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/agent-image.tar --name "$KIND_CLUSTER" + rm -f /tmp/agent-image.tar + else + kind load docker-image "${IMG}:dev" --name "$KIND_CLUSTER" + fi + echo " loaded ${IMG}:dev" +done + +for SKILL in "${SKILL_DIRS[@]}"; do + if $CONTAINER_TOOL image exists "${SKILL_IMAGE}:${SKILL}" 2>/dev/null; then + if [ "$CONTAINER_TOOL" = "podman" ]; then + $CONTAINER_TOOL save "${SKILL_IMAGE}:${SKILL}" -o /tmp/skill-image.tar + KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/skill-image.tar --name "$KIND_CLUSTER" + rm -f /tmp/skill-image.tar + else + kind load docker-image "${SKILL_IMAGE}:${SKILL}" --name "$KIND_CLUSTER" + fi + echo " loaded ${SKILL_IMAGE}:${SKILL}" + fi +done echo "" echo "=== Applying resources ===" @@ -63,11 +102,15 @@ if [ -z "$GCP_PROJECT_ID" ]; then echo "ERROR: No GCP project set. Run: gcloud config set project <project-id>" exit 1 fi -echo " GCP project: $GCP_PROJECT_ID" +echo " GCP project: (set)" sed "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/" "$SCRIPT_DIR/resources.yaml" | kubectl apply -f - +TIMESTAMP=$(date +%s) +sed -e "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/g" -e "s/__TIMESTAMP__/$TIMESTAMP/g" "$SCRIPT_DIR/playbook-resources.yaml" | kubectl apply -f - +echo " AgentPlaybookRun: coolstore-migration-$TIMESTAMP" +echo " Branch: konveyor/playbook-$TIMESTAMP" echo "" echo "=== Done ===" -echo "Watch the run: kubectl get agentrun coolstore-migration -w" +echo "Watch the run: kubectl get agentplaybookrun coolstore-migration-$TIMESTAMP -w" echo "Check pods: kubectl get pods" -echo "View logs: kubectl logs -f coolstore-migration -c agent" +echo "View logs: kubectl logs -f coolstore-migration-${TIMESTAMP}-plan -c agent" diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index a748764..a495064 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -1,78 +1,42 @@ package main import ( - "bufio" "context" - "crypto/rand" + "bufio" + "encoding/json" "fmt" "os" - "os/exec" "os/signal" "path/filepath" - "strconv" + "regexp" "strings" "time" - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" "github.com/spf13/cobra" + gogit "github.com/go-git/go-git/v5" + "github.com/konveyor/migration-harness/internal/acp" "github.com/konveyor/migration-harness/internal/config" - "github.com/konveyor/migration-harness/internal/detect" - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/fixloop" "github.com/konveyor/migration-harness/internal/git" "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/handoff" "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/metrics" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/rundir" - "github.com/konveyor/migration-harness/internal/verify" + "github.com/konveyor/migration-harness/internal/watcher" ) var rootCmd = &cobra.Command{ Use: "migration-harness", - Short: "AI-powered code migration CLI", - Long: "migration-harness orchestrates LLM agents through a 5-step pipeline (detect, plan, execute, verify, fix-loop) to automate enterprise code migrations.", -} - -var initCmd = &cobra.Command{ - Use: "init", - Short: "Configure migration-harness", - RunE: runInit, + Short: "Thin git plumbing wrapper for goose-based migration stages", } var runCmd = &cobra.Command{ - Use: "run [repo-or-url] [request]", - Short: "Run a full migration pipeline", - Args: cobra.MaximumNArgs(2), - RunE: runMigration, -} - -var statusCmd = &cobra.Command{ - Use: "status", - Short: "Show status of the latest migration run", - RunE: runStatus, -} - -var resumeCmd = &cobra.Command{ - Use: "resume", - Short: "Resume an incomplete migration", - RunE: runResume, -} - -var stepCmd = &cobra.Command{ - Use: "step <name> <repo>", - Short: "Run a single pipeline step", - Args: cobra.MinimumNArgs(2), - RunE: runStep, + Use: "run", + Short: "Run a single migration stage (plan, execute, or verify)", + RunE: runStage, } func init() { - runCmd.Flags().BoolVar(&plan.AutoApprove, "auto-approve", false, "Skip interactive plan approval") - rootCmd.AddCommand(initCmd, runCmd, statusCmd, resumeCmd, stepCmd) + rootCmd.AddCommand(runCmd) } func main() { @@ -81,409 +45,335 @@ func main() { } } -func checkDeps() error { - for _, dep := range []string{"goose", "graphify"} { - if _, err := exec.LookPath(dep); err != nil { - return fmt.Errorf("required command not found: %s", dep) - } - } - return nil -} - -func runInit(cmd *cobra.Command, args []string) error { - logging.Header("Migration Harness Setup") +func runStage(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() - if err := checkDeps(); err != nil { - return err + // 1. Load config from env + cfg := config.LoadFromEnv() + if cfg == nil { + return fmt.Errorf("KONVEYOR_MODEL_PRIMARY_MODEL and KONVEYOR_MODEL_PRIMARY_PROVIDER are required") } - reader := bufio.NewReader(os.Stdin) - - provider := promptWithDefault(reader, "LLM provider (e.g. anthropic, gcp_vertex_ai, openai)", "anthropic") - model := promptWithDefault(reader, "Model name", "claude-sonnet-4-5") - maxTurnsStr := promptWithDefault(reader, "Max turns per step", strconv.Itoa(config.DefaultMaxTurns)) - maxFixStr := promptWithDefault(reader, "Max fix iterations", strconv.Itoa(config.DefaultMaxFixIterations)) - - maxTurns, err := strconv.Atoi(maxTurnsStr) + // 2. Read git creds + creds, err := git.ReadFromEnv() if err != nil { - maxTurns = config.DefaultMaxTurns + return fmt.Errorf("git credentials: %w", err) } - maxFix, err := strconv.Atoi(maxFixStr) - if err != nil { - maxFix = config.DefaultMaxFixIterations + if creds == nil { + return fmt.Errorf("GIT_REPO_URL is required") } - cfg := &config.Config{ - Model: model, - Provider: provider, - MaxTurns: maxTurns, - MaxFixIterations: maxFix, - } + // 3. Clone, strip creds, checkout branch + logging.Header("Git Setup") + logging.Info("cloning %s...", creds.RepoURL) - path := config.DefaultConfigPath() - if err := config.Save(path, cfg); err != nil { - return fmt.Errorf("save config: %w", err) + cloneDir := os.Getenv("HARNESS_WORK_DIR") + if cloneDir == "" { + cloneDir = "/workspace/repo" } - logging.Ok("Config saved to %s", path) - return nil -} - -func promptWithDefault(reader *bufio.Reader, prompt, defaultVal string) string { - fmt.Fprintf(os.Stderr, "%s [%s]: ", prompt, defaultVal) - line, _ := reader.ReadString('\n') - line = strings.TrimSpace(line) - if line == "" { - return defaultVal + repo, err := git.Clone(ctx, creds, cloneDir) + if err != nil { + return fmt.Errorf("clone: %w", err) } - return line -} -func runMigration(cmd *cobra.Command, args []string) error { - var repoArg, request string - switch len(args) { - case 2: - repoArg = args[0] - request = args[1] - case 1: - repoArg = args[0] + if err := git.StripCredentials(repo); err != nil { + return fmt.Errorf("strip credentials: %w", err) } - if request == "" { - request = os.Getenv("KONVEYOR_INSTRUCTIONS") - } - if request == "" { - return fmt.Errorf("request is required: pass as argument or set KONVEYOR_INSTRUCTIONS") + git.ClearEnvCredentials() + + if err := git.CheckoutBranch(repo, creds.Branch); err != nil { + return fmt.Errorf("checkout branch %s: %w", creds.Branch, err) } - if repoArg == "" { - repoArg = os.Getenv("GIT_REPO_URL") + logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) + + // 4. Start goose serve + logging.Header("Goose Setup") + srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) + if err != nil { + return fmt.Errorf("start goose serve: %w", err) } - if repoArg == "" { - return fmt.Errorf("repo is required: pass as argument or set GIT_REPO_URL") + defer srv.Stop() + + // 5. Connect ACP, create session + wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) + if err != nil { + return fmt.Errorf("connect to goose: %w", err) } + defer wsClient.Close() - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() + session := acp.NewSessionClient(wsClient) + sessionID, err := session.CreateSession(ctx, cloneDir, nil) + if err != nil { + return fmt.Errorf("create session: %w", err) + } - cfgPath := config.DefaultConfigPath() - cfg, err := config.Load(cfgPath) + // 6. Discover skills + skillContent, skillPaths, err := discoverSkills() if err != nil { - return fmt.Errorf("load config (run 'migration-harness init' first): %w", err) + return fmt.Errorf("discover skills: %w", err) } - tracker := metrics.NewTracker() + // 7. Build prompt from context layers + prompt := buildPrompt(skillContent) - creds, err := git.ReadFromEnv() + // 8. Start filesystem watcher BEFORE blocking prompt + commitPush := func() error { + if _, err := git.CommitAll(repo, "konveyor: auto-commit progress"); err != nil { + return err + } + return git.Push(ctx, creds, repo, creds.Branch) + } + w, err := watcher.New(cloneDir, commitPush) if err != nil { - return fmt.Errorf("git credentials: %w", err) + return fmt.Errorf("create watcher: %w", err) + } + if err := w.Start(ctx); err != nil { + return fmt.Errorf("start watcher: %w", err) } + defer w.Stop() - var workDir string - var repo *gogit.Repository + // 9. Send single ACP prompt (blocks until goose finishes or MaxTurns is hit) + logging.Header("Running Stage") + logging.Info("max turns: %d", cfg.MaxTurns) + _, err = session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ + {Type: "text", Text: prompt}, + }, cfg.MaxTurns) - if creds != nil { - tracker.StartStep("git-clone") - logging.Header("Git Setup") - logging.Info("cloning %s...", creds.RepoURL) + if err != nil { + logging.Err("prompt failed: %v", err) + } - tmpBase := os.Getenv("HARNESS_WORK_DIR") - if tmpBase == "" { - tmpBase = os.TempDir() - } - workDir, err = os.MkdirTemp(tmpBase, "migration-harness-"+filepath.Base(creds.RepoURL)+"-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - repo, err = git.Clone(ctx, creds, workDir) - if err != nil { - return fmt.Errorf("clone: %w", err) - } + if !srv.Alive() { + logging.Err("goose serve crashed") + } - if err := git.StripCredentials(repo); err != nil { - return fmt.Errorf("strip credentials: %w", err) - } + // 10. Stop watcher + w.Stop() - git.ClearEnvCredentials() + // 11. Read result.json for exit status + result, exitCode := readResultStatus(cloneDir) - if err := git.CheckoutBranch(repo, creds.Branch); err != nil { - return fmt.Errorf("checkout branch: %w", err) - } + // 11b. Write handoff.md for next stage + if err := writeHandoff(cloneDir, skillPaths, result, repo); err != nil { + logging.Warn("handoff: %v", err) + } - logging.Ok("cloned to %s, branch %s", workDir, creds.Branch) - tracker.EndStep() - } else { - workDir = repoArg - if _, err := os.Stat(workDir); err != nil { - return fmt.Errorf("repo path does not exist: %s", workDir) - } + // 12. Final commit + push + logging.Header("Final Push") + if _, err := git.CommitAll(repo, "konveyor: stage complete"); err != nil { + logging.Warn("final commit: %v", err) + } + if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { + logging.Warn("final push: %v", err) } - runsDir := rundir.DefaultRunsDir() - runDir, err := rundir.New(runsDir, filepath.Base(workDir)) - if err != nil { - return fmt.Errorf("create run dir: %w", err) + // 13. Exit + if exitCode != 0 { + logging.Err("stage failed (result.json)") + os.Exit(1) } - logging.Info("run directory: %s", runDir) + logging.Ok("stage succeeded") + return nil +} - installDir := findInstallDir() - skillDir := filepath.Join(installDir, "skill-bundle", "goose-migration") - recipesDir := filepath.Join(installDir, "recipes") - logDir := filepath.Join(runDir, "logs") +const defaultSkillsDir = "/opt/skills" - srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) - if err != nil { - return fmt.Errorf("start goose serve: %w", err) +func skillsDir() string { + if v := os.Getenv("HARNESS_SKILLS_DIR"); v != "" { + return v } - defer srv.Stop() + return defaultSkillsDir +} - wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) +func discoverSkills() (string, []string, error) { + pattern := filepath.Join(skillsDir(), "*/SKILL.md") + matches, err := filepath.Glob(pattern) if err != nil { - return fmt.Errorf("connect to goose serve: %w", err) + return "", nil, err + } + if len(matches) == 0 { + return "", nil, fmt.Errorf("no skills found at %s", pattern) } - defer wsClient.Close() - - acpSession := acp.NewSessionClient(wsClient) - runner := goose.NewACPRunner(acpSession, srv, cfg.Provider, cfg.Model, logDir, workDir) - var pushFn func() error - if creds != nil && repo != nil { - pushFn = func() error { - return git.Push(ctx, creds, repo, creds.Branch) + var combined strings.Builder + for i, m := range matches { + content, err := os.ReadFile(m) + if err != nil { + return "", nil, fmt.Errorf("read skill %s: %w", m, err) + } + logging.Info("discovered skill: %s", m) + if i > 0 { + combined.WriteString("\n\n---\n\n") } + combined.Write(content) } + return combined.String(), matches, nil +} - session := handoff.NewSession( - generateSessionID(), - request, - repoArg, - branchName(creds), - cfg.Model, - cfg.Provider, - ) - - pipelineStatus := "completed" +func buildPrompt(skillContent string) string { + var b strings.Builder - syncSession := func(stepMsg string) { - session.Status = "in_progress" - if err := handoff.WriteSession(workDir, session); err != nil { - logging.Warn("write session.json: %v", err) - } - commitAndPush(repo, pushFn, stepMsg) + if v := os.Getenv("KONVEYOR_PROMPT"); v != "" { + b.WriteString(v) + b.WriteString("\n\n") } - defer func() { - session.Status = pipelineStatus + if v := os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS"); v != "" { + b.WriteString("## Migration Context\n\n") + b.WriteString(v) + b.WriteString("\n\n") + } - if err := handoff.WriteSession(workDir, session); err != nil { - logging.Warn("write session.json: %v", err) - } + b.WriteString("## Skill Instructions\n\n") + b.WriteString(skillContent) + b.WriteString("\n\n") - m := tracker.Generate(pipelineStatus, cfg.Model, cfg.Provider) - if err := metrics.WriteMetrics(runDir, m); err != nil { - logging.Warn("write metrics: %v", err) - } + if v := os.Getenv("KONVEYOR_INSTRUCTIONS"); v != "" { + b.WriteString("## Stage Task\n\n") + b.WriteString(v) + } - if creds != nil && repo != nil { - if _, err := git.CommitAll(repo, "konveyor: session handoff"); err != nil { - logging.Warn("commit handoff: %v", err) - } - if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { - logging.Warn("push: %v", err) - } - } - }() + return b.String() +} - // Step 1: Detect - tracker.StartStep("detect") - detectResult, err := detect.Run(ctx, workDir, runDir) - if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("detect: %w", err) - } - session.Pipeline.Detect = &handoff.DetectStatus{ - StepStatus: handoff.StepStatus{ - Status: "completed", - DurationSeconds: tracker.StepDuration("detect"), - }, - Nodes: detectResult.Graph.Nodes, - Edges: detectResult.Graph.Edges, - Communities: detectResult.Graph.Communities, - } - tracker.EndStep() - syncSession("konveyor: detect complete") - - // Step 2: Plan - tracker.StartStep("plan") - p, err := plan.Run(ctx, workDir, runDir, request, skillDir, runner) - if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("plan: %w", err) - } - session.Pipeline.Plan = &handoff.PlanStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("plan")}, - ItemsPlanned: len(p.Items), - } - tracker.EndStep() - syncSession("konveyor: plan complete") +type stageResult struct { + Stage string `json:"stage"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Summary string `json:"summary,omitempty"` +} - // Step 3: Execute - tracker.StartStep("execute") - items, summary, execCommits, err := execute.Run(ctx, workDir, runDir, recipesDir, p, runner, repo, pushFn) +func readResultStatus(workDir string) (stageResult, int) { + path := filepath.Join(workDir, ".konveyor", "result.json") + data, err := os.ReadFile(path) if err != nil { - pipelineStatus = "failed" - return fmt.Errorf("execute: %w", err) - } - for _, c := range execCommits { - session.Commits = append(session.Commits, handoff.CommitRecord{ - SHA: c.SHA, Message: c.Message, Step: c.Step, - }) - } - session.Pipeline.Execute = &handoff.ExecuteStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("execute")}, - ItemsSucceeded: summary.Ok, - ItemsFailed: summary.Failed, - ItemsSkipped: summary.Skipped, - } - tracker.EndStep() - syncSession("konveyor: execute complete") - - // Step 4: Verify - tracker.StartStep("verify") - vr, err := verify.Run(ctx, workDir, runDir, recipesDir, p.MigrationType, runner) - if err != nil { - logging.Warn("verify: %v", err) - } - verifyOk := vr != nil && vr.BuildOk - session.Pipeline.Verify = &handoff.VerifyStatus{ - StepStatus: handoff.StepStatus{Status: "completed", DurationSeconds: tracker.StepDuration("verify")}, - BuildOk: verifyOk, - TestsPassed: safeTestsPassed(vr), + logging.Warn("no result.json found — treating as failure") + return stageResult{Stage: "unknown", Status: "failed", Reason: "no result.json"}, 1 } - tracker.EndStep() - syncSession("konveyor: verify complete") - // Step 5: Fix Loop - tracker.StartStep("fix-loop") - flReport, flCommits, err := fixloop.Run(ctx, workDir, runDir, recipesDir, p.MigrationType, cfg.MaxFixIterations, runner, repo, pushFn) - if err != nil { - logging.Warn("fix-loop: %v", err) - } - for _, c := range flCommits { - session.Commits = append(session.Commits, handoff.CommitRecord{ - SHA: c.SHA, Message: c.Message, Step: c.Iter, - }) + var results []stageResult + if err := json.Unmarshal(data, &results); err != nil { + logging.Warn("invalid result.json: %v", err) + return stageResult{Stage: "unknown", Status: "failed", Reason: "invalid result.json"}, 1 } - session.Pipeline.FixLoop = &handoff.FixLoopStatus{ - StepStatus: handoff.StepStatus{Status: fixLoopStatus(flReport)}, - Iterations: fixLoopIterations(flReport), - } - tracker.EndStep() - // Write handoff - if err := handoff.WriteHandoff(workDir, session, p, items, vr); err != nil { - logging.Warn("write handoff: %v", err) + if len(results) == 0 { + logging.Warn("result.json is empty — treating as failure") + return stageResult{Stage: "unknown", Status: "failed", Reason: "empty result.json"}, 1 } - if !verifyOk && (flReport == nil || flReport.Status != "success") { - pipelineStatus = "partial" + last := results[len(results)-1] + if last.Status == "succeeded" { + return last, 0 } - logging.Header("Migration Complete") - logging.Ok("status: %s", pipelineStatus) - logging.Info("run dir: %s", runDir) + logging.Err("stage %s failed: %s", last.Stage, last.Reason) + return last, 1 +} - return nil +func skillName(path string) string { + return filepath.Base(filepath.Dir(path)) } -func runStatus(cmd *cobra.Command, args []string) error { - runsDir := rundir.DefaultRunsDir() - latest, err := rundir.Latest(runsDir) - if err != nil { - return fmt.Errorf("no runs found: %w", err) +func writeHandoff(workDir string, skills []string, result stageResult, repo *gogit.Repository) error { + handoffPath := filepath.Join(workDir, ".konveyor", "handoff.md") + if err := os.MkdirAll(filepath.Dir(handoffPath), 0o755); err != nil { + return fmt.Errorf("create .konveyor dir: %w", err) } - logging.Info("latest run: %s", latest) - metricsPath := filepath.Join(latest, "metrics.json") - if data, err := os.ReadFile(metricsPath); err == nil { - fmt.Fprintln(os.Stderr, string(data)) - } else { - logging.Warn("no metrics.json found") - } - return nil -} + existing, _ := os.ReadFile(handoffPath) -func runResume(cmd *cobra.Command, args []string) error { - return fmt.Errorf("resume is not yet implemented") -} + var b strings.Builder -func runStep(cmd *cobra.Command, args []string) error { - return fmt.Errorf("step execution is not yet implemented") -} + if len(existing) > 0 { + b.Write(existing) + b.WriteString("\n---\n\n") + } -func findInstallDir() string { - // Environment override takes priority (set by Dockerfile or operator) - if dir := os.Getenv("KONVEYOR_INSTALL_DIR"); dir != "" { - return dir + fmt.Fprintf(&b, "## Stage: %s\n\n", result.Stage) + fmt.Fprintf(&b, "**Status:** %s \n", result.Status) + fmt.Fprintf(&b, "**Completed:** %s\n", time.Now().UTC().Format(time.RFC3339)) + if result.Reason != "" { + fmt.Fprintf(&b, "**Reason:** %s\n", result.Reason) } - // Default: go up one level from the binary location. - // Binary at /opt/migration-harness/bin/migration-harness - // → installDir = /opt/migration-harness/ - // This matches the bash version's behavior. - exe, err := os.Executable() - if err != nil { - return "." + + if result.Summary != "" { + b.WriteString("\n### Summary\n\n") + b.WriteString(result.Summary) + b.WriteString("\n") } - return filepath.Dir(filepath.Dir(exe)) -} -func branchName(creds *git.Credentials) string { - if creds != nil { - return creds.Branch + b.WriteString("\n### Skills\n\n") + for _, s := range skills { + fmt.Fprintf(&b, "- %s\n", skillName(s)) } - return "" -} -func safeTestsPassed(vr *verify.VerifyResult) int { - if vr != nil { - return vr.TestsPassed + if result.Stage == "plan" { + if steps := planSteps(workDir); len(steps) > 0 { + b.WriteString("\n### Migration Steps (from PLAN.md)\n\n") + for _, s := range steps { + fmt.Fprintf(&b, "- %s\n", s) + } + } } - return 0 -} -func fixLoopStatus(r *fixloop.FixLoopReport) string { - if r != nil { - return r.Status + if n := changedFileCount(repo); n > 0 { + fmt.Fprintf(&b, "\n**Files changed:** %d\n", n) } - return "skipped" -} -func fixLoopIterations(r *fixloop.FixLoopReport) int { - if r != nil { - return r.Iterations + if err := os.WriteFile(handoffPath, []byte(b.String()), 0o644); err != nil { + return fmt.Errorf("write handoff.md: %w", err) } - return 0 + + logging.Ok("wrote %s", handoffPath) + return nil } -func commitAndPush(repo *gogit.Repository, pushFn func() error, msg string) { - if repo == nil { - return - } - hash, err := git.CommitAll(repo, msg) +var excludeDirs = []string{"graphify-out/", ".konveyor/", "target/", ".git/"} + +func changedFileCount(repo *gogit.Repository) int { + wt, err := repo.Worktree() if err != nil { - logging.Warn("commit (%s): %v", msg, err) - return + return 0 } - if hash == (plumbing.Hash{}) { - return - } - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("push (%s): %v", msg, err) + status, err := wt.Status() + if err != nil { + return 0 + } + n := 0 + for path := range status { + excluded := false + for _, prefix := range excludeDirs { + if strings.HasPrefix(path, prefix) { + excluded = true + break + } + } + if !excluded { + n++ } } + return n } -func generateSessionID() string { - b := make([]byte, 16) - rand.Read(b) - return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:]) +var stepRe = regexp.MustCompile(`^###\s+Step\s+\d+`) + +func planSteps(workDir string) []string { + f, err := os.Open(filepath.Join(workDir, "PLAN.md")) + if err != nil { + return nil + } + defer f.Close() + var steps []string + sc := bufio.NewScanner(f) + for sc.Scan() { + line := sc.Text() + if stepRe.MatchString(line) { + title := strings.TrimPrefix(line, "### ") + steps = append(steps, title) + } + } + return steps } diff --git a/harness/go.mod b/harness/go.mod index f654fc2..9e1e21e 100644 --- a/harness/go.mod +++ b/harness/go.mod @@ -3,7 +3,9 @@ module github.com/konveyor/migration-harness go 1.26.2 require ( + github.com/fsnotify/fsnotify v1.10.1 github.com/go-git/go-git/v5 v5.19.1 + github.com/gorilla/websocket v1.5.3 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 ) @@ -18,7 +20,6 @@ require ( github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect @@ -34,5 +35,4 @@ require ( golang.org/x/sys v0.47.0 // indirect golang.org/x/term v0.45.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/harness/go.sum b/harness/go.sum index 3d7369e..a4a6dcb 100644 --- a/harness/go.sum +++ b/harness/go.sum @@ -21,6 +21,8 @@ github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= +github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= diff --git a/harness/internal/acp/session.go b/harness/internal/acp/session.go index 4261e5a..b32c6bf 100644 --- a/harness/internal/acp/session.go +++ b/harness/internal/acp/session.go @@ -153,8 +153,9 @@ type PromptUsage struct { } // SendPrompt sends a prompt to a session and collects the streaming -// response. Returns the final result with all collected message chunks. -func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, content []ContentBlock) (*PromptResult, error) { +// response. maxTurns limits the number of tool calls before the prompt +// is terminated. If maxTurns is 0, no limit is enforced. +func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, content []ContentBlock, maxTurns int) (*PromptResult, error) { req := newRequest("session/prompt", &PromptParams{ SessionID: sessionID, Prompt: content, @@ -165,6 +166,7 @@ func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, conten } result := &PromptResult{} + turnCount := 0 for { select { @@ -174,6 +176,13 @@ func (c *SessionClient) SendPrompt(ctx context.Context, sessionID string, conten return nil, fmt.Errorf("websocket connection closed during prompt") case msg := <-c.ws.Recv(): if msg.IsNotification() { + if isToolCall(msg) { + turnCount++ + if maxTurns > 0 && turnCount >= maxTurns { + logging.Warn("max turns reached (%d), terminating", maxTurns) + return result, fmt.Errorf("max turns reached (%d)", maxTurns) + } + } handlePromptNotification(msg, result) continue } @@ -205,6 +214,21 @@ func extractSessionIDFromNotifications(notifications []*RPCResponse) string { return "" } +func isToolCall(msg *RPCResponse) bool { + if msg.Method != "session/update" { + return false + } + var params struct { + Update struct { + SessionUpdate string `json:"sessionUpdate"` + } `json:"update"` + } + if err := json.Unmarshal(msg.Params, ¶ms); err != nil { + return false + } + return params.Update.SessionUpdate == "tool_call" +} + func handlePromptNotification(msg *RPCResponse, result *PromptResult) { if msg.Method != "session/update" { return diff --git a/harness/internal/config/config.go b/harness/internal/config/config.go index fa44baa..282e87c 100644 --- a/harness/internal/config/config.go +++ b/harness/internal/config/config.go @@ -1,12 +1,8 @@ package config import ( - "bufio" - "fmt" "os" - "path/filepath" "strconv" - "strings" ) const ( @@ -23,17 +19,6 @@ type Config struct { MaxFixIterations int } -func DefaultHome() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".migration-harness") -} - -func DefaultConfigPath() string { - return filepath.Join(DefaultHome(), "config") -} - -// LoadFromEnv builds a Config from the KONVEYOR_MODEL_* env vars injected -// by the agentic controller. Returns nil if the env vars are not set. func LoadFromEnv() *Config { model := os.Getenv("KONVEYOR_MODEL_PRIMARY_MODEL") provider := os.Getenv("KONVEYOR_MODEL_PRIMARY_PROVIDER") @@ -59,87 +44,3 @@ func LoadFromEnv() *Config { return cfg } - -func Load(path string) (*Config, error) { - if cfg := LoadFromEnv(); cfg != nil { - return cfg, nil - } - - f, err := os.Open(path) - if err != nil { - return nil, fmt.Errorf("open config: %w", err) - } - defer f.Close() - - cfg := &Config{ - MaxTurns: DefaultMaxTurns, - MaxFixIterations: DefaultMaxFixIterations, - } - - scanner := bufio.NewScanner(f) - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - - key, value, ok := parseConfigLine(line) - if !ok { - continue - } - - switch key { - case "MH_MODEL": - cfg.Model = value - case "MH_PROVIDER": - cfg.Provider = value - case "MH_MAX_TURNS": - if n, err := strconv.Atoi(value); err == nil { - cfg.MaxTurns = n - } - case "MH_MAX_FIX_ITERATIONS": - if n, err := strconv.Atoi(value); err == nil { - cfg.MaxFixIterations = n - } - } - } - if err := scanner.Err(); err != nil { - return nil, fmt.Errorf("read config: %w", err) - } - - if cfg.Model == "" { - return nil, fmt.Errorf("MH_MODEL is required in %s", path) - } - if cfg.Provider == "" { - return nil, fmt.Errorf("MH_PROVIDER is required in %s", path) - } - - return cfg, nil -} - -func Save(path string, cfg *Config) error { - if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { - return fmt.Errorf("create config dir: %w", err) - } - - content := fmt.Sprintf( - "MH_MODEL=\"%s\"\nMH_PROVIDER=\"%s\"\nMH_MAX_TURNS=\"%d\"\nMH_MAX_FIX_ITERATIONS=\"%d\"\n", - cfg.Model, cfg.Provider, cfg.MaxTurns, cfg.MaxFixIterations, - ) - - if err := os.WriteFile(path, []byte(content), 0600); err != nil { - return fmt.Errorf("write config: %w", err) - } - return nil -} - -func parseConfigLine(line string) (key, value string, ok bool) { - key, value, ok = strings.Cut(line, "=") - if !ok { - return "", "", false - } - key = strings.TrimSpace(key) - value = strings.TrimSpace(value) - value = strings.Trim(value, `"'`) - return key, value, true -} diff --git a/harness/internal/config/config_test.go b/harness/internal/config/config_test.go index 5b3142c..c3c3533 100644 --- a/harness/internal/config/config_test.go +++ b/harness/internal/config/config_test.go @@ -2,120 +2,9 @@ package config import ( "os" - "path/filepath" "testing" ) -func TestRoundTrip(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - original := &Config{ - Model: "gemini-2.5-pro", - Provider: "gcp_vertex_ai", - MaxTurns: 300, - MaxFixIterations: 5, - } - - if err := Save(path, original); err != nil { - t.Fatalf("Save: %v", err) - } - - loaded, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if loaded.Model != original.Model { - t.Errorf("Model = %q, want %q", loaded.Model, original.Model) - } - if loaded.Provider != original.Provider { - t.Errorf("Provider = %q, want %q", loaded.Provider, original.Provider) - } - if loaded.MaxTurns != original.MaxTurns { - t.Errorf("MaxTurns = %d, want %d", loaded.MaxTurns, original.MaxTurns) - } - if loaded.MaxFixIterations != original.MaxFixIterations { - t.Errorf("MaxFixIterations = %d, want %d", loaded.MaxFixIterations, original.MaxFixIterations) - } -} - -func TestLoadDefaults(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_MODEL=\"test-model\"\nMH_PROVIDER=\"test-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - - if cfg.MaxTurns != DefaultMaxTurns { - t.Errorf("MaxTurns = %d, want default %d", cfg.MaxTurns, DefaultMaxTurns) - } - if cfg.MaxFixIterations != DefaultMaxFixIterations { - t.Errorf("MaxFixIterations = %d, want default %d", cfg.MaxFixIterations, DefaultMaxFixIterations) - } -} - -func TestLoadMissingModel(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_PROVIDER=\"test-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - _, err := Load(path) - if err == nil { - t.Fatal("expected error for missing MH_MODEL") - } -} - -func TestLoadMissingProvider(t *testing.T) { - clearKonveyorEnv(t) - dir := t.TempDir() - path := filepath.Join(dir, "config") - - content := "MH_MODEL=\"test-model\"\n" - os.WriteFile(path, []byte(content), 0600) - - _, err := Load(path) - if err == nil { - t.Fatal("expected error for missing MH_PROVIDER") - } -} - -func TestLoadMissingFile(t *testing.T) { - clearKonveyorEnv(t) - _, err := Load("/nonexistent/path/config") - if err == nil { - t.Fatal("expected error for missing file") - } -} - -func TestSavePermissions(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "config") - - cfg := &Config{Model: "m", Provider: "p", MaxTurns: 100, MaxFixIterations: 2} - if err := Save(path, cfg); err != nil { - t.Fatalf("Save: %v", err) - } - - info, err := os.Stat(path) - if err != nil { - t.Fatalf("Stat: %v", err) - } - if perm := info.Mode().Perm(); perm != 0600 { - t.Errorf("permissions = %o, want 0600", perm) - } -} - func clearKonveyorEnv(t *testing.T) { t.Helper() for _, k := range []string{ @@ -187,47 +76,3 @@ func TestLoadFromEnv(t *testing.T) { } }) } - -func TestLoadPrefersEnvOverFile(t *testing.T) { - t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "env-model") - t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "env-provider") - - dir := t.TempDir() - path := filepath.Join(dir, "config") - content := "MH_MODEL=\"file-model\"\nMH_PROVIDER=\"file-provider\"\n" - os.WriteFile(path, []byte(content), 0600) - - cfg, err := Load(path) - if err != nil { - t.Fatalf("Load: %v", err) - } - if cfg.Model != "env-model" { - t.Errorf("Model = %q, want %q (env should take precedence)", cfg.Model, "env-model") - } - if cfg.Provider != "env-provider" { - t.Errorf("Provider = %q, want %q (env should take precedence)", cfg.Provider, "env-provider") - } -} - -func TestParseConfigLineVariants(t *testing.T) { - tests := []struct { - line string - wantKey string - wantVal string - wantOK bool - }{ - {`MH_MODEL="claude"`, "MH_MODEL", "claude", true}, - {`MH_MODEL='claude'`, "MH_MODEL", "claude", true}, - {`MH_MODEL=claude`, "MH_MODEL", "claude", true}, - {`# comment`, "", "", false}, - {`no-equals-sign`, "", "", false}, - } - - for _, tt := range tests { - key, val, ok := parseConfigLine(tt.line) - if ok != tt.wantOK || key != tt.wantKey || val != tt.wantVal { - t.Errorf("parseConfigLine(%q) = (%q, %q, %v), want (%q, %q, %v)", - tt.line, key, val, ok, tt.wantKey, tt.wantVal, tt.wantOK) - } - } -} diff --git a/harness/internal/detect/detect.go b/harness/internal/detect/detect.go deleted file mode 100644 index 60aa41a..0000000 --- a/harness/internal/detect/detect.go +++ /dev/null @@ -1,251 +0,0 @@ -package detect - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -type Manifests struct { - PomXML bool `json:"pom_xml"` - PackageJSON bool `json:"package_json"` - PyprojectTOML bool `json:"pyproject_toml"` - RequirementsTXT bool `json:"requirements_txt"` - SetupPy bool `json:"setup_py"` - GoMod bool `json:"go_mod"` - CargoTOML bool `json:"cargo_toml"` - Gemfile bool `json:"gemfile"` -} - -type FileCounts struct { - Java int `json:"java"` - Python int `json:"python"` - JavaScript int `json:"javascript"` - TypeScript int `json:"typescript"` - Go int `json:"go"` - Rust int `json:"rust"` - CSharp int `json:"csharp"` - Ruby int `json:"ruby"` -} - -type GraphStats struct { - Nodes int `json:"nodes"` - Edges int `json:"edges"` - Communities int `json:"communities"` - GodNodes int `json:"god_nodes"` -} - -type DetectResult struct { - Repo string `json:"repo"` - Manifests Manifests `json:"manifests"` - Files FileCounts `json:"files"` - Graph GraphStats `json:"graph"` - GraphFile string `json:"graph_file"` -} - -type GraphJSON struct { - Nodes []GraphNode `json:"nodes"` - Links []GraphLink `json:"links"` - Communities []GraphCommunity `json:"communities"` -} - -type GraphNode struct { - ID string `json:"id"` - Label string `json:"label"` - SourceFile string `json:"source_file"` - Degree int `json:"degree"` -} - -type GraphLink struct { - Source string `json:"source"` - Target string `json:"target"` - Relation string `json:"relation"` -} - -type GraphCommunity struct { - ID any `json:"id"` - Nodes []string `json:"nodes"` -} - -func Run(ctx context.Context, repoDir, runDir string) (*DetectResult, error) { - logging.Header("Step 1: Detect") - - ensureGitignore(repoDir) - - logging.Info("1a. Checking manifest files...") - manifests := checkManifests(repoDir) - - logging.Info("1b. Building code graph...") - if err := runGraphify(ctx, repoDir); err != nil { - return nil, fmt.Errorf("graphify: %w", err) - } - - graphifyOut := filepath.Join(repoDir, "graphify-out") - graphPath := filepath.Join(graphifyOut, "graph.json") - reportPath := filepath.Join(graphifyOut, "GRAPH_REPORT.md") - - graph, err := parseGraphJSON(graphPath) - if err != nil { - return nil, fmt.Errorf("parse graph.json: %w", err) - } - - logging.Info("1c. Counting source files...") - files := countFiles(graph) - - stats := computeStats(graph) - - result := &DetectResult{ - Repo: repoDir, - Manifests: manifests, - Files: files, - Graph: stats, - GraphFile: "graph.json", - } - - // Copy artifacts to run dir - copyFile(graphPath, filepath.Join(runDir, "graph.json")) - copyFile(reportPath, filepath.Join(runDir, "GRAPH_REPORT.md")) - - detectPath := filepath.Join(runDir, "detect.json") - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal detect.json: %w", err) - } - if err := os.WriteFile(detectPath, data, 0644); err != nil { - return nil, fmt.Errorf("write detect.json: %w", err) - } - - logging.Ok("Detect complete: %d nodes, %d edges, %d communities", - stats.Nodes, stats.Edges, stats.Communities) - return result, nil -} - -func checkManifests(repoDir string) Manifests { - m := Manifests{} - check := func(file string) bool { - _, err := os.Stat(filepath.Join(repoDir, file)) - return err == nil - } - m.PomXML = check("pom.xml") - m.PackageJSON = check("package.json") - m.PyprojectTOML = check("pyproject.toml") - m.RequirementsTXT = check("requirements.txt") - m.SetupPy = check("setup.py") - m.GoMod = check("go.mod") - m.CargoTOML = check("Cargo.toml") - m.Gemfile = check("Gemfile") - return m -} - -func runGraphify(ctx context.Context, repoDir string) error { - graphifyPath, err := exec.LookPath("graphify") - if err != nil { - return fmt.Errorf("graphify not found in PATH: %w", err) - } - - cmd := exec.CommandContext(ctx, graphifyPath, "update", repoDir) - cmd.Stdout = os.Stderr - cmd.Stderr = os.Stderr - - if err := cmd.Run(); err != nil { - return fmt.Errorf("graphify update: %w", err) - } - - graphPath := filepath.Join(repoDir, "graphify-out", "graph.json") - if _, err := os.Stat(graphPath); os.IsNotExist(err) { - return fmt.Errorf("graphify did not produce graph.json at %s", graphPath) - } - - return nil -} - -func parseGraphJSON(path string) (*GraphJSON, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var g GraphJSON - if err := json.Unmarshal(data, &g); err != nil { - return nil, err - } - return &g, nil -} - -func countFiles(graph *GraphJSON) FileCounts { - fc := FileCounts{} - for _, node := range graph.Nodes { - sf := strings.ToLower(node.SourceFile) - switch { - case strings.HasSuffix(sf, ".java"): - fc.Java++ - case strings.HasSuffix(sf, ".py"): - fc.Python++ - case strings.HasSuffix(sf, ".js") || strings.HasSuffix(sf, ".jsx"): - fc.JavaScript++ - case strings.HasSuffix(sf, ".ts") || strings.HasSuffix(sf, ".tsx"): - fc.TypeScript++ - case strings.HasSuffix(sf, ".go"): - fc.Go++ - case strings.HasSuffix(sf, ".rs"): - fc.Rust++ - case strings.HasSuffix(sf, ".cs"): - fc.CSharp++ - case strings.HasSuffix(sf, ".rb"): - fc.Ruby++ - } - } - return fc -} - -func computeStats(graph *GraphJSON) GraphStats { - godCount := 0 - for _, node := range graph.Nodes { - if node.Degree > 20 { - godCount++ - } - } - return GraphStats{ - Nodes: len(graph.Nodes), - Edges: len(graph.Links), - Communities: len(graph.Communities), - GodNodes: godCount, - } -} - -func ensureGitignore(repoDir string) { - path := filepath.Join(repoDir, ".gitignore") - entry := "graphify-out/" - - data, _ := os.ReadFile(path) - if strings.Contains(string(data), entry) { - return - } - - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - - if len(data) > 0 && data[len(data)-1] != '\n' { - f.WriteString("\n") - } - f.WriteString(entry + "\n") -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - logging.Warn("copyFile: read %s: %v", src, err) - return - } - if err := os.WriteFile(dst, data, 0644); err != nil { - logging.Warn("copyFile: write %s: %v", dst, err) - } -} diff --git a/harness/internal/detect/detect_test.go b/harness/internal/detect/detect_test.go deleted file mode 100644 index 5c7daaf..0000000 --- a/harness/internal/detect/detect_test.go +++ /dev/null @@ -1,157 +0,0 @@ -package detect - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestCheckManifests(t *testing.T) { - dir := t.TempDir() - os.WriteFile(filepath.Join(dir, "pom.xml"), []byte("<project/>"), 0644) - os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module test"), 0644) - - m := checkManifests(dir) - - if !m.PomXML { - t.Error("expected pom.xml detected") - } - if !m.GoMod { - t.Error("expected go.mod detected") - } - if m.PackageJSON { - t.Error("expected package.json NOT detected") - } - if m.CargoTOML { - t.Error("expected Cargo.toml NOT detected") - } -} - -func TestCountFiles(t *testing.T) { - graph := &GraphJSON{ - Nodes: []GraphNode{ - {ID: "1", SourceFile: "src/main/java/Foo.java"}, - {ID: "2", SourceFile: "src/main/java/Bar.java"}, - {ID: "3", SourceFile: "app.py"}, - {ID: "4", SourceFile: "index.js"}, - {ID: "5", SourceFile: "App.tsx"}, - {ID: "6", SourceFile: "main.go"}, - }, - } - - fc := countFiles(graph) - - if fc.Java != 2 { - t.Errorf("Java = %d, want 2", fc.Java) - } - if fc.Python != 1 { - t.Errorf("Python = %d, want 1", fc.Python) - } - if fc.JavaScript != 1 { - t.Errorf("JavaScript = %d, want 1", fc.JavaScript) - } - if fc.TypeScript != 1 { - t.Errorf("TypeScript = %d, want 1", fc.TypeScript) - } - if fc.Go != 1 { - t.Errorf("Go = %d, want 1", fc.Go) - } -} - -func TestComputeStats(t *testing.T) { - graph := &GraphJSON{ - Nodes: []GraphNode{ - {ID: "1", Degree: 5}, - {ID: "2", Degree: 25}, - {ID: "3", Degree: 30}, - {ID: "4", Degree: 10}, - }, - Links: []GraphLink{ - {Source: "1", Target: "2"}, - {Source: "2", Target: "3"}, - {Source: "3", Target: "4"}, - }, - Communities: []GraphCommunity{ - {ID: 0, Nodes: []string{"1", "2"}}, - {ID: 1, Nodes: []string{"3", "4"}}, - }, - } - - stats := computeStats(graph) - - if stats.Nodes != 4 { - t.Errorf("Nodes = %d, want 4", stats.Nodes) - } - if stats.Edges != 3 { - t.Errorf("Edges = %d, want 3", stats.Edges) - } - if stats.Communities != 2 { - t.Errorf("Communities = %d, want 2", stats.Communities) - } - if stats.GodNodes != 2 { - t.Errorf("GodNodes = %d, want 2", stats.GodNodes) - } -} - -func TestParseGraphJSON(t *testing.T) { - dir := t.TempDir() - graphPath := filepath.Join(dir, "graph.json") - - graph := GraphJSON{ - Nodes: []GraphNode{ - {ID: "a", Label: "ClassA", SourceFile: "A.java", Degree: 5}, - }, - Links: []GraphLink{ - {Source: "a", Target: "b", Relation: "imports"}, - }, - Communities: []GraphCommunity{ - {ID: 0, Nodes: []string{"a", "b"}}, - }, - } - - data, _ := json.Marshal(graph) - os.WriteFile(graphPath, data, 0644) - - parsed, err := parseGraphJSON(graphPath) - if err != nil { - t.Fatalf("parseGraphJSON: %v", err) - } - - if len(parsed.Nodes) != 1 { - t.Errorf("Nodes = %d, want 1", len(parsed.Nodes)) - } - if parsed.Nodes[0].SourceFile != "A.java" { - t.Errorf("SourceFile = %q, want A.java", parsed.Nodes[0].SourceFile) - } -} - -func TestDetectResultJSON(t *testing.T) { - result := DetectResult{ - Repo: "/path/to/repo", - Manifests: Manifests{PomXML: true, GoMod: true}, - Files: FileCounts{Java: 10, Go: 5}, - Graph: GraphStats{Nodes: 100, Edges: 200, Communities: 5, GodNodes: 2}, - GraphFile: "graph.json", - } - - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var parsed DetectResult - if err := json.Unmarshal(data, &parsed); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if !parsed.Manifests.PomXML { - t.Error("expected pom_xml = true") - } - if parsed.Files.Java != 10 { - t.Errorf("Java = %d, want 10", parsed.Files.Java) - } - if parsed.Graph.Nodes != 100 { - t.Errorf("Nodes = %d, want 100", parsed.Graph.Nodes) - } -} diff --git a/harness/internal/execute/execute.go b/harness/internal/execute/execute.go deleted file mode 100644 index d284457..0000000 --- a/harness/internal/execute/execute.go +++ /dev/null @@ -1,234 +0,0 @@ -package execute - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - - "github.com/konveyor/migration-harness/internal/git" - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/plan" -) - -type ItemResult struct { - N int `json:"n"` - Path string `json:"path"` - Action string `json:"action"` - Status string `json:"status"` - Lesson string `json:"lesson,omitempty"` - ErrorLog string `json:"error_log,omitempty"` - FilesTouched []string `json:"files_touched,omitempty"` -} - -type Summary struct { - Ok int `json:"ok"` - Failed int `json:"failed"` - Skipped int `json:"skipped"` -} - -type CommitRecord struct { - SHA string - Message string - Step int -} - -type PushFn func() error - -func Run(ctx context.Context, repoDir, runDir, recipesDir string, p *plan.Plan, runner goose.Runner, repo *gogit.Repository, pushFn PushFn) ([]ItemResult, *Summary, []CommitRecord, error) { - logging.Header("Step 3: Execute") - - total := len(p.Items) - logging.Info("executing %d items (migration: %s)", total, p.MigrationType) - - execLog := filepath.Join(runDir, "execution-log.md") - initExecutionLog(execLog, p.MigrationType) - - var results []ItemResult - var commits []CommitRecord - summary := &Summary{} - - recipeFile := filepath.Join(recipesDir, "execute.yaml") - - for i, item := range p.Items { - current := i + 1 - outPath := filepath.Join(runDir, fmt.Sprintf("item-%03d.json", item.N)) - - if existing := loadExistingResult(outPath); existing != nil && existing.Status == "ok" { - logging.Ok("(%d/%d) #%d already done, skipping", current, total, item.N) - summary.Ok++ - results = append(results, *existing) - continue - } - - logging.Info("(%d/%d) #%d [%s] %s", current, total, item.N, item.Action, item.Path) - logging.Info(" invoking goose — this may take 15-60s per file...") - - result := executeItem(ctx, runner, recipeFile, repoDir, runDir, item, p.MigrationType) - writeItemResult(outPath, &result) - appendExecutionLog(execLog, &result) - - switch result.Status { - case "ok": - summary.Ok++ - plan.UpdateHintsChecklist(repoDir, item.N) - logging.Ok("(%d/%d) #%d done", current, total, item.N) - if result.Lesson != "" && len(result.Lesson) > 80 { - logging.Info(" lesson: %s...", result.Lesson[:80]) - } else if result.Lesson != "" { - logging.Info(" lesson: %s", result.Lesson) - } - case "skipped": - summary.Skipped++ - logging.Warn("(%d/%d) #%d skipped", current, total, item.N) - default: - summary.Failed++ - logging.Err("(%d/%d) #%d status=%s", current, total, item.N, result.Status) - } - - if repo != nil { - commitMsg := fmt.Sprintf("migrate: #%d %s", item.N, itemLabel(item)) - hash, err := git.CommitAll(repo, commitMsg) - if err != nil { - logging.Warn("git commit after item #%d: %v", item.N, err) - } else if hash != plumbing.ZeroHash { - commits = append(commits, CommitRecord{SHA: hash.String(), Message: commitMsg, Step: item.N}) - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("git push after item #%d: %v", item.N, err) - } - } - } - } - - results = append(results, result) - } - - summaryPath := filepath.Join(runDir, "execute-summary.json") - summaryData, _ := json.MarshalIndent(summary, "", " ") - os.WriteFile(summaryPath, summaryData, 0644) - - copyFile(execLog, filepath.Join(repoDir, "execution-log.md")) - - logging.Ok("Step 3/5 complete: %d ok, %d failed, %d skipped", summary.Ok, summary.Failed, summary.Skipped) - return results, summary, commits, nil -} - -func executeItem(ctx context.Context, runner goose.Runner, recipeFile, repoDir, runDir string, item plan.PlanItem, migrationType string) ItemResult { - result := ItemResult{ - N: item.N, - Path: item.Path, - Action: item.Action, - Status: "failed", - } - - output, err := runner.RunRecipe(ctx, recipeFile, 10, map[string]string{ - "repo": repoDir, - "plan_md_path": filepath.Join(runDir, "PLAN.md"), - "migration_type": migrationType, - "item_n": fmt.Sprintf("%d", item.N), - "item_path": item.Path, - "item_action": item.Action, - }) - if err != nil { - result.ErrorLog = err.Error() - return result - } - - var parsed struct { - Status string `json:"status"` - FilesTouched []string `json:"files_touched"` - Lesson string `json:"lesson"` - ErrorLog string `json:"error_log"` - } - if err := json.Unmarshal(output, &parsed); err != nil { - result.ErrorLog = fmt.Sprintf("parse goose output: %v", err) - return result - } - - result.Status = parsed.Status - result.FilesTouched = parsed.FilesTouched - result.Lesson = parsed.Lesson - result.ErrorLog = parsed.ErrorLog - - if result.Status == "" { - result.Status = "failed" - } - - return result -} - -func loadExistingResult(path string) *ItemResult { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var r ItemResult - if err := json.Unmarshal(data, &r); err != nil { - return nil - } - return &r -} - -func writeItemResult(path string, r *ItemResult) { - data, _ := json.MarshalIndent(r, "", " ") - os.WriteFile(path, data, 0644) -} - -func initExecutionLog(path, migrationType string) { - var b strings.Builder - b.WriteString("# Execution Log\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Started:** %s\n\n", time.Now().Format(time.RFC3339)) - b.WriteString("---\n\n") - os.WriteFile(path, []byte(b.String()), 0644) -} - -func appendExecutionLog(path string, r *ItemResult) { - f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644) - if err != nil { - return - } - defer f.Close() - - fmt.Fprintf(f, "## Step #%d: %s - %s\n\n", r.N, r.Action, r.Path) - fmt.Fprintf(f, "**Status:** %s\n", r.Status) - if len(r.FilesTouched) > 0 { - fmt.Fprintf(f, "**Files touched:** %s\n", strings.Join(r.FilesTouched, ", ")) - } - if r.Lesson != "" { - fmt.Fprintf(f, "\n**Lesson learned:**\n%s\n", r.Lesson) - } - if r.ErrorLog != "" { - fmt.Fprintf(f, "\n**Errors:**\n```\n%s\n```\n", r.ErrorLog) - } - fmt.Fprintf(f, "\n---\n\n") -} - -func itemLabel(item plan.PlanItem) string { - if item.Path != "" { - return item.Path - } - if item.Notes != "" { - if len(item.Notes) > 80 { - return item.Notes[:80] - } - return item.Notes - } - return item.Action -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - return - } - os.WriteFile(dst, data, 0644) -} diff --git a/harness/internal/execute/execute_test.go b/harness/internal/execute/execute_test.go deleted file mode 100644 index 6c59568..0000000 --- a/harness/internal/execute/execute_test.go +++ /dev/null @@ -1,117 +0,0 @@ -package execute - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestLoadExistingResult(t *testing.T) { - dir := t.TempDir() - - t.Run("no file returns nil", func(t *testing.T) { - r := loadExistingResult(filepath.Join(dir, "nonexistent.json")) - if r != nil { - t.Error("expected nil for nonexistent file") - } - }) - - t.Run("valid file returns result", func(t *testing.T) { - result := ItemResult{N: 1, Path: "pom.xml", Status: "ok"} - data, _ := json.Marshal(result) - path := filepath.Join(dir, "item-001.json") - os.WriteFile(path, data, 0644) - - r := loadExistingResult(path) - if r == nil { - t.Fatal("expected non-nil result") - } - if r.Status != "ok" { - t.Errorf("status = %q, want ok", r.Status) - } - }) -} - -func TestWriteItemResult(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "item-001.json") - r := &ItemResult{N: 1, Path: "test.java", Status: "ok", Lesson: "javax -> jakarta"} - writeItemResult(path, r) - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - - var loaded ItemResult - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Lesson != "javax -> jakarta" { - t.Errorf("lesson = %q", loaded.Lesson) - } -} - -func TestInitExecutionLog(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "execution-log.md") - initExecutionLog(path, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - if !strings.Contains(content, "Execution Log") { - t.Error("missing header") - } - if !strings.Contains(content, "java-ee-to-quarkus") { - t.Error("missing migration type") - } -} - -func TestAppendExecutionLog(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "execution-log.md") - os.WriteFile(path, []byte("# Log\n\n"), 0644) - - r := &ItemResult{ - N: 1, - Path: "pom.xml", - Action: "migrate", - Status: "ok", - Lesson: "updated deps", - FilesTouched: []string{"pom.xml"}, - } - appendExecutionLog(path, r) - - data, _ := os.ReadFile(path) - content := string(data) - if !strings.Contains(content, "Step #1") { - t.Error("missing step header") - } - if !strings.Contains(content, "updated deps") { - t.Error("missing lesson") - } - if !strings.Contains(content, "pom.xml") { - t.Error("missing file touched") - } -} - -func TestSummaryJSON(t *testing.T) { - s := Summary{Ok: 10, Failed: 1, Skipped: 2} - data, err := json.Marshal(s) - if err != nil { - t.Fatalf("marshal: %v", err) - } - - var loaded Summary - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Ok != 10 || loaded.Failed != 1 || loaded.Skipped != 2 { - t.Errorf("summary = %+v", loaded) - } -} diff --git a/harness/internal/fixloop/fixloop.go b/harness/internal/fixloop/fixloop.go deleted file mode 100644 index d6e10b2..0000000 --- a/harness/internal/fixloop/fixloop.go +++ /dev/null @@ -1,206 +0,0 @@ -package fixloop - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - gogit "github.com/go-git/go-git/v5" - "github.com/go-git/go-git/v5/plumbing" - - "github.com/konveyor/migration-harness/internal/git" - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/verify" -) - -type FixResult struct { - Fixed bool `json:"fixed"` - FileChanged string `json:"file_changed,omitempty"` - Summary string `json:"summary,omitempty"` -} - -type FixLoopReport struct { - MigrationType string `json:"migration_type"` - Iterations int `json:"iterations"` - Status string `json:"status"` -} - -type CommitRecord struct { - SHA string - Message string - Iter int -} - -type PushFn func() error - -func Run(ctx context.Context, repoDir, runDir, recipesDir, migrationType string, maxIterations int, runner goose.Runner, repo *gogit.Repository, pushFn PushFn) (*FixLoopReport, []CommitRecord, error) { - logging.Header("Step 5: Fix Loop") - - verifyPath := filepath.Join(runDir, "verify.json") - if _, err := os.Stat(verifyPath); os.IsNotExist(err) { - return nil, nil, fmt.Errorf("no verification output found — run step 4 (verify) first") - } - - vr := loadVerifyResult(verifyPath) - if vr != nil && vr.BuildOk { - logging.Ok("build status: SUCCESS (from verification report)") - logging.Ok("fix loop not needed — skipping") - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: 0, - Status: "skipped", - } - logging.Ok("Step 5/5 complete (skipped - build already successful)") - return report, nil, nil - } - - logging.Warn("build status: FAILED (from verification report)") - logging.Info("starting fix loop (max %d iterations)...", maxIterations) - - var commits []CommitRecord - lastIter := 0 - - for iter := 1; iter <= maxIterations; iter++ { - lastIter = iter - verifyIterPath := filepath.Join(runDir, fmt.Sprintf("verify-fix-%d.json", iter)) - - logging.Info("iteration %d/%d — re-verifying...", iter, maxIterations) - iterResult, err := verify.Run(ctx, repoDir, runDir, recipesDir, migrationType, runner) - if err == nil && iterResult.BuildOk && len(iterResult.Errors) == 0 { - logging.Ok("iteration %d/%d — build clean, no errors!", iter, maxIterations) - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: iter, - Status: "success", - } - writeFixReport(runDir, repoDir, report, migrationType) - logging.Ok("Step 5/5 complete") - return report, commits, nil - } - - iterData, _ := json.MarshalIndent(iterResult, "", " ") - os.WriteFile(verifyIterPath, iterData, 0644) - - var currentVerify *verify.VerifyResult - if iter == 1 { - currentVerify = vr - } else { - currentVerify = iterResult - } - - if currentVerify == nil || len(currentVerify.Errors) == 0 { - logging.Warn("iteration %d/%d — build not OK but no errors reported; cannot auto-fix", iter, maxIterations) - break - } - - firstErr := currentVerify.Errors[0] - logging.Info("iteration %d/%d — fixing: %s", iter, maxIterations, firstErr.File) - logging.Info(" error: %s", truncate(firstErr.Message, 100)) - - fixResult := attemptFix(ctx, runner, recipesDir, repoDir, runDir, migrationType, firstErr, iter) - if !fixResult.Fixed { - logging.Err("iteration %d/%d — fix did not succeed (%s)", iter, maxIterations, firstErr.File) - break - } - - logging.Ok("iteration %d/%d — fixed %s", iter, maxIterations, fixResult.FileChanged) - - if repo != nil { - commitMsg := fmt.Sprintf("fix: %s", fixResult.FileChanged) - hash, err := git.CommitAll(repo, commitMsg) - if err != nil { - logging.Warn("git commit after fix #%d: %v", iter, err) - } else if hash != plumbing.ZeroHash { - commits = append(commits, CommitRecord{SHA: hash.String(), Message: commitMsg, Iter: iter}) - if pushFn != nil { - if err := pushFn(); err != nil { - logging.Warn("git push after fix #%d: %v", iter, err) - } - } - } - } - } - - report := &FixLoopReport{ - MigrationType: migrationType, - Iterations: lastIter, - Status: "manual_intervention_needed", - } - writeFixReport(runDir, repoDir, report, migrationType) - logging.Warn("hit max iterations (%d) — manual intervention needed", maxIterations) - return report, commits, nil -} - -func attemptFix(ctx context.Context, runner goose.Runner, recipesDir, repoDir, runDir, migrationType string, verifyErr verify.VerifyError, iter int) FixResult { - fixPath := filepath.Join(runDir, fmt.Sprintf("fix-%d.json", iter)) - recipeFile := filepath.Join(recipesDir, "fix.yaml") - - output, err := runner.RunRecipe(ctx, recipeFile, 8, map[string]string{ - "repo": repoDir, - "verification_report_path": filepath.Join(runDir, "verification-report.md"), - "migration_type": migrationType, - "error_file": verifyErr.File, - "error_message": verifyErr.Message, - }) - - var result FixResult - if err != nil { - result = FixResult{Fixed: false, Summary: err.Error()} - } else if err := json.Unmarshal(output, &result); err != nil { - result = FixResult{Fixed: false, Summary: fmt.Sprintf("parse goose output: %v", err)} - } - - data, _ := json.MarshalIndent(result, "", " ") - os.WriteFile(fixPath, data, 0644) - - return result -} - -func loadVerifyResult(path string) *verify.VerifyResult { - data, err := os.ReadFile(path) - if err != nil { - return nil - } - var r verify.VerifyResult - if err := json.Unmarshal(data, &r); err != nil { - return nil - } - return &r -} - -func writeFixReport(runDir, repoDir string, report *FixLoopReport, migrationType string) { - var b strings.Builder - b.WriteString("# Fix Loop Report\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Iterations:** %d\n", report.Iterations) - fmt.Fprintf(&b, "**Status:** %s\n\n", report.Status) - - if report.Status == "success" { - b.WriteString("## Result\n\nAll compilation errors resolved. Build is now successful.\n") - } else { - b.WriteString("## Next Steps\n\nManual intervention is required. Review the remaining errors in verification-report.md\n") - } - - path := filepath.Join(runDir, "fix-loop-report.md") - os.WriteFile(path, []byte(b.String()), 0644) - copyFile(path, filepath.Join(repoDir, "fix-loop-report.md")) -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - return - } - os.WriteFile(dst, data, 0644) -} - -func truncate(s string, n int) string { - if len(s) <= n { - return s - } - return s[:n] + "..." -} diff --git a/harness/internal/fixloop/fixloop_test.go b/harness/internal/fixloop/fixloop_test.go deleted file mode 100644 index 4c8f340..0000000 --- a/harness/internal/fixloop/fixloop_test.go +++ /dev/null @@ -1,78 +0,0 @@ -package fixloop - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/verify" -) - -func TestLoadVerifyResult(t *testing.T) { - dir := t.TempDir() - - t.Run("missing file returns nil", func(t *testing.T) { - r := loadVerifyResult(filepath.Join(dir, "nope.json")) - if r != nil { - t.Error("expected nil") - } - }) - - t.Run("valid file", func(t *testing.T) { - vr := verify.VerifyResult{BuildOk: true, TestsPassed: 5, TestsTotal: 5} - data, _ := json.Marshal(vr) - path := filepath.Join(dir, "verify.json") - os.WriteFile(path, data, 0644) - - r := loadVerifyResult(path) - if r == nil { - t.Fatal("expected non-nil") - } - if !r.BuildOk { - t.Error("expected build_ok = true") - } - }) -} - -func TestWriteFixReport(t *testing.T) { - runDir := t.TempDir() - repoDir := t.TempDir() - - report := &FixLoopReport{ - MigrationType: "java-ee-to-quarkus", - Iterations: 2, - Status: "success", - } - writeFixReport(runDir, repoDir, report, "java-ee-to-quarkus") - - data, err := os.ReadFile(filepath.Join(runDir, "fix-loop-report.md")) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - if !strings.Contains(content, "Fix Loop Report") { - t.Error("missing header") - } - if !strings.Contains(content, "success") { - t.Error("missing success status") - } - if !strings.Contains(content, "**Iterations:** 2") { - t.Error("missing iteration count") - } - - if _, err := os.Stat(filepath.Join(repoDir, "fix-loop-report.md")); err != nil { - t.Error("report not copied to repo dir") - } -} - -func TestTruncate(t *testing.T) { - if got := truncate("short", 100); got != "short" { - t.Errorf("truncate short = %q", got) - } - if got := truncate("abcdefghij", 5); got != "abcde..." { - t.Errorf("truncate long = %q", got) - } -} diff --git a/harness/internal/git/credentials.go b/harness/internal/git/credentials.go index 1886c83..c7547f5 100644 --- a/harness/internal/git/credentials.go +++ b/harness/internal/git/credentials.go @@ -3,7 +3,6 @@ package git import ( "fmt" "os" - "time" "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -33,7 +32,7 @@ func ReadFromEnv() (*Credentials, error) { branch := os.Getenv("GIT_TARGET_BRANCH") if branch == "" { - branch = fmt.Sprintf("konveyor-migrate-%s", time.Now().Format("20060102-150405")) + return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TARGET_BRANCH is missing") } return &Credentials{ diff --git a/harness/internal/git/git.go b/harness/internal/git/git.go index 2981aaa..02600af 100644 --- a/harness/internal/git/git.go +++ b/harness/internal/git/git.go @@ -6,16 +6,21 @@ import ( "fmt" "net/url" "os" + "strings" "time" gogit "github.com/go-git/go-git/v5" gogitcfg "github.com/go-git/go-git/v5/config" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" + "github.com/konveyor/migration-harness/internal/watcher" ) func Clone(ctx context.Context, cred *Credentials, destDir string) (*gogit.Repository, error) { if _, err := os.Stat(destDir); err == nil { + if !strings.HasPrefix(destDir, "/workspace") && !strings.HasPrefix(destDir, os.TempDir()) { + return nil, fmt.Errorf("refusing to remove %s: not under /workspace or temp", destDir) + } os.RemoveAll(destDir) } @@ -73,15 +78,27 @@ func CheckoutBranch(repo *gogit.Repository, branch string) error { return fmt.Errorf("get worktree: %w", err) } - ref := plumbing.NewBranchReferenceName(branch) + localRef := plumbing.NewBranchReferenceName(branch) + remoteRef := plumbing.NewRemoteReferenceName("origin", branch) + + // If the remote tracking branch exists, create local branch from it. + if hash, err := repo.ResolveRevision(plumbing.Revision(remoteRef)); err == nil { + return wt.Checkout(&gogit.CheckoutOptions{ + Branch: localRef, + Hash: *hash, + Create: true, + }) + } + // Otherwise create a new branch from HEAD. err = wt.Checkout(&gogit.CheckoutOptions{ - Branch: ref, + Branch: localRef, Create: true, }) if err != nil { + // Branch might already exist locally. err = wt.Checkout(&gogit.CheckoutOptions{ - Branch: ref, + Branch: localRef, }) if err != nil { return fmt.Errorf("checkout branch %s: %w", branch, err) @@ -104,8 +121,13 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { return plumbing.ZeroHash, nil } - if err := wt.AddWithOptions(&gogit.AddOptions{All: true}); err != nil { - return plumbing.ZeroHash, fmt.Errorf("add all: %w", err) + for path, s := range status { + if s.Worktree == gogit.Untracked && !watcher.ShouldStageNewFile(path) { + continue + } + if _, err := wt.Add(path); err != nil { + return plumbing.ZeroHash, fmt.Errorf("add %s: %w", path, err) + } } hash, err := wt.Commit(message, &gogit.CommitOptions{ @@ -122,6 +144,10 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { return hash, nil } +// Push force-pushes the branch. Force is intentional: the watcher's +// auto-commits may create non-fast-forward histories, and each stage +// owns its branch exclusively. Concurrent runs on the same branch are +// not supported. func Push(ctx context.Context, cred *Credentials, repo *gogit.Repository, branch string) error { refSpec := gogitcfg.RefSpec(fmt.Sprintf("+refs/heads/%s:refs/heads/%s", branch, branch)) diff --git a/harness/internal/git/git_test.go b/harness/internal/git/git_test.go index c087937..184b58d 100644 --- a/harness/internal/git/git_test.go +++ b/harness/internal/git/git_test.go @@ -82,7 +82,7 @@ func TestCommitAllWithChanges(t *testing.T) { seedBareRepo(t, remoteDir) cloneDir, repo := cloneLocal(t, remoteDir) - os.WriteFile(filepath.Join(cloneDir, "new-file.txt"), []byte("hello\n"), 0644) + os.WriteFile(filepath.Join(cloneDir, "new-file.java"), []byte("class Hello {}\n"), 0644) hash, err := CommitAll(repo, "add new file") if err != nil { @@ -119,6 +119,49 @@ func TestCheckoutBranch(t *testing.T) { } } +func TestCheckoutBranchFromRemote(t *testing.T) { + remoteDir, _ := setupBareRemote(t) + seedBareRepo(t, remoteDir) + + // Push a file to a branch on the remote via a temporary clone. + tmpDir := filepath.Join(t.TempDir(), "pusher") + pusher, err := gogit.PlainClone(tmpDir, false, &gogit.CloneOptions{URL: remoteDir}) + if err != nil { + t.Fatalf("clone for push: %v", err) + } + wt, _ := pusher.Worktree() + wt.Checkout(&gogit.CheckoutOptions{Branch: plumbing.NewBranchReferenceName("remote-branch"), Create: true}) + os.WriteFile(filepath.Join(tmpDir, "PLAN.md"), []byte("# Plan\n"), 0644) + wt.Add("PLAN.md") + wt.Commit("add plan", &gogit.CommitOptions{ + Author: &object.Signature{Name: "test", Email: "test@test.com", When: time.Now()}, + }) + pusher.Push(&gogit.PushOptions{ + RefSpecs: []gogitcfg.RefSpec{"refs/heads/remote-branch:refs/heads/remote-branch"}, + }) + + // Fresh clone (only fetches default branch). + cloneDir := filepath.Join(t.TempDir(), "clone2") + repo, err := gogit.PlainClone(cloneDir, false, &gogit.CloneOptions{URL: remoteDir}) + if err != nil { + t.Fatalf("clone: %v", err) + } + + // CheckoutBranch should resolve the remote tracking branch. + if err := CheckoutBranch(repo, "remote-branch"); err != nil { + t.Fatalf("CheckoutBranch: %v", err) + } + + // Verify we got the remote content. + data, err := os.ReadFile(filepath.Join(cloneDir, "PLAN.md")) + if err != nil { + t.Fatalf("PLAN.md not found after checkout: %v", err) + } + if string(data) != "# Plan\n" { + t.Errorf("PLAN.md content = %q, want %q", string(data), "# Plan\n") + } +} + func TestFullLifecycle(t *testing.T) { remoteDir, _ := setupBareRemote(t) seedBareRepo(t, remoteDir) diff --git a/harness/internal/goose/acp_runner.go b/harness/internal/goose/acp_runner.go deleted file mode 100644 index f08f3f5..0000000 --- a/harness/internal/goose/acp_runner.go +++ /dev/null @@ -1,202 +0,0 @@ -package goose - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/acp" - "github.com/konveyor/migration-harness/internal/logging" -) - -// ACPRunner implements the Runner interface using goose serve (ACP over -// WebSocket) instead of goose run (CLI). The existing CLIRunner stays -// as fallback. All pipeline steps (plan, execute, verify, fixloop) -// work without modification — they call RunRecipe the same way. -type ACPRunner struct { - session *acp.SessionClient - serve *ServeProcess - provider string - model string - logDir string - cwd string -} - -// NewACPRunner creates a runner backed by a goose serve WebSocket connection. -func NewACPRunner(session *acp.SessionClient, serve *ServeProcess, provider, model, logDir, cwd string) *ACPRunner { - return &ACPRunner{ - session: session, - serve: serve, - provider: provider, - model: model, - logDir: logDir, - cwd: cwd, - } -} - -// RunRecipe implements the Runner interface. It parses the recipe YAML, -// renders templates, creates an ACP session, sends the prompt, and -// extracts structured output — producing the same json.RawMessage -// shape that CLIRunner returns. -func (r *ACPRunner) RunRecipe(ctx context.Context, recipe string, maxTurns int, params map[string]string) (json.RawMessage, error) { - if !r.serve.Alive() { - return nil, fmt.Errorf("goose serve is not running") - } - - recipeName := strings.TrimSuffix(filepath.Base(recipe), ".yaml") - logging.Info("ACP: running recipe %s (max %d turns)", recipeName, maxTurns) - - // Parse and render the recipe. For dynamically generated recipes - // (like the plan recipe), the file is already a rendered YAML — - // params may be nil/empty. - rec, err := ParseRecipe(recipe) - if err != nil { - return nil, fmt.Errorf("parse recipe: %w", err) - } - - if params == nil { - params = map[string]string{} - } - - instructions, prompt, err := rec.Render(params) - if err != nil { - return nil, fmt.Errorf("render recipe: %w", err) - } - - // Build the full prompt with schema - fullPrompt, err := rec.BuildACPPrompt(instructions, prompt) - if err != nil { - return nil, fmt.Errorf("build ACP prompt: %w", err) - } - - // Add max turns guidance to the prompt - if maxTurns > 0 { - fullPrompt += fmt.Sprintf("\n\nYou have a maximum of %d tool calls for this task.", maxTurns) - } - - // Create a fresh session for this recipe call (matches CLIRunner - // behavior — each RunRecipe is independent, no shared context) - sessionID, err := r.session.CreateSession(ctx, r.cwd, rec.MCPServers()) - if err != nil { - return nil, fmt.Errorf("create session: %w", err) - } - - // Send the prompt and collect streaming response - result, err := r.session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ - {Type: "text", Text: fullPrompt}, - }) - if err != nil { - return nil, fmt.Errorf("send prompt: %w", err) - } - - // Log the full response - logPath := filepath.Join(r.logDir, fmt.Sprintf("%s-%d.json", recipeName, os.Getpid())) - logData, _ := json.MarshalIndent(result, "", " ") - os.WriteFile(logPath, logData, 0600) - - // Check stop reason - switch result.StopReason { - case "end_turn": - // normal completion - case "max_tokens": - return nil, fmt.Errorf("model hit context limit — try a model with a larger context window") - case "max_turn_requests": - return nil, fmt.Errorf("hit max tool calls (%d) — increase max turns", maxTurns) - case "refusal": - return nil, fmt.Errorf("model refused the task") - default: - logging.Warn("unexpected stop reason: %s", result.StopReason) - } - - // Extract structured output from the collected message chunks - output, err := extractStructuredOutput(result.Chunks) - if err != nil { - return nil, fmt.Errorf("extract output: %w", err) - } - - return output, nil -} - -// extractStructuredOutput attempts to find JSON in the agent's response. -// The agent may return JSON directly, wrapped in markdown code blocks, -// or mixed with other text. -func extractStructuredOutput(chunks []string) (json.RawMessage, error) { - if len(chunks) == 0 { - return nil, fmt.Errorf("no response chunks received") - } - - full := strings.Join(chunks, "") - - // Try parsing the full text as JSON directly - if isJSON(full) { - return json.RawMessage(full), nil - } - - // Try extracting from markdown code blocks: ```json ... ``` - if extracted := extractFromCodeBlock(full); extracted != "" { - if isJSON(extracted) { - return json.RawMessage(extracted), nil - } - } - - // Try finding JSON object anywhere in the text - if extracted := extractJSONObject(full); extracted != "" { - return json.RawMessage(extracted), nil - } - - return nil, fmt.Errorf("no valid JSON found in agent response (%d bytes)", len(full)) -} - -func isJSON(s string) bool { - s = strings.TrimSpace(s) - if len(s) == 0 { - return false - } - var js json.RawMessage - return json.Unmarshal([]byte(s), &js) == nil -} - -func extractFromCodeBlock(s string) string { - markers := []string{"```json\n", "```JSON\n", "```\n"} - for _, start := range markers { - idx := strings.Index(s, start) - if idx < 0 { - continue - } - content := s[idx+len(start):] - end := strings.Index(content, "```") - if end < 0 { - continue - } - return strings.TrimSpace(content[:end]) - } - return "" -} - -func extractJSONObject(s string) string { - // Search from the END of the text — the structured output is - // typically the last JSON object in the response. Searching from - // the start would match JSON embedded in PLAN.md code examples. - for start := strings.LastIndex(s, "{"); start >= 0; start = strings.LastIndex(s[:start], "{") { - depth := 0 - for i := start; i < len(s); i++ { - switch s[i] { - case '{': - depth++ - case '}': - depth-- - if depth == 0 { - candidate := s[start : i+1] - if isJSON(candidate) { - return candidate - } - break - } - } - } - } - return "" -} diff --git a/harness/internal/goose/acp_runner_test.go b/harness/internal/goose/acp_runner_test.go deleted file mode 100644 index c0b2d43..0000000 --- a/harness/internal/goose/acp_runner_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package goose - -import ( - "encoding/json" - "testing" -) - -func TestExtractStructuredOutput_DirectJSON(t *testing.T) { - chunks := []string{`{"status": "ok", "files_touched": ["pom.xml"]}`} - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["status"] != "ok" { - t.Errorf("expected status 'ok', got %v", parsed["status"]) - } -} - -func TestExtractStructuredOutput_CodeBlock(t *testing.T) { - chunks := []string{ - "Here's the result:\n\n```json\n", - `{"fixed": true, "summary": "added import"}`, - "\n```\n\nDone!", - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["fixed"] != true { - t.Errorf("expected fixed=true, got %v", parsed["fixed"]) - } -} - -func TestExtractStructuredOutput_EmbeddedJSON(t *testing.T) { - chunks := []string{ - "I've completed the migration. ", - `The result is: {"build_ok": true, "tests_passed": 5, "tests_total": 5, "errors": [], "summary": "all good"}`, - " Let me know if you need anything else.", - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["build_ok"] != true { - t.Errorf("expected build_ok=true, got %v", parsed["build_ok"]) - } -} - -func TestExtractStructuredOutput_MultiChunkJSON(t *testing.T) { - chunks := []string{ - `{"n": 1, `, - `"path": "pom.xml", `, - `"status": "ok", `, - `"files_touched": ["pom.xml"], `, - `"lesson": "", `, - `"error_log": ""}`, - } - result, err := extractStructuredOutput(chunks) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - var parsed map[string]any - if err := json.Unmarshal(result, &parsed); err != nil { - t.Fatalf("invalid JSON: %v", err) - } - if parsed["status"] != "ok" { - t.Errorf("expected status 'ok', got %v", parsed["status"]) - } -} - -func TestExtractStructuredOutput_NoJSON(t *testing.T) { - chunks := []string{"I completed the task successfully."} - _, err := extractStructuredOutput(chunks) - if err == nil { - t.Error("expected error for non-JSON response") - } -} - -func TestExtractStructuredOutput_Empty(t *testing.T) { - _, err := extractStructuredOutput(nil) - if err == nil { - t.Error("expected error for empty chunks") - } -} diff --git a/harness/internal/goose/goose.go b/harness/internal/goose/goose.go deleted file mode 100644 index 9365596..0000000 --- a/harness/internal/goose/goose.go +++ /dev/null @@ -1,12 +0,0 @@ -package goose - -import ( - "context" - "encoding/json" -) - -// Runner is the interface for invoking goose. ACPRunner is the only -// implementation — it communicates with goose serve over WebSocket. -type Runner interface { - RunRecipe(ctx context.Context, recipe string, maxTurns int, params map[string]string) (json.RawMessage, error) -} diff --git a/harness/internal/goose/lifecycle.go b/harness/internal/goose/lifecycle.go index 2ee2857..1d9df24 100644 --- a/harness/internal/goose/lifecycle.go +++ b/harness/internal/goose/lifecycle.go @@ -2,8 +2,6 @@ package goose import ( "context" - "crypto/rand" - "encoding/hex" "fmt" "net" "os" @@ -52,13 +50,7 @@ func StartServe(ctx context.Context, port int, provider, model, apiKey, endpoint secretKey := os.Getenv("KONVEYOR_ACP_SECRET_KEY") if secretKey == "" { - // REMOVE LATER: local testing only — in production the controller - // provides KONVEYOR_ACP_SECRET_KEY via a K8s Secret. - secretKey, err = generateLocalSecretKey() - if err != nil { - return nil, fmt.Errorf("generate secret key: %w", err) - } - logging.Warn("no KONVEYOR_ACP_SECRET_KEY set, generated local key for testing") + return nil, fmt.Errorf("KONVEYOR_ACP_SECRET_KEY is required") } cmd := exec.CommandContext(ctx, goosePath, "serve", @@ -145,16 +137,6 @@ func FindFreePort() (int, error) { return port, nil } -// REMOVE LATER: local testing only — generates a random secret key -// when KONVEYOR_ACP_SECRET_KEY is not set. -func generateLocalSecretKey() (string, error) { - b := make([]byte, 32) - if _, err := rand.Read(b); err != nil { - return "", err - } - return hex.EncodeToString(b), nil -} - // providerEnv returns the current process environment with LLM provider // credentials translated to the env vars goose expects. Called before // starting goose serve so the process has the right credentials at diff --git a/harness/internal/goose/recipe.go b/harness/internal/goose/recipe.go deleted file mode 100644 index aa61c8d..0000000 --- a/harness/internal/goose/recipe.go +++ /dev/null @@ -1,139 +0,0 @@ -package goose - -import ( - "encoding/json" - "fmt" - "os" - "regexp" - "strings" - - "github.com/konveyor/migration-harness/internal/acp" - "gopkg.in/yaml.v3" -) - -// Recipe represents a goose recipe YAML file. -type Recipe struct { - Version string `yaml:"version"` - Title string `yaml:"title"` - Description string `yaml:"description"` - Settings RecipeSettings `yaml:"settings"` - Parameters []RecipeParam `yaml:"parameters"` - Extensions []RecipeExt `yaml:"extensions"` - Instruction string `yaml:"instructions"` - Prompt string `yaml:"prompt"` - Response RecipeResp `yaml:"response"` -} - -type RecipeSettings struct { - Temperature float64 `yaml:"temperature"` -} - -type RecipeParam struct { - Key string `yaml:"key"` - InputType string `yaml:"input_type"` - Requirement string `yaml:"requirement"` - Description string `yaml:"description"` -} - -type RecipeExt struct { - Type string `yaml:"type"` - Name string `yaml:"name"` - Timeout int `yaml:"timeout"` - Bundled bool `yaml:"bundled"` -} - -type RecipeResp struct { - JSONSchema yaml.Node `yaml:"json_schema"` -} - -// SchemaJSON converts the YAML json_schema node to JSON bytes. -func (r *RecipeResp) SchemaJSON() ([]byte, error) { - if r.JSONSchema.Kind == 0 { - return nil, nil - } - var raw any - if err := r.JSONSchema.Decode(&raw); err != nil { - return nil, err - } - return json.Marshal(raw) -} - -// ParseRecipe reads and parses a goose recipe YAML file. -func ParseRecipe(path string) (*Recipe, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("read recipe %s: %w", path, err) - } - - var r Recipe - if err := yaml.Unmarshal(data, &r); err != nil { - return nil, fmt.Errorf("parse recipe %s: %w", path, err) - } - - return &r, nil -} - -var templatePattern = regexp.MustCompile(`\{\{\s*(\w+)\s*\}\}`) - -// Render substitutes {{ key }} placeholders in instructions and prompt -// with values from params. Returns an error if any required parameter -// is missing. -func (r *Recipe) Render(params map[string]string) (instructions string, prompt string, err error) { - instructions = renderTemplate(r.Instruction, params) - prompt = renderTemplate(r.Prompt, params) - - // Check for unsubstituted placeholders - remaining := templatePattern.FindAllString(instructions+prompt, -1) - if len(remaining) > 0 { - return "", "", fmt.Errorf("unsubstituted template parameters: %v", remaining) - } - - return instructions, prompt, nil -} - -func renderTemplate(tmpl string, params map[string]string) string { - return templatePattern.ReplaceAllStringFunc(tmpl, func(match string) string { - key := strings.TrimSpace(match[2 : len(match)-2]) - if val, ok := params[key]; ok { - return val - } - return match - }) -} - -// MCPServers translates recipe extensions to ACP MCPServer configs. -// For builtin extensions (like "developer"), goose serve already has -// them via --with-builtin. Returns an empty list for builtins. -func (r *Recipe) MCPServers() []acp.MCPServer { - var servers []acp.MCPServer - for _, ext := range r.Extensions { - if ext.Bundled { - continue - } - servers = append(servers, acp.MCPServer{ - Command: ext.Name, - Args: []string{}, - }) - } - return servers -} - -// BuildACPPrompt combines rendered instructions, prompt, and response -// schema into a single prompt string for ACP session/prompt. -func (r *Recipe) BuildACPPrompt(instructions, prompt string) (string, error) { - var b strings.Builder - b.WriteString(instructions) - b.WriteString("\n\n") - b.WriteString(prompt) - - schemaBytes, err := r.Response.SchemaJSON() - if err != nil { - return "", fmt.Errorf("marshal response schema: %w", err) - } - if len(schemaBytes) > 0 { - b.WriteString("\n\nYou MUST return your response as a JSON object matching this schema:\n") - b.Write(schemaBytes) - } - - return b.String(), nil -} diff --git a/harness/internal/goose/recipe_test.go b/harness/internal/goose/recipe_test.go deleted file mode 100644 index 018f836..0000000 --- a/harness/internal/goose/recipe_test.go +++ /dev/null @@ -1,118 +0,0 @@ -package goose - -import ( - "path/filepath" - "runtime" - "strings" - "testing" -) - -func recipesDir() string { - _, thisFile, _, _ := runtime.Caller(0) - return filepath.Join(filepath.Dir(thisFile), "..", "..", "recipes") -} - -func TestParseRecipe(t *testing.T) { - for _, name := range []string{"execute.yaml", "verify.yaml", "fix.yaml"} { - t.Run(name, func(t *testing.T) { - path := filepath.Join(recipesDir(), name) - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe(%s): %v", name, err) - } - if r.Title == "" { - t.Error("expected non-empty title") - } - if len(r.Parameters) == 0 { - t.Error("expected at least one parameter") - } - if r.Instruction == "" { - t.Error("expected non-empty instructions") - } - if r.Prompt == "" { - t.Error("expected non-empty prompt") - } - t.Logf("%s: %d params, %d extensions", r.Title, len(r.Parameters), len(r.Extensions)) - }) - } -} - -func TestRenderRecipe(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - params := map[string]string{ - "repo": "/workspace/coolstore", - "verification_report_path": "/workspace/coolstore/verification-report.md", - "migration_type": "java-ee-to-quarkus", - "error_file": "src/main/java/com/redhat/coolstore/service/OrderService.java", - "error_message": "cannot find symbol: class EntityManager", - } - - instructions, prompt, err := r.Render(params) - if err != nil { - t.Fatalf("Render: %v", err) - } - - if instructions == "" || prompt == "" { - t.Fatal("expected non-empty rendered output") - } - - // Verify templates were substituted - if strings.Contains(instructions, "{{ repo }}") || strings.Contains(instructions, "{{repo}}") { - t.Error("unsubstituted {{ repo }} in instructions") - } - if !strings.Contains(instructions, "/workspace/coolstore") { - t.Error("expected rendered repo path in instructions") - } - if !strings.Contains(prompt, "cannot find symbol") { - t.Error("expected error message in prompt") - } - - t.Logf("Instructions length: %d bytes", len(instructions)) - t.Logf("Prompt length: %d bytes", len(prompt)) -} - -func TestRenderMissingParam(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - // Missing error_file and error_message - params := map[string]string{ - "repo": "/workspace/coolstore", - "verification_report_path": "/run/verify.md", - "migration_type": "java-ee-to-quarkus", - } - - _, _, err = r.Render(params) - if err == nil { - t.Fatal("expected error for missing params") - } - t.Logf("Got expected error: %v", err) -} - -func TestBuildACPPrompt(t *testing.T) { - path := filepath.Join(recipesDir(), "fix.yaml") - r, err := ParseRecipe(path) - if err != nil { - t.Fatalf("ParseRecipe: %v", err) - } - - full, err := r.BuildACPPrompt("do this", "now please") - if err != nil { - t.Fatalf("BuildACPPrompt: %v", err) - } - if !strings.Contains(full, "do this") || !strings.Contains(full, "now please") { - t.Error("expected instructions and prompt in output") - } - if !strings.Contains(full, "fixed") || !strings.Contains(full, "boolean") { - t.Errorf("expected JSON schema with 'fixed' and 'boolean' in output, got: %s", full[len(full)-200:]) - } - t.Logf("Full ACP prompt: %d bytes", len(full)) -} diff --git a/harness/internal/handoff/handoff.go b/harness/internal/handoff/handoff.go deleted file mode 100644 index 47e23cb..0000000 --- a/harness/internal/handoff/handoff.go +++ /dev/null @@ -1,203 +0,0 @@ -package handoff - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/verify" -) - -type StepStatus struct { - Status string `json:"status"` - DurationSeconds int `json:"duration_seconds,omitempty"` -} - -type DetectStatus struct { - StepStatus - Nodes int `json:"nodes,omitempty"` - Edges int `json:"edges,omitempty"` - Communities int `json:"communities,omitempty"` -} - -type PlanStatus struct { - StepStatus - ItemsPlanned int `json:"items_planned,omitempty"` - ReferenceUsed string `json:"reference_used,omitempty"` -} - -type ExecuteStatus struct { - StepStatus - ItemsSucceeded int `json:"items_succeeded,omitempty"` - ItemsFailed int `json:"items_failed,omitempty"` - ItemsSkipped int `json:"items_skipped,omitempty"` -} - -type VerifyStatus struct { - StepStatus - BuildOk bool `json:"build_ok"` - TestsPassed int `json:"tests_passed,omitempty"` - TestsFailed int `json:"tests_failed,omitempty"` -} - -type FixLoopStatus struct { - StepStatus - Iterations int `json:"iterations"` -} - -type Pipeline struct { - Detect *DetectStatus `json:"detect,omitempty"` - Plan *PlanStatus `json:"plan,omitempty"` - Execute *ExecuteStatus `json:"execute,omitempty"` - Verify *VerifyStatus `json:"verify,omitempty"` - FixLoop *FixLoopStatus `json:"fix_loop,omitempty"` -} - -type CommitRecord struct { - SHA string `json:"sha"` - Message string `json:"message"` - Step int `json:"step"` -} - -type Session struct { - SchemaVersion string `json:"schema_version"` - SessionID string `json:"session_id"` - StartedAt time.Time `json:"started_at"` - CompletedAt time.Time `json:"completed_at,omitempty"` - Status string `json:"status"` - MigrationRequest string `json:"migration_request"` - SourceRepo string `json:"source_repo"` - TargetBranch string `json:"target_branch"` - Model string `json:"model"` - Provider string `json:"provider"` - Pipeline Pipeline `json:"pipeline"` - Commits []CommitRecord `json:"commits"` - Errors []string `json:"errors"` -} - -func NewSession(sessionID, request, sourceRepo, targetBranch, model, provider string) *Session { - return &Session{ - SchemaVersion: "1.0", - SessionID: sessionID, - StartedAt: time.Now(), - Status: "in_progress", - MigrationRequest: request, - SourceRepo: sourceRepo, - TargetBranch: targetBranch, - Model: model, - Provider: provider, - Commits: []CommitRecord{}, - Errors: []string{}, - } -} - -func WriteSession(repoDir string, session *Session) error { - konveyorDir := filepath.Join(repoDir, ".konveyor") - if err := os.MkdirAll(konveyorDir, 0755); err != nil { - return fmt.Errorf("create .konveyor dir: %w", err) - } - - data, err := json.MarshalIndent(session, "", " ") - if err != nil { - return fmt.Errorf("marshal session: %w", err) - } - - return os.WriteFile(filepath.Join(konveyorDir, "session.json"), data, 0644) -} - -func WriteHandoff(repoDir string, session *Session, p *plan.Plan, items []execute.ItemResult, vr *verify.VerifyResult) error { - konveyorDir := filepath.Join(repoDir, ".konveyor") - if err := os.MkdirAll(konveyorDir, 0755); err != nil { - return fmt.Errorf("create .konveyor dir: %w", err) - } - - var b strings.Builder - b.WriteString("# Migration Handoff\n\n") - - b.WriteString("## Request\n") - fmt.Fprintf(&b, "%s\n\n", session.MigrationRequest) - - status := session.Status - if len(status) > 0 { - status = strings.ToUpper(status[:1]) + status[1:] - } - fmt.Fprintf(&b, "## Status: %s\n\n", status) - - b.WriteString("## Summary\n") - ok, failed, skipped := countResults(items) - fmt.Fprintf(&b, "- %d of %d items migrated successfully\n", ok, len(items)) - if vr != nil { - if vr.BuildOk { - fmt.Fprintf(&b, "- Build passes, %d tests passing\n", vr.TestsPassed) - } else { - fmt.Fprintf(&b, "- Build failing, %d/%d tests passing\n", vr.TestsPassed, vr.TestsTotal) - } - } - if failed > 0 { - fmt.Fprintf(&b, "- %d item(s) failed\n", failed) - } - if skipped > 0 { - fmt.Fprintf(&b, "- %d item(s) skipped\n", skipped) - } - b.WriteString("\n") - - b.WriteString("## What Was Done\n") - for _, item := range items { - icon := "x" - if item.Status == "ok" { - icon = "check" - } - switch icon { - case "check": - fmt.Fprintf(&b, "%d. [x] %s — %s\n", item.N, item.Path, item.Action) - default: - fmt.Fprintf(&b, "%d. [ ] %s — %s (%s)\n", item.N, item.Path, item.Action, item.Status) - } - } - b.WriteString("\n") - - if failed > 0 || skipped > 0 { - b.WriteString("## What Needs Manual Attention\n") - for _, item := range items { - if item.Status != "ok" { - reason := item.ErrorLog - if reason == "" { - reason = item.Status - } - fmt.Fprintf(&b, "- %s — %s\n", item.Path, reason) - } - } - b.WriteString("\n") - } - - if vr != nil { - b.WriteString("## Verification\n") - if vr.BuildOk { - b.WriteString("- Build: passing\n") - } else { - b.WriteString("- Build: failing\n") - } - fmt.Fprintf(&b, "- Tests: %d passed, %d failed\n\n", vr.TestsPassed, vr.TestsTotal-vr.TestsPassed) - } - - return os.WriteFile(filepath.Join(konveyorDir, "handoff.md"), []byte(b.String()), 0644) -} - -func countResults(items []execute.ItemResult) (ok, failed, skipped int) { - for _, item := range items { - switch item.Status { - case "ok": - ok++ - case "skipped": - skipped++ - default: - failed++ - } - } - return -} diff --git a/harness/internal/handoff/handoff_test.go b/harness/internal/handoff/handoff_test.go deleted file mode 100644 index 59feb75..0000000 --- a/harness/internal/handoff/handoff_test.go +++ /dev/null @@ -1,150 +0,0 @@ -package handoff - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/execute" - "github.com/konveyor/migration-harness/internal/plan" - "github.com/konveyor/migration-harness/internal/verify" -) - -func TestWriteSession(t *testing.T) { - dir := t.TempDir() - s := NewSession("test-id", "Migrate to Quarkus", "https://github.com/org/repo.git", - "konveyor-migrate", "gemini-2.5-pro", "gcp_vertex_ai") - s.Status = "completed" - s.Pipeline.Execute = &ExecuteStatus{ - StepStatus: StepStatus{Status: "completed"}, - ItemsSucceeded: 10, - ItemsFailed: 1, - } - - err := WriteSession(dir, s) - if err != nil { - t.Fatalf("WriteSession: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".konveyor", "session.json")) - if err != nil { - t.Fatalf("read session.json: %v", err) - } - - var loaded Session - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - - if loaded.SchemaVersion != "1.0" { - t.Errorf("schema_version = %q", loaded.SchemaVersion) - } - if loaded.SessionID != "test-id" { - t.Errorf("session_id = %q", loaded.SessionID) - } - if loaded.Status != "completed" { - t.Errorf("status = %q", loaded.Status) - } - if loaded.Pipeline.Execute.ItemsSucceeded != 10 { - t.Errorf("items_succeeded = %d", loaded.Pipeline.Execute.ItemsSucceeded) - } -} - -func TestWriteHandoffSuccess(t *testing.T) { - dir := t.TempDir() - s := NewSession("id", "Migrate to Quarkus", "https://github.com/org/repo.git", - "branch", "model", "provider") - s.Status = "completed" - - p := &plan.Plan{ - MigrationType: "java-ee-to-quarkus", - Items: []plan.PlanItem{ - {N: 1, Path: "pom.xml"}, - {N: 2, Path: "Foo.java"}, - }, - } - items := []execute.ItemResult{ - {N: 1, Path: "pom.xml", Action: "migrate", Status: "ok"}, - {N: 2, Path: "Foo.java", Action: "migrate", Status: "ok"}, - } - vr := &verify.VerifyResult{BuildOk: true, TestsPassed: 5, TestsTotal: 5} - - err := WriteHandoff(dir, s, p, items, vr) - if err != nil { - t.Fatalf("WriteHandoff: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".konveyor", "handoff.md")) - if err != nil { - t.Fatalf("read handoff.md: %v", err) - } - content := string(data) - - checks := []string{ - "Migration Handoff", - "Migrate to Quarkus", - "Completed", - "2 of 2 items migrated", - "Build passes", - "pom.xml", - } - for _, c := range checks { - if !strings.Contains(content, c) { - t.Errorf("missing %q in handoff.md", c) - } - } -} - -func TestWriteHandoffPartial(t *testing.T) { - dir := t.TempDir() - s := NewSession("id", "Migrate", "repo", "branch", "model", "provider") - s.Status = "partial" - - p := &plan.Plan{Items: []plan.PlanItem{ - {N: 1, Path: "a.java"}, - {N: 2, Path: "b.java"}, - }} - items := []execute.ItemResult{ - {N: 1, Path: "a.java", Action: "migrate", Status: "ok"}, - {N: 2, Path: "b.java", Action: "migrate", Status: "failed", ErrorLog: "compilation error"}, - } - - err := WriteHandoff(dir, s, p, items, nil) - if err != nil { - t.Fatalf("WriteHandoff: %v", err) - } - - data, _ := os.ReadFile(filepath.Join(dir, ".konveyor", "handoff.md")) - content := string(data) - - if !strings.Contains(content, "1 of 2 items migrated") { - t.Error("missing partial count") - } - if !strings.Contains(content, "Manual Attention") { - t.Error("missing manual attention section") - } - if !strings.Contains(content, "compilation error") { - t.Error("missing error in manual attention") - } -} - -func TestCountResults(t *testing.T) { - items := []execute.ItemResult{ - {Status: "ok"}, - {Status: "ok"}, - {Status: "failed"}, - {Status: "skipped"}, - } - ok, failed, skipped := countResults(items) - if ok != 2 { - t.Errorf("ok = %d, want 2", ok) - } - if failed != 1 { - t.Errorf("failed = %d, want 1", failed) - } - if skipped != 1 { - t.Errorf("skipped = %d, want 1", skipped) - } -} diff --git a/harness/internal/metrics/metrics.go b/harness/internal/metrics/metrics.go deleted file mode 100644 index 08980cc..0000000 --- a/harness/internal/metrics/metrics.go +++ /dev/null @@ -1,91 +0,0 @@ -package metrics - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "time" -) - -type StepTiming struct { - Step string `json:"step"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - Duration time.Duration `json:"-"` - Seconds float64 `json:"duration_seconds"` -} - -type Metrics struct { - Status string `json:"status"` - Model string `json:"model,omitempty"` - Provider string `json:"provider,omitempty"` - StartedAt time.Time `json:"started_at"` - EndedAt time.Time `json:"ended_at"` - Seconds float64 `json:"total_seconds"` - Steps []StepTiming `json:"steps"` -} - -type Tracker struct { - startedAt time.Time - steps []StepTiming - current *StepTiming -} - -func NewTracker() *Tracker { - return &Tracker{startedAt: time.Now()} -} - -func (t *Tracker) StartStep(name string) { - if t.current != nil { - t.EndStep() - } - t.current = &StepTiming{ - Step: name, - StartedAt: time.Now(), - } -} - -func (t *Tracker) EndStep() { - if t.current == nil { - return - } - t.current.EndedAt = time.Now() - t.current.Duration = t.current.EndedAt.Sub(t.current.StartedAt) - t.current.Seconds = t.current.Duration.Seconds() - t.steps = append(t.steps, *t.current) - t.current = nil -} - -func (t *Tracker) Generate(status, model, provider string) *Metrics { - if t.current != nil { - t.EndStep() - } - now := time.Now() - return &Metrics{ - Status: status, - Model: model, - Provider: provider, - StartedAt: t.startedAt, - EndedAt: now, - Seconds: now.Sub(t.startedAt).Seconds(), - Steps: t.steps, - } -} - -func (t *Tracker) StepDuration(name string) int { - for _, s := range t.steps { - if s.Step == name { - return int(s.Seconds) - } - } - return 0 -} - -func WriteMetrics(runDir string, m *Metrics) error { - data, err := json.MarshalIndent(m, "", " ") - if err != nil { - return fmt.Errorf("marshal metrics: %w", err) - } - return os.WriteFile(filepath.Join(runDir, "metrics.json"), data, 0644) -} diff --git a/harness/internal/metrics/metrics_test.go b/harness/internal/metrics/metrics_test.go deleted file mode 100644 index cd27732..0000000 --- a/harness/internal/metrics/metrics_test.go +++ /dev/null @@ -1,111 +0,0 @@ -package metrics - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - "time" -) - -func TestTrackerLifecycle(t *testing.T) { - tracker := NewTracker() - - tracker.StartStep("detect") - time.Sleep(10 * time.Millisecond) - tracker.EndStep() - - tracker.StartStep("plan") - time.Sleep(10 * time.Millisecond) - tracker.EndStep() - - m := tracker.Generate("completed", "test-model", "test-provider") - - if m.Status != "completed" { - t.Errorf("status = %q", m.Status) - } - if len(m.Steps) != 2 { - t.Fatalf("steps = %d, want 2", len(m.Steps)) - } - if m.Steps[0].Step != "detect" { - t.Errorf("step[0] = %q", m.Steps[0].Step) - } - if m.Steps[0].Seconds <= 0 { - t.Error("expected positive duration for detect") - } - if m.Seconds <= 0 { - t.Error("expected positive total duration") - } -} - -func TestTrackerAutoEndOnGenerate(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - m := tracker.Generate("completed", "test-model", "test-provider") - - if len(m.Steps) != 1 { - t.Fatalf("steps = %d, want 1", len(m.Steps)) - } -} - -func TestTrackerAutoEndOnStartStep(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - tracker.StartStep("plan") - tracker.EndStep() - - m := tracker.Generate("completed", "test-model", "test-provider") - if len(m.Steps) != 2 { - t.Fatalf("steps = %d, want 2", len(m.Steps)) - } -} - -func TestStepDuration(t *testing.T) { - tracker := NewTracker() - tracker.StartStep("detect") - time.Sleep(50 * time.Millisecond) - tracker.EndStep() - - d := tracker.StepDuration("detect") - if d < 0 { - t.Errorf("duration = %d, want >= 0", d) - } - - if tracker.StepDuration("nonexistent") != 0 { - t.Error("expected 0 for nonexistent step") - } -} - -func TestWriteMetrics(t *testing.T) { - dir := t.TempDir() - m := &Metrics{ - Status: "completed", - StartedAt: time.Now(), - EndedAt: time.Now(), - Seconds: 42.5, - Steps: []StepTiming{ - {Step: "detect", Seconds: 10}, - }, - } - - err := WriteMetrics(dir, m) - if err != nil { - t.Fatalf("WriteMetrics: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, "metrics.json")) - if err != nil { - t.Fatalf("read: %v", err) - } - - var loaded Metrics - if err := json.Unmarshal(data, &loaded); err != nil { - t.Fatalf("unmarshal: %v", err) - } - if loaded.Status != "completed" { - t.Errorf("status = %q", loaded.Status) - } - if loaded.Seconds != 42.5 { - t.Errorf("seconds = %f", loaded.Seconds) - } -} diff --git a/harness/internal/plan/approval.go b/harness/internal/plan/approval.go deleted file mode 100644 index 9a6f11f..0000000 --- a/harness/internal/plan/approval.go +++ /dev/null @@ -1,72 +0,0 @@ -package plan - -import ( - "bufio" - "fmt" - "os" - "os/exec" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -type ApprovalResult int - -const ( - Approved ApprovalResult = iota - Edited - Rejected -) - -var AutoApprove bool - -func PromptApproval(planMDPath string) (ApprovalResult, error) { - content, err := os.ReadFile(planMDPath) - if err != nil { - return Rejected, fmt.Errorf("read PLAN.md: %w", err) - } - - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "══════════════════ PLAN ══════════════════") - fmt.Fprint(os.Stderr, string(content)) - fmt.Fprintln(os.Stderr, "══════════════════════════════════════════") - fmt.Fprintln(os.Stderr) - - if AutoApprove { - logging.Ok("plan auto-approved") - return Approved, nil - } - - reader := bufio.NewReader(os.Stdin) - fmt.Fprint(os.Stderr, "Approve and execute? [y/edit/N]: ") - answer, _ := reader.ReadString('\n') - answer = strings.TrimSpace(strings.ToLower(answer)) - - switch answer { - case "y", "yes": - logging.Ok("plan approved") - return Approved, nil - case "edit", "e": - editor := os.Getenv("EDITOR") - if editor == "" { - editor = "vi" - } - parts := strings.Fields(editor) - if len(parts) == 0 { - parts = []string{"vi"} - } - editorArgs := append(parts[1:], planMDPath) - cmd := exec.Command(parts[0], editorArgs...) - cmd.Stdin = os.Stdin - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - if err := cmd.Run(); err != nil { - return Rejected, fmt.Errorf("editor: %w", err) - } - logging.Ok("plan edited") - return Edited, nil - default: - logging.Info("aborted by user") - return Rejected, nil - } -} diff --git a/harness/internal/plan/context.go b/harness/internal/plan/context.go deleted file mode 100644 index 2da28a7..0000000 --- a/harness/internal/plan/context.go +++ /dev/null @@ -1,147 +0,0 @@ -package plan - -import ( - "encoding/json" - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func GatherContext(runDir, skillDir string) (string, error) { - var b strings.Builder - - b.WriteString("=== DETECTION SUMMARY ===\n") - detectData, err := os.ReadFile(filepath.Join(runDir, "detect.json")) - if err != nil { - return "", fmt.Errorf("read detect.json: %w", err) - } - b.Write(detectData) - b.WriteString("\n\n") - - graphPath := filepath.Join(runDir, "graph.json") - if _, err := os.Stat(graphPath); err == nil { - if err := writeGraphContext(&b, graphPath); err != nil { - return "", err - } - } else { - b.WriteString("=== FILE TREE ===\n") - b.WriteString("(Graph not available)\n\n") - } - - refsDir := filepath.Join(skillDir, "references") - if entries, err := os.ReadDir(refsDir); err == nil { - b.WriteString("=== AVAILABLE MIGRATION REFERENCES ===\n") - fmt.Fprintf(&b, "Directory: %s/\n", refsDir) - for _, e := range entries { - if strings.HasSuffix(e.Name(), ".md") { - fmt.Fprintf(&b, " - %s\n", e.Name()) - } - } - b.WriteString("\nRead the reference that matches the detected migration type.\n\n") - } - - return b.String(), nil -} - -func writeGraphContext(b *strings.Builder, graphPath string) error { - data, err := os.ReadFile(graphPath) - if err != nil { - return fmt.Errorf("read graph.json: %w", err) - } - var g detect.GraphJSON - if err := json.Unmarshal(data, &g); err != nil { - return fmt.Errorf("parse graph.json: %w", err) - } - - fileTypes := make(map[string]int) - for _, n := range g.Nodes { - if n.SourceFile == "" { - continue - } - parts := strings.Split(n.SourceFile, ".") - if len(parts) > 1 { - ext := parts[len(parts)-1] - if ext != "" { - fileTypes[ext]++ - } - } - } - - b.WriteString("=== CODE GRAPH OVERVIEW ===\n") - ftJSON, _ := json.Marshal(fileTypes) - fmt.Fprintf(b, `{"nodes":%d,"edges":%d,"communities":%d,"file_types":%s}`, - len(g.Nodes), len(g.Links), len(g.Communities), ftJSON) - b.WriteString("\n\n") - - b.WriteString("=== ARCHITECTURAL LAYERS (communities from graph clustering) ===\n") - fmt.Fprintf(b, "Graphify detected %d architectural clusters via community detection.\n", len(g.Communities)) - b.WriteString("These map to natural boundaries in your codebase (layers, modules, subsystems).\n\n") - - type comSize struct { - id string - size int - } - communityMap := make(map[string]int) - for _, c := range g.Communities { - key := fmt.Sprintf("%v", c.ID) - communityMap[key] += len(c.Nodes) - } - var comms []comSize - for id, size := range communityMap { - comms = append(comms, comSize{id, size}) - } - sort.Slice(comms, func(i, j int) bool { return comms[i].size > comms[j].size }) - b.WriteString("Top 10 communities by size:\n") - for i, c := range comms { - if i >= 10 { - break - } - fmt.Fprintf(b, " - Community %s: %d nodes\n", c.id, c.size) - } - b.WriteString("\n") - - b.WriteString("=== GOD NODES (high-degree abstractions - handle with care) ===\n") - b.WriteString("These nodes have many connections - changes here ripple across the system.\n") - type nodeInfo struct { - label string - degree int - sourceFile string - } - var sorted []nodeInfo - for _, n := range g.Nodes { - sorted = append(sorted, nodeInfo{n.Label, n.Degree, n.SourceFile}) - } - sort.Slice(sorted, func(i, j int) bool { return sorted[i].degree > sorted[j].degree }) - for i, n := range sorted { - if i >= 10 { - break - } - sf := n.sourceFile - if sf == "" { - sf = "unknown" - } - fmt.Fprintf(b, " - %s (%d edges) → %s\n", n.label, n.degree, sf) - } - b.WriteString("\nMark god nodes as HIGH RISK in the migration plan.\n\n") - - b.WriteString("=== FILE TREE (from graph - source + config only) ===\n") - var files []string - seen := make(map[string]bool) - for _, n := range g.Nodes { - if n.SourceFile != "" && !seen[n.SourceFile] { - files = append(files, n.SourceFile) - seen[n.SourceFile] = true - } - } - sort.Strings(files) - for _, f := range files { - fmt.Fprintf(b, "%s\n", f) - } - b.WriteString("\n") - - return nil -} diff --git a/harness/internal/plan/context_test.go b/harness/internal/plan/context_test.go deleted file mode 100644 index cab6470..0000000 --- a/harness/internal/plan/context_test.go +++ /dev/null @@ -1,105 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func TestGatherContextBasic(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{ - Repo: "/test/repo", - Manifests: detect.Manifests{PomXML: true}, - Files: detect.FileCounts{Java: 10}, - Graph: detect.GraphStats{Nodes: 5, Edges: 3}, - } - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - if !strings.Contains(ctx, "DETECTION SUMMARY") { - t.Error("missing DETECTION SUMMARY") - } - if !strings.Contains(ctx, "pom_xml") { - t.Error("missing pom_xml in context") - } -} - -func TestGatherContextWithGraph(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{Files: detect.FileCounts{Java: 5}} - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - graph := detect.GraphJSON{ - Nodes: []detect.GraphNode{ - {ID: "1", Label: "ClassA", SourceFile: "A.java", Degree: 25}, - {ID: "2", Label: "ClassB", SourceFile: "B.java", Degree: 5}, - }, - Links: []detect.GraphLink{ - {Source: "1", Target: "2", Relation: "imports"}, - }, - Communities: []detect.GraphCommunity{ - {ID: 0, Nodes: []string{"1", "2"}}, - }, - } - gdata, _ := json.Marshal(graph) - os.WriteFile(filepath.Join(runDir, "graph.json"), gdata, 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - checks := []string{ - "CODE GRAPH OVERVIEW", - "ARCHITECTURAL LAYERS", - "GOD NODES", - "FILE TREE", - "ClassA", - "A.java", - } - for _, c := range checks { - if !strings.Contains(ctx, c) { - t.Errorf("missing %q in context", c) - } - } -} - -func TestGatherContextWithRefs(t *testing.T) { - runDir := t.TempDir() - skillDir := t.TempDir() - - dr := detect.DetectResult{Files: detect.FileCounts{Java: 1}} - data, _ := json.MarshalIndent(dr, "", " ") - os.WriteFile(filepath.Join(runDir, "detect.json"), data, 0644) - - refsDir := filepath.Join(skillDir, "references") - os.MkdirAll(refsDir, 0755) - os.WriteFile(filepath.Join(refsDir, "javaee-quarkus.md"), []byte("# ref"), 0644) - - ctx, err := GatherContext(runDir, skillDir) - if err != nil { - t.Fatalf("GatherContext: %v", err) - } - - if !strings.Contains(ctx, "AVAILABLE MIGRATION REFERENCES") { - t.Error("missing references section") - } - if !strings.Contains(ctx, "javaee-quarkus.md") { - t.Error("missing reference file name") - } -} diff --git a/harness/internal/plan/hints.go b/harness/internal/plan/hints.go deleted file mode 100644 index 9265b96..0000000 --- a/harness/internal/plan/hints.go +++ /dev/null @@ -1,55 +0,0 @@ -package plan - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" -) - -func WriteHints(repoDir string, plan *Plan, request string) error { - hintsPath := filepath.Join(repoDir, ".goosehints") - - var b strings.Builder - b.WriteString("# AUTO-GENERATED by migration-harness\n") - fmt.Fprintf(&b, "# %s | request: %s\n\n", time.Now().Format(time.RFC3339), request) - - b.WriteString("## TOKEN DISCIPLINE\n") - b.WriteString("- Read ONE file at a time\n") - b.WriteString("- After writing each file: STOP\n") - b.WriteString("- Do NOT re-read migrated files\n") - b.WriteString("- Do NOT compile unless explicitly asked\n\n") - - b.WriteString("## Migration\n") - fmt.Fprintf(&b, "- Source: %s\n", plan.SourceStack) - fmt.Fprintf(&b, "- Target: %s\n", plan.TargetStack) - fmt.Fprintf(&b, "- Type: %s\n\n", plan.MigrationType) - - b.WriteString("## Order\n") - for _, item := range plan.Items { - fmt.Fprintf(&b, "%d. [%s] %s — %s\n", item.N, item.Action, item.Path, item.Notes) - } - b.WriteString("\n") - - b.WriteString("## Checklist\n") - for _, item := range plan.Items { - fmt.Fprintf(&b, "- [ ] %d. %s\n", item.N, item.Path) - } - - return os.WriteFile(hintsPath, []byte(b.String()), 0644) -} - -func UpdateHintsChecklist(repoDir string, itemN int) error { - hintsPath := filepath.Join(repoDir, ".goosehints") - data, err := os.ReadFile(hintsPath) - if err != nil { - return err - } - - old := fmt.Sprintf("- [ ] %d.", itemN) - new := fmt.Sprintf("- [x] %d.", itemN) - content := strings.Replace(string(data), old, new, 1) - - return os.WriteFile(hintsPath, []byte(content), 0644) -} diff --git a/harness/internal/plan/hints_test.go b/harness/internal/plan/hints_test.go deleted file mode 100644 index f850ca5..0000000 --- a/harness/internal/plan/hints_test.go +++ /dev/null @@ -1,68 +0,0 @@ -package plan - -import ( - "os" - "path/filepath" - "strings" - "testing" -) - -func TestWriteHints(t *testing.T) { - dir := t.TempDir() - plan := &Plan{ - MigrationType: "java-ee-to-quarkus", - SourceStack: "Java EE 7", - TargetStack: "Quarkus 3", - Items: []PlanItem{ - {N: 1, Path: "pom.xml", Action: "migrate", Notes: "Update deps"}, - {N: 2, Path: "src/main/java/Foo.java", Action: "migrate", Notes: "javax to jakarta"}, - }, - } - - err := WriteHints(dir, plan, "Migrate to Quarkus") - if err != nil { - t.Fatalf("WriteHints: %v", err) - } - - data, err := os.ReadFile(filepath.Join(dir, ".goosehints")) - if err != nil { - t.Fatalf("read .goosehints: %v", err) - } - content := string(data) - - checks := []string{ - "TOKEN DISCIPLINE", - "Source: Java EE 7", - "Target: Quarkus 3", - "Type: java-ee-to-quarkus", - "1. [migrate] pom.xml", - "- [ ] 1. pom.xml", - "- [ ] 2. src/main/java/Foo.java", - } - for _, check := range checks { - if !strings.Contains(content, check) { - t.Errorf("missing %q in .goosehints", check) - } - } -} - -func TestUpdateHintsChecklist(t *testing.T) { - dir := t.TempDir() - hintsPath := filepath.Join(dir, ".goosehints") - initial := "## Checklist\n- [ ] 1. pom.xml\n- [ ] 2. Foo.java\n" - os.WriteFile(hintsPath, []byte(initial), 0644) - - err := UpdateHintsChecklist(dir, 1) - if err != nil { - t.Fatalf("UpdateHintsChecklist: %v", err) - } - - data, _ := os.ReadFile(hintsPath) - content := string(data) - if !strings.Contains(content, "- [x] 1.") { - t.Error("expected item 1 to be checked") - } - if !strings.Contains(content, "- [ ] 2.") { - t.Error("expected item 2 to remain unchecked") - } -} diff --git a/harness/internal/plan/parser.go b/harness/internal/plan/parser.go deleted file mode 100644 index e976f49..0000000 --- a/harness/internal/plan/parser.go +++ /dev/null @@ -1,246 +0,0 @@ -package plan - -import ( - "regexp" - "strconv" - "strings" -) - -var ( - reStepHeader = regexp.MustCompile(`^### Step (\d+):\s*(.*)`) - reFileField = regexp.MustCompile(`^- File:\s*(.+)`) - reActionField = regexp.MustCompile(`^- Action:\s*(\w+)`) - - reItemLine = regexp.MustCompile("^(\\d+)\\.\\s+(?:\\*\\*(?:DELETE|REMOVE)[:\\*]*\\s*)?`([^`]+)`(.*)") - - reSectionHeader = regexp.MustCompile(`^##\s`) - reNextItem = regexp.MustCompile(`^\d+\.\s+`) - - reMigrationType = map[string]*regexp.Regexp{ - "java-ee-to-quarkus": regexp.MustCompile(`(?i)quarkus|java.?ee|jakarta|weblogic`), - "python2-to-python3": regexp.MustCompile(`(?i)python.?[23]|py2|py3`), - "react-class-to-hooks": regexp.MustCompile(`(?i)react|hooks|class.?component`), - "dotnet-upgrade": regexp.MustCompile(`(?i)\.net|asp\.net|csharp|c#|\.NET\s*(Core|Framework)`), - "spring-boot-upgrade": regexp.MustCompile(`(?i)spring.?boot|spring.?framework`), - } - - reSource = regexp.MustCompile(`(?i)\b(?:from|source)[:\s]+(.+?)(?:\n|→|->)`) - reTarget = regexp.MustCompile(`(?i)(?:\b(?:to|target)[:\s]+|(?:→|->)\s*)(.+?)(?:\n|$)`) -) - -func ParsePlanMD(content string) *Plan { - items := tryFormatA(content) - if len(items) == 0 { - items = tryFormatBC(content) - } - - for i := range items { - items[i].Layer = assignLayer(items[i].Path) - } - - mt := detectMigrationType(content) - src := extractMatch(reSource, content) - tgt := extractMatch(reTarget, content) - - return &Plan{ - MigrationType: mt, - SourceStack: src, - TargetStack: tgt, - Items: items, - } -} - -func tryFormatA(content string) []PlanItem { - lines := strings.Split(content, "\n") - var items []PlanItem - var current *PlanItem - var bodyLines []string - - flushCurrent := func() { - if current != nil { - for _, bl := range bodyLines { - bl = strings.TrimSpace(bl) - if m := reFileField.FindStringSubmatch(bl); m != nil { - current.Path = strings.TrimSpace(m[1]) - } - if m := reActionField.FindStringSubmatch(bl); m != nil { - current.Action = normalizeAction(strings.TrimSpace(m[1])) - } - } - if current.Action == "" { - current.Action = "migrate" - } - items = append(items, *current) - current = nil - bodyLines = nil - } - } - - for _, line := range lines { - if m := reStepHeader.FindStringSubmatch(line); m != nil { - flushCurrent() - n, _ := strconv.Atoi(m[1]) - title := strings.TrimSpace(m[2]) - title = strings.TrimRight(title, " ✅") - risk := "low" - if strings.Contains(title, "⚠️") || strings.Contains(strings.ToUpper(title), "COMPLEX") { - risk = "high" - } - current = &PlanItem{N: n, Notes: title, Risk: risk} - bodyLines = nil - continue - } - if current != nil { - if reSectionHeader.MatchString(line) || strings.HasPrefix(line, "### Step") { - flushCurrent() - } else { - bodyLines = append(bodyLines, line) - } - } - } - flushCurrent() - - return items -} - -func tryFormatBC(content string) []PlanItem { - lines := strings.Split(content, "\n") - var items []PlanItem - - for i, line := range lines { - m := reItemLine.FindStringSubmatch(line) - if m == nil { - continue - } - n, _ := strconv.Atoi(m[1]) - path := strings.TrimSpace(m[2]) - rest := strings.TrimSpace(m[3]) - fullLine := m[0] - - notes := collectNotes(lines, i+1, path) - actionContext := fullLine + " " + notes - action := detectAction(actionContext) - risk := "low" - if strings.Contains(fullLine, "⚠️") || strings.Contains(strings.ToUpper(fullLine), "COMPLEX") { - risk = "high" - } - - items = append(items, PlanItem{ - N: n, - Path: path, - Action: action, - Risk: risk, - Notes: notes, - }) - _ = rest - } - - return items -} - -func collectNotes(lines []string, startIdx int, fallback string) string { - var noteLines []string - for j := startIdx; j < len(lines) && len(noteLines) < 3; j++ { - l := strings.TrimSpace(lines[j]) - if l == "" { - continue - } - if reNextItem.MatchString(l) || reSectionHeader.MatchString(l) { - break - } - if strings.HasPrefix(l, "-") || strings.HasPrefix(l, "*") { - cleaned := strings.TrimLeft(l, "- *") - noteLines = append(noteLines, strings.TrimSpace(cleaned)) - } - } - if len(noteLines) == 0 { - return fallback - } - return strings.Join(noteLines, "; ") -} - -func detectAction(line string) string { - upper := strings.ToUpper(line) - if strings.Contains(upper, "DELETE") || strings.Contains(upper, "REMOVE") { - return "delete" - } - if strings.Contains(upper, "CREATE") { - return "create" - } - return "migrate" -} - -func normalizeAction(action string) string { - switch strings.ToLower(action) { - case "modify": - return "migrate" - case "create": - return "create" - case "delete": - return "delete" - default: - return strings.ToLower(action) - } -} - -func assignLayer(path string) string { - p := strings.ToLower(path) - - buildFiles := []string{"pom.xml", "package.json", "build.gradle", ".csproj", ".sln", - "cargo.toml", "gemfile", "go.mod", "requirements.txt", "pyproject.toml", "setup.py"} - for _, bf := range buildFiles { - if strings.Contains(p, bf) { - return "build" - } - } - - configFiles := []string{"application.properties", "application.yml", "appsettings", - "web.config", ".env", "config.yaml", "config.json", - "persistence.xml", "web.xml", "beans.xml", - "global.asax", "startup.cs", "program.cs"} - for _, cf := range configFiles { - if strings.Contains(p, cf) { - return "config" - } - } - - layerDirs := map[string][]string{ - "model": {"/model/", "/models/", "/domain/", "/entity/", "/entities/"}, - "service": {"/service/", "/services/"}, - "api": {"/rest/", "/controller/", "/controllers/", "/api/", "/endpoint/", "/endpoints/", "/handler/", "/handlers/"}, - "util": {"/utils/", "/util/", "/helper/", "/helpers/", "/common/"}, - "persistence": {"/persistence/", "/repository/", "/repositories/", "/dao/", "/data/"}, - "view": {"/views/", "/pages/"}, - } - for layer, dirs := range layerDirs { - for _, d := range dirs { - if strings.Contains(p, d) { - return layer - } - } - } - - if strings.Contains(p, "weblogic/") { - return "cleanup" - } - - return "unknown" -} - -func detectMigrationType(content string) string { - order := []string{"java-ee-to-quarkus", "python2-to-python3", "react-class-to-hooks", "dotnet-upgrade", "spring-boot-upgrade"} - for _, mt := range order { - if reMigrationType[mt].MatchString(content) { - return mt - } - } - return "custom" -} - -func extractMatch(re *regexp.Regexp, content string) string { - m := re.FindStringSubmatch(content) - if len(m) > 1 { - return strings.TrimSpace(m[1]) - } - return "" -} diff --git a/harness/internal/plan/parser_test.go b/harness/internal/plan/parser_test.go deleted file mode 100644 index 981a657..0000000 --- a/harness/internal/plan/parser_test.go +++ /dev/null @@ -1,193 +0,0 @@ -package plan - -import ( - "testing" -) - -const formatA = `# Migration Plan - -### Step 1: Update build configuration ✅ -- File: pom.xml -- Action: MODIFY -- Convert Maven dependencies from Java EE to Quarkus - -### Step 2: Migrate data model ⚠️ COMPLEX -- File: src/main/java/model/User.java -- Action: MODIFY -- Convert javax.persistence to jakarta.persistence - -### Step 3: Create new config -- File: src/main/resources/application.properties -- Action: CREATE -- Add Quarkus configuration - -## Verification -Build and run tests. -` - -const formatB = "# Migration Plan\n\n" + - "1. `pom.xml`\n" + - " - Convert Maven dependencies to Quarkus BOM\n" + - " - Remove Java EE dependencies\n" + - "2. `src/main/java/model/User.java`\n" + - " - Convert javax to jakarta imports\n" + - "3. `src/main/java/service/UserService.java` ⚠️\n" + - " - Complex EJB to CDI conversion\n" + - "4. `src/main/resources/application.properties`\n" + - " - **CREATE NEW** Quarkus configuration\n\n" + - "## Notes\nDone.\n" - -const formatC = "# Migration Plan\n\n" + - "1. `pom.xml`\n" + - " - Update dependencies\n" + - "28. `src/main/java/model/Entity.java`\n" + - " - Migrate annotations\n" + - "29. **DELETE:** `src/main/webapp/weblogic-application.xml`\n" + - " - Remove WebLogic-specific descriptor\n" - -func TestParseFormatA(t *testing.T) { - plan := ParsePlanMD(formatA) - - if len(plan.Items) != 3 { - t.Fatalf("items = %d, want 3", len(plan.Items)) - } - - item1 := plan.Items[0] - if item1.N != 1 { - t.Errorf("item1.N = %d, want 1", item1.N) - } - if item1.Path != "pom.xml" { - t.Errorf("item1.Path = %q, want pom.xml", item1.Path) - } - if item1.Action != "migrate" { - t.Errorf("item1.Action = %q, want migrate", item1.Action) - } - if item1.Risk != "low" { - t.Errorf("item1.Risk = %q, want low", item1.Risk) - } - if item1.Layer != "build" { - t.Errorf("item1.Layer = %q, want build", item1.Layer) - } - - item2 := plan.Items[1] - if item2.Risk != "high" { - t.Errorf("item2.Risk = %q, want high", item2.Risk) - } - if item2.Layer != "model" { - t.Errorf("item2.Layer = %q, want model", item2.Layer) - } - - item3 := plan.Items[2] - if item3.Action != "create" { - t.Errorf("item3.Action = %q, want create", item3.Action) - } - if item3.Layer != "config" { - t.Errorf("item3.Layer = %q, want config", item3.Layer) - } -} - -func TestParseFormatB(t *testing.T) { - plan := ParsePlanMD(formatB) - - if len(plan.Items) != 4 { - t.Fatalf("items = %d, want 4", len(plan.Items)) - } - - if plan.Items[0].Path != "pom.xml" { - t.Errorf("item0.Path = %q, want pom.xml", plan.Items[0].Path) - } - if plan.Items[0].Layer != "build" { - t.Errorf("item0.Layer = %q, want build", plan.Items[0].Layer) - } - - if plan.Items[2].Risk != "high" { - t.Errorf("item2.Risk = %q, want high (⚠️)", plan.Items[2].Risk) - } - if plan.Items[2].Layer != "service" { - t.Errorf("item2.Layer = %q, want service", plan.Items[2].Layer) - } - - if plan.Items[3].Action != "create" { - t.Errorf("item3.Action = %q, want create", plan.Items[3].Action) - } -} - -func TestParseFormatC_Delete(t *testing.T) { - plan := ParsePlanMD(formatC) - - if len(plan.Items) != 3 { - t.Fatalf("items = %d, want 3", len(plan.Items)) - } - - del := plan.Items[2] - if del.N != 29 { - t.Errorf("del.N = %d, want 29", del.N) - } - if del.Action != "delete" { - t.Errorf("del.Action = %q, want delete", del.Action) - } - if del.Path != "src/main/webapp/weblogic-application.xml" { - t.Errorf("del.Path = %q", del.Path) - } -} - -func TestMigrationType(t *testing.T) { - tests := []struct { - content string - want string - }{ - {"Migrate from Java EE to Quarkus", "java-ee-to-quarkus"}, - {"Convert WebLogic app to Jakarta EE", "java-ee-to-quarkus"}, - {"Upgrade from Python 2 to Python 3", "python2-to-python3"}, - {"Convert React class components to hooks", "react-class-to-hooks"}, - {"Upgrade .NET Framework to .NET Core", "dotnet-upgrade"}, - {"Migrate Spring Boot 2 to Spring Boot 3", "spring-boot-upgrade"}, - {"Something entirely custom", "custom"}, - } - - for _, tt := range tests { - got := detectMigrationType(tt.content) - if got != tt.want { - t.Errorf("detectMigrationType(%q) = %q, want %q", tt.content[:30], got, tt.want) - } - } -} - -func TestAssignLayer(t *testing.T) { - tests := []struct { - path string - want string - }{ - {"pom.xml", "build"}, - {"package.json", "build"}, - {"src/main/resources/application.properties", "config"}, - {"src/main/java/model/User.java", "model"}, - {"src/main/java/service/UserService.java", "service"}, - {"src/main/java/rest/UserEndpoint.java", "api"}, - {"src/main/java/controller/HomeController.java", "api"}, - {"src/main/java/utils/StringHelper.java", "util"}, - {"src/main/java/repository/UserRepo.java", "persistence"}, - {"src/main/webapp/views/index.jsp", "view"}, - {"src/main/java/SomeRandom.java", "unknown"}, - } - - for _, tt := range tests { - got := assignLayer(tt.path) - if got != tt.want { - t.Errorf("assignLayer(%q) = %q, want %q", tt.path, got, tt.want) - } - } -} - -func TestCollectNotes(t *testing.T) { - lines := []string{ - "1. `pom.xml`", - " - Convert Maven dependencies", - " - Remove old deps", - "2. `next.java`", - } - notes := collectNotes(lines, 1, "fallback") - if notes == "fallback" { - t.Error("expected notes from bullet points, got fallback") - } -} diff --git a/harness/internal/plan/plan.go b/harness/internal/plan/plan.go deleted file mode 100644 index 0b93e33..0000000 --- a/harness/internal/plan/plan.go +++ /dev/null @@ -1,129 +0,0 @@ -package plan - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" -) - -func Run(ctx context.Context, repoDir, runDir, request string, skillDir string, runner goose.Runner) (*Plan, error) { - logging.Header("Step 2: Plan") - - logging.Info("2a. gathering project context...") - contextText, err := GatherContext(runDir, skillDir) - if err != nil { - return nil, fmt.Errorf("gather context: %w", err) - } - ctxPath := filepath.Join(runDir, "plan-context.txt") - os.WriteFile(ctxPath, []byte(contextText), 0644) - logging.Ok("2a. gathered %d bytes of project context", len(contextText)) - - logging.Info("2b. loading planner skill and migration references...") - refCount := countRefs(skillDir) - logging.Ok("2b. loaded planner skill + %d reference doc(s)", refCount) - - logging.Info("2c. building plan recipe...") - recipe := RenderRecipe(repoDir, request, contextText, skillDir) - recipePath := filepath.Join(runDir, "plan-recipe.yaml") - os.WriteFile(recipePath, []byte(recipe), 0644) - logging.Ok("2c. recipe ready (%dKB)", len(recipe)/1024) - - turns := CalcPlanTurns(runDir, skillDir) - logging.Info("2d. running goose planner (max %d turns)...", turns) - logging.Info(" this is the LLM step — may take 30-90s depending on model") - - _, err = runner.RunRecipe(ctx, recipePath, turns, nil) - if err != nil { - logging.Warn("goose planner: %v", err) - } - - planMD := filepath.Join(repoDir, "PLAN.md") - if _, err := os.Stat(planMD); os.IsNotExist(err) { - return nil, fmt.Errorf("plan failed — PLAN.md not written; see %s/logs/", runDir) - } - - copyFile(planMD, filepath.Join(runDir, "PLAN.md")) - logging.Ok("2e. PLAN.md written") - - logging.Info("2f. parsing PLAN.md → plan.json...") - content, err := os.ReadFile(planMD) - if err != nil { - return nil, fmt.Errorf("read PLAN.md: %w", err) - } - plan := ParsePlanMD(string(content)) - - planJSON, err := json.MarshalIndent(plan, "", " ") - if err != nil { - return nil, fmt.Errorf("marshal plan.json: %w", err) - } - planJSONPath := filepath.Join(runDir, "plan.json") - if err := os.WriteFile(planJSONPath, planJSON, 0644); err != nil { - return nil, fmt.Errorf("write plan.json: %w", err) - } - logging.Ok("2f. parsed %d items from PLAN.md", len(plan.Items)) - - approval, err := PromptApproval(planMD) - if err != nil { - return nil, fmt.Errorf("approval: %w", err) - } - switch approval { - case Rejected: - return nil, fmt.Errorf("plan rejected by user") - case Edited: - copyFile(planMD, filepath.Join(runDir, "PLAN.md")) - content, err = os.ReadFile(planMD) - if err != nil { - logging.Warn("re-read edited PLAN.md: %v", err) - } - plan = ParsePlanMD(string(content)) - planJSON, err = json.MarshalIndent(plan, "", " ") - if err != nil { - logging.Warn("re-marshal plan.json: %v", err) - } else if err := os.WriteFile(planJSONPath, planJSON, 0644); err != nil { - logging.Warn("re-write plan.json: %v", err) - } - logging.Ok("2g. plan edited and re-parsed") - case Approved: - logging.Ok("2g. plan approved") - } - - logging.Info("2h. writing .goosehints...") - if err := WriteHints(repoDir, plan, request); err != nil { - return nil, fmt.Errorf("write hints: %w", err) - } - logging.Ok("2h. .goosehints written") - - logging.Ok("Step 2/5 complete") - return plan, nil -} - -func countRefs(skillDir string) int { - refsDir := filepath.Join(skillDir, "references") - entries, err := os.ReadDir(refsDir) - if err != nil { - return 0 - } - count := 0 - for _, e := range entries { - if filepath.Ext(e.Name()) == ".md" { - count++ - } - } - return count -} - -func copyFile(src, dst string) { - data, err := os.ReadFile(src) - if err != nil { - logging.Warn("copyFile: read %s: %v", src, err) - return - } - if err := os.WriteFile(dst, data, 0644); err != nil { - logging.Warn("copyFile: write %s: %v", dst, err) - } -} diff --git a/harness/internal/plan/recipe.go b/harness/internal/plan/recipe.go deleted file mode 100644 index 2d76904..0000000 --- a/harness/internal/plan/recipe.go +++ /dev/null @@ -1,113 +0,0 @@ -package plan - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/logging" -) - -func RenderRecipe(repoDir, request, context, skillDir string) string { - var plannerSkill string - skillPath := filepath.Join(skillDir, "skills", "migration-plan", "SKILL.md") - data, err := os.ReadFile(skillPath) - if err != nil { - logging.Warn("failed to read planner skill at %s: %v", skillPath, err) - plannerSkill = indentBlock(fmt.Sprintf("WARNING: planner skill not found at %s", skillPath), 4) - } else { - plannerSkill = indentBlock(string(data), 4) - } - - indentedCtx := indentBlock(context, 4) - - var b strings.Builder - b.WriteString(`version: "1.0.0" -title: "Migration plan" -description: "Generate PLAN.md using the planner skill." - -settings: - temperature: 1 - -extensions: - - type: builtin - name: developer - timeout: 600 - bundled: true - -instructions: | - You are the planner sub-skill. Your ONLY job is to produce PLAN.md in the - repo root. Do NOT modify any source files. - - === PLANNER SKILL === -`) - b.WriteString(plannerSkill) - b.WriteString("\n === END PLANNER SKILL ===\n\n") - b.WriteString(" === PRE-GATHERED CONTEXT ===\n") - b.WriteString(" The following has been pre-collected: detect.json, source file tree,\n") - b.WriteString(" and build manifests. Do NOT re-run these discovery commands.\n") - b.WriteString(indentedCtx) - b.WriteString("\n === END PRE-GATHERED CONTEXT ===\n\n") - b.WriteString(` YOUR JOB (follow this order strictly): - - PHASE 1 — Quick scan (max 3 reads): - 1. Read the pre-gathered context above (detect.json, file tree) — already done. - 2. Read the build manifest (pom.xml, package.json, .csproj, etc.) — 1 read. - 3. Check AVAILABLE REFERENCES list. If one matches, read it — 1 read. - You MUST report which reference file you read in your final response. - - PHASE 2 — Write a DRAFT PLAN.md: - 4. Based on what you know so far, write PLAN.md to `) - b.WriteString(repoDir) - b.WriteString(`/PLAN.md NOW. - For files you haven't read, make your best guess from the file name - and mark uncertain steps with ⚠️. - - PHASE 3 — Refine (max 5 reads): - 5. Read ONLY the source files where your guess might be wrong — complex - patterns like MDBs, JNDI lookups, lifecycle listeners, etc. - 6. Update PLAN.md with corrections based on what you read. - If no corrections needed, skip this phase. - - RULES: - - Write PLAN.md BEFORE reading source files. Draft first, refine after. - - Max 8 file reads total across all phases. - - If uncertain about a file, mark it ⚠️ and move on. Do NOT read every file. - - In your response, report which reference file you read (or "none" if no match). - -prompt: | - Repo: `) - b.WriteString(repoDir) - b.WriteString("\n Migration request: ") - b.WriteString(request) - b.WriteString(` - - Follow the planner skill phases. All discovery data and references are - already in your instructions. Read only complex source files you need, - then write PLAN.md. - -response: - json_schema: - type: object - required: [plan_written, step_count, reference_used] - properties: - plan_written: { type: boolean } - step_count: { type: integer } - complex_count: { type: integer } - reference_used: { type: string, description: "Name of reference file read, or 'none'" } - summary: { type: string } -`) - return b.String() -} - -func indentBlock(s string, spaces int) string { - prefix := strings.Repeat(" ", spaces) - lines := strings.Split(s, "\n") - for i, l := range lines { - if l != "" { - lines[i] = prefix + l - } - } - return strings.Join(lines, "\n") -} diff --git a/harness/internal/plan/turns.go b/harness/internal/plan/turns.go deleted file mode 100644 index a701711..0000000 --- a/harness/internal/plan/turns.go +++ /dev/null @@ -1,102 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func CalcPlanTurns(runDir, skillDir string) int { - turns := 10 - - detectPath := filepath.Join(runDir, "detect.json") - data, err := os.ReadFile(detectPath) - if err != nil { - return clampTurns(turns) - } - - var dr detect.DetectResult - if err := json.Unmarshal(data, &dr); err != nil { - return clampTurns(turns) - } - - if !hasMatchingReference(skillDir, dr) { - turns += 2 - } - - primaryFiles := maxLangCount(dr.Files) - turns += primaryFiles / 50 - - langCount := countLanguages(dr.Files) - if langCount > 2 { - turns += 3 - } - - return clampTurns(turns) -} - -func hasMatchingReference(skillDir string, dr detect.DetectResult) bool { - refsDir := filepath.Join(skillDir, "references") - if _, err := os.Stat(refsDir); err != nil { - return false - } - - if dr.Manifests.PomXML { - if _, err := os.Stat(filepath.Join(refsDir, "javaee-quarkus.md")); err == nil { - return true - } - } - - if dr.Files.Python > 0 { - entries, err := os.ReadDir(refsDir) - if err == nil { - for _, e := range entries { - if strings.Contains(strings.ToLower(e.Name()), "python") { - return true - } - } - } - } - - if dr.Files.CSharp > 0 { - if _, err := os.Stat(filepath.Join(refsDir, "dotnet-framework-to-core.md")); err == nil { - return true - } - } - - return false -} - -func maxLangCount(fc detect.FileCounts) int { - counts := []int{fc.Java, fc.Python, fc.JavaScript, fc.TypeScript, fc.Go, fc.Rust, fc.CSharp, fc.Ruby} - max := 0 - for _, c := range counts { - if c > max { - max = c - } - } - return max -} - -func countLanguages(fc detect.FileCounts) int { - n := 0 - for _, c := range []int{fc.Java, fc.Python, fc.JavaScript, fc.TypeScript, fc.Go, fc.Rust, fc.CSharp, fc.Ruby} { - if c > 0 { - n++ - } - } - return n -} - -func clampTurns(turns int) int { - if turns < 12 { - return 12 - } - if turns > 50 { - return 50 - } - return turns -} diff --git a/harness/internal/plan/turns_test.go b/harness/internal/plan/turns_test.go deleted file mode 100644 index d7f9a29..0000000 --- a/harness/internal/plan/turns_test.go +++ /dev/null @@ -1,79 +0,0 @@ -package plan - -import ( - "encoding/json" - "os" - "path/filepath" - "testing" - - "github.com/konveyor/migration-harness/internal/detect" -) - -func writeDetectJSON(t *testing.T, dir string, dr detect.DetectResult) { - t.Helper() - data, _ := json.Marshal(dr) - os.WriteFile(filepath.Join(dir, "detect.json"), data, 0644) -} - -func TestCalcPlanTurnsDefault(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 10}, - Manifests: detect.Manifests{PomXML: true}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 12 { - t.Errorf("turns = %d, want >= 12", turns) - } -} - -func TestCalcPlanTurnsMultiLang(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 10, Python: 5, Go: 3}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 15 { - t.Errorf("turns = %d, want >= 15 for multi-lang", turns) - } -} - -func TestCalcPlanTurnsLargeProject(t *testing.T) { - runDir := t.TempDir() - writeDetectJSON(t, runDir, detect.DetectResult{ - Files: detect.FileCounts{Java: 200}, - }) - - turns := CalcPlanTurns(runDir, "/nonexistent") - if turns < 14 { - t.Errorf("turns = %d, want >= 14 for 200 files", turns) - } -} - -func TestCalcPlanTurnsClamp(t *testing.T) { - if c := clampTurns(5); c != 12 { - t.Errorf("clampTurns(5) = %d, want 12", c) - } - if c := clampTurns(100); c != 50 { - t.Errorf("clampTurns(100) = %d, want 50", c) - } - if c := clampTurns(25); c != 25 { - t.Errorf("clampTurns(25) = %d, want 25", c) - } -} - -func TestMaxLangCount(t *testing.T) { - fc := detect.FileCounts{Java: 100, Python: 50, Go: 200} - if m := maxLangCount(fc); m != 200 { - t.Errorf("maxLangCount = %d, want 200", m) - } -} - -func TestCountLanguages(t *testing.T) { - fc := detect.FileCounts{Java: 10, Python: 0, Go: 5} - if c := countLanguages(fc); c != 2 { - t.Errorf("countLanguages = %d, want 2", c) - } -} diff --git a/harness/internal/plan/types.go b/harness/internal/plan/types.go deleted file mode 100644 index 9ac9fa6..0000000 --- a/harness/internal/plan/types.go +++ /dev/null @@ -1,17 +0,0 @@ -package plan - -type Plan struct { - MigrationType string `json:"migration_type"` - SourceStack string `json:"source_stack"` - TargetStack string `json:"target_stack"` - Items []PlanItem `json:"items"` -} - -type PlanItem struct { - N int `json:"n"` - Path string `json:"path"` - Action string `json:"action"` - Risk string `json:"risk"` - Notes string `json:"notes"` - Layer string `json:"layer"` -} diff --git a/harness/internal/rundir/rundir.go b/harness/internal/rundir/rundir.go deleted file mode 100644 index 87a9c86..0000000 --- a/harness/internal/rundir/rundir.go +++ /dev/null @@ -1,52 +0,0 @@ -package rundir - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "time" -) - -func New(runsDir, repoName string) (string, error) { - ts := time.Now().Format("20060102-150405") - dir := filepath.Join(runsDir, fmt.Sprintf("%s-%s", repoName, ts)) - logsDir := filepath.Join(dir, "logs") - if err := os.MkdirAll(logsDir, 0755); err != nil { - return "", fmt.Errorf("create run dir: %w", err) - } - return dir, nil -} - -func Latest(runsDir string) (string, error) { - entries, err := os.ReadDir(runsDir) - if err != nil { - return "", fmt.Errorf("read runs dir: %w", err) - } - - var dirs []os.DirEntry - for _, e := range entries { - if e.IsDir() { - dirs = append(dirs, e) - } - } - if len(dirs) == 0 { - return "", fmt.Errorf("no runs found in %s", runsDir) - } - - sort.Slice(dirs, func(i, j int) bool { - infoI, _ := dirs[i].Info() - infoJ, _ := dirs[j].Info() - if infoI == nil || infoJ == nil { - return dirs[i].Name() < dirs[j].Name() - } - return infoI.ModTime().After(infoJ.ModTime()) - }) - - return filepath.Join(runsDir, dirs[0].Name()), nil -} - -func DefaultRunsDir() string { - home, _ := os.UserHomeDir() - return filepath.Join(home, ".migration-harness", "runs") -} diff --git a/harness/internal/rundir/rundir_test.go b/harness/internal/rundir/rundir_test.go deleted file mode 100644 index 7ca0908..0000000 --- a/harness/internal/rundir/rundir_test.go +++ /dev/null @@ -1,52 +0,0 @@ -package rundir - -import ( - "os" - "path/filepath" - "testing" - "time" -) - -func TestNew(t *testing.T) { - dir := t.TempDir() - - runDir, err := New(dir, "myapp") - if err != nil { - t.Fatalf("New: %v", err) - } - - if _, err := os.Stat(runDir); os.IsNotExist(err) { - t.Fatalf("run dir not created: %s", runDir) - } - - logsDir := filepath.Join(runDir, "logs") - if _, err := os.Stat(logsDir); os.IsNotExist(err) { - t.Fatalf("logs dir not created: %s", logsDir) - } -} - -func TestLatest(t *testing.T) { - dir := t.TempDir() - - os.MkdirAll(filepath.Join(dir, "app-older"), 0755) - time.Sleep(10 * time.Millisecond) - os.MkdirAll(filepath.Join(dir, "app-newer"), 0755) - - latest, err := Latest(dir) - if err != nil { - t.Fatalf("Latest: %v", err) - } - - if filepath.Base(latest) != "app-newer" { - t.Errorf("Latest = %s, want app-newer", filepath.Base(latest)) - } -} - -func TestLatestEmpty(t *testing.T) { - dir := t.TempDir() - - _, err := Latest(dir) - if err == nil { - t.Fatal("expected error for empty runs dir") - } -} diff --git a/harness/internal/verify/verify.go b/harness/internal/verify/verify.go deleted file mode 100644 index 61c3404..0000000 --- a/harness/internal/verify/verify.go +++ /dev/null @@ -1,158 +0,0 @@ -package verify - -import ( - "context" - "encoding/json" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" -) - -type VerifyError struct { - File string `json:"file"` - Line int `json:"line,omitempty"` - Message string `json:"message"` -} - -type VerifyResult struct { - BuildOk bool `json:"build_ok"` - TestsPassed int `json:"tests_passed"` - TestsTotal int `json:"tests_total"` - Errors []VerifyError `json:"errors"` - FixAttempts int `json:"fix_attempts"` - FixesApplied string `json:"fixes_applied,omitempty"` - Summary string `json:"summary,omitempty"` -} - -func Run(ctx context.Context, repoDir, runDir, recipesDir, migrationType string, runner goose.Runner) (*VerifyResult, error) { - logging.Header("Step 4: Verify") - logging.Info("verifying build + tests (migration: %s)", migrationType) - - outPath := filepath.Join(runDir, "verify.json") - - result, err := runVerify(ctx, runner, recipesDir, repoDir, runDir, migrationType) - if err != nil { - return nil, err - } - - data, err := json.MarshalIndent(result, "", " ") - if err != nil { - logging.Warn("marshal verify.json: %v", err) - } else if err := os.WriteFile(outPath, data, 0644); err != nil { - logging.Warn("write verify.json: %v", err) - } - - if result.FixAttempts > 0 { - logging.Info("fix attempts: %d", result.FixAttempts) - } - - if result.BuildOk { - logging.Ok("build: OK | tests: %d/%d passed | errors: %d", - result.TestsPassed, result.TestsTotal, len(result.Errors)) - } else { - logging.Err("build: FAILED | tests: %d/%d passed | errors: %d", - result.TestsPassed, result.TestsTotal, len(result.Errors)) - } - - if len(result.Errors) > 0 { - logging.Info("first errors:") - limit := 3 - if len(result.Errors) < limit { - limit = len(result.Errors) - } - for _, e := range result.Errors[:limit] { - logging.Info(" %s:%d — %s", e.File, e.Line, e.Message) - } - } - - writeVerifyReport(filepath.Join(runDir, "verification-report.md"), result, migrationType) - if err := copyFile(filepath.Join(runDir, "verification-report.md"), filepath.Join(repoDir, "verification-report.md")); err != nil { - logging.Warn("copy verification report to repo: %v", err) - } - - logging.Ok("Step 4/5 complete") - return result, nil -} - -func runVerify(ctx context.Context, runner goose.Runner, recipesDir, repoDir, runDir, migrationType string) (*VerifyResult, error) { - recipeFile := filepath.Join(recipesDir, "verify.yaml") - output, err := runner.RunRecipe(ctx, recipeFile, 50, map[string]string{ - "repo": repoDir, - "plan_md_path": filepath.Join(runDir, "PLAN.md"), - "execution_log_path": filepath.Join(repoDir, "execution-log.md"), - "migration_type": migrationType, - }) - if err != nil { - return &VerifyResult{ - BuildOk: false, - Errors: []VerifyError{{Message: err.Error()}}, - Summary: fmt.Sprintf("goose verify failed: %v", err), - }, nil - } - - var result VerifyResult - if err := json.Unmarshal(output, &result); err != nil { - return &VerifyResult{ - BuildOk: false, - Errors: []VerifyError{{Message: fmt.Sprintf("parse goose output: %v", err)}}, - }, nil - } - - return &result, nil -} - -func writeVerifyReport(path string, r *VerifyResult, migrationType string) { - var b strings.Builder - b.WriteString("# Verification Report\n\n") - fmt.Fprintf(&b, "**Migration:** %s\n", migrationType) - fmt.Fprintf(&b, "**Timestamp:** %s\n\n", time.Now().Format(time.RFC3339)) - - b.WriteString("## Build Status\n\n") - if r.BuildOk { - b.WriteString("- Compilation: **SUCCESS**\n") - } else { - b.WriteString("- Compilation: **FAILED**\n") - } - fmt.Fprintf(&b, "- Tests: %d/%d passed\n\n", r.TestsPassed, r.TestsTotal) - - if r.FixAttempts > 0 { - b.WriteString("## Auto-Fix Attempts\n\n") - fmt.Fprintf(&b, "- Fix iterations: %d\n", r.FixAttempts) - if r.FixesApplied != "" { - fmt.Fprintf(&b, "- Fixes applied: %s\n", r.FixesApplied) - } - b.WriteString("\n") - } - - if len(r.Errors) > 0 { - fmt.Fprintf(&b, "## Remaining Errors (%d total)\n\n", len(r.Errors)) - for _, e := range r.Errors { - fmt.Fprintf(&b, "### %s:%d\n\n```\n%s\n```\n\n", e.File, e.Line, e.Message) - } - } - - b.WriteString("## Summary\n\n") - if r.Summary != "" { - b.WriteString(r.Summary) - } else { - b.WriteString("No summary provided") - } - b.WriteString("\n") - - if err := os.WriteFile(path, []byte(b.String()), 0644); err != nil { - logging.Warn("write verify report %s: %v", path, err) - } -} - -func copyFile(src, dst string) error { - data, err := os.ReadFile(src) - if err != nil { - return fmt.Errorf("read %s: %w", src, err) - } - return os.WriteFile(dst, data, 0644) -} diff --git a/harness/internal/verify/verify_test.go b/harness/internal/verify/verify_test.go deleted file mode 100644 index 075b107..0000000 --- a/harness/internal/verify/verify_test.go +++ /dev/null @@ -1,75 +0,0 @@ -package verify - -import ( - "os" - "strings" - "testing" -) - -func TestWriteVerifyReport(t *testing.T) { - dir := t.TempDir() - path := dir + "/report.md" - - r := &VerifyResult{ - BuildOk: true, - TestsPassed: 42, - TestsTotal: 42, - Errors: nil, - Summary: "All tests pass", - } - writeVerifyReport(path, r, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - checks := []string{ - "Verification Report", - "SUCCESS", - "42/42", - "All tests pass", - } - for _, c := range checks { - if !strings.Contains(content, c) { - t.Errorf("missing %q in report", c) - } - } -} - -func TestWriteVerifyReportWithErrors(t *testing.T) { - dir := t.TempDir() - path := dir + "/report.md" - - r := &VerifyResult{ - BuildOk: false, - TestsPassed: 10, - TestsTotal: 15, - Errors: []VerifyError{ - {File: "Foo.java", Line: 42, Message: "cannot find symbol"}, - }, - FixAttempts: 2, - FixesApplied: "fixed import", - } - writeVerifyReport(path, r, "java-ee-to-quarkus") - - data, err := os.ReadFile(path) - if err != nil { - t.Fatalf("read: %v", err) - } - content := string(data) - - if !strings.Contains(content, "FAILED") { - t.Error("missing FAILED status") - } - if !strings.Contains(content, "Foo.java:42") { - t.Error("missing error location") - } - if !strings.Contains(content, "cannot find symbol") { - t.Error("missing error message") - } - if !strings.Contains(content, "Fix iterations: 2") { - t.Error("missing fix attempts") - } -} diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go new file mode 100644 index 0000000..a5b8d9c --- /dev/null +++ b/harness/internal/watcher/patterns.go @@ -0,0 +1,42 @@ +package watcher + +import ( + "path/filepath" + "strings" +) + +var sourceExts = map[string]bool{ + ".java": true, ".xml": true, ".properties": true, + ".md": true, ".json": true, ".yaml": true, ".yml": true, + ".gradle": true, ".kt": true, ".groovy": true, +} + +var excludeDirs = map[string]bool{ + ".goose": true, "__pycache__": true, ".git": true, + "node_modules": true, "target": true, "graphify-out": true, +} + +var excludeExts = map[string]bool{ + ".tmp": true, ".swp": true, ".bak": true, +} + +func ShouldStageNewFile(path string) bool { + base := filepath.Base(path) + + if base == "pom.xml" || base == "result.json" { + return true + } + + for _, part := range strings.Split(filepath.Dir(path), string(filepath.Separator)) { + if excludeDirs[part] { + return false + } + } + + ext := filepath.Ext(base) + if excludeExts[ext] { + return false + } + + return sourceExts[ext] +} diff --git a/harness/internal/watcher/patterns_test.go b/harness/internal/watcher/patterns_test.go new file mode 100644 index 0000000..a9e73ae --- /dev/null +++ b/harness/internal/watcher/patterns_test.go @@ -0,0 +1,32 @@ +package watcher + +import "testing" + +func TestShouldStageNewFile(t *testing.T) { + tests := []struct { + path string + want bool + }{ + {"src/main/java/com/example/App.java", true}, + {"pom.xml", true}, + {"src/main/resources/application.properties", true}, + {".konveyor/result.json", true}, + {"PLAN.md", true}, + {"graph.json", true}, + {".goose/cache/foo.txt", false}, + {"__pycache__/mod.pyc", false}, + {"target/classes/App.class", false}, + {"scratch.tmp", false}, + {"file.swp", false}, + {"random.txt", false}, + {"src/main/java/.goose/internal.java", false}, + {"graphify-out/model/graph.json", false}, + } + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + if got := ShouldStageNewFile(tt.path); got != tt.want { + t.Errorf("ShouldStageNewFile(%q) = %v, want %v", tt.path, got, tt.want) + } + }) + } +} diff --git a/harness/internal/watcher/watcher.go b/harness/internal/watcher/watcher.go new file mode 100644 index 0000000..cb517e5 --- /dev/null +++ b/harness/internal/watcher/watcher.go @@ -0,0 +1,197 @@ +package watcher + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/fsnotify/fsnotify" + "github.com/konveyor/migration-harness/internal/logging" +) + +// DefaultQuietPeriod is the debounce window: after the last relevant +// filesystem event, the watcher waits this long before committing. +// .konveyor/ is excluded from watching (result.json is written once at +// stage end) but NOT from ShouldStageNewFile, so the final CommitAll +// in main.go picks it up. +const DefaultQuietPeriod = 30 * time.Second + +type CommitPushFn func() error + +type Watcher struct { + dir string + commitFn CommitPushFn + fsw *fsnotify.Watcher + quietPeriod time.Duration + cancel context.CancelFunc + wg sync.WaitGroup +} + +func New(dir string, commitFn CommitPushFn) (*Watcher, error) { + fsw, err := fsnotify.NewWatcher() + if err != nil { + return nil, err + } + return &Watcher{ + dir: dir, + commitFn: commitFn, + fsw: fsw, + quietPeriod: DefaultQuietPeriod, + }, nil +} + +// WithQuietPeriod sets a custom debounce period (for testing). +func (w *Watcher) WithQuietPeriod(d time.Duration) *Watcher { + w.quietPeriod = d + return w +} + +func (w *Watcher) Start(ctx context.Context) error { + if err := w.addDirRecursive(w.dir); err != nil { + return err + } + + ctx, w.cancel = context.WithCancel(ctx) + w.wg.Add(1) + go w.loop(ctx) + logging.Info("filesystem watcher started (quiet period: %s)", w.quietPeriod) + return nil +} + +func (w *Watcher) Stop() { + if w.cancel != nil { + w.cancel() + } + w.wg.Wait() + w.fsw.Close() +} + +func (w *Watcher) loop(ctx context.Context) { + defer w.wg.Done() + timer := time.NewTimer(w.quietPeriod) + timer.Stop() + dirty := false + + for { + select { + case <-ctx.Done(): + timer.Stop() + return + case event, ok := <-w.fsw.Events: + if !ok { + return + } + if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) == 0 { + continue + } + rel, err := filepath.Rel(w.dir, event.Name) + if err != nil { + continue + } + if !isRelevantChange(rel) { + continue + } + if event.Op&fsnotify.Create != 0 { + if info, err := os.Stat(event.Name); err == nil && info.IsDir() { + // Only add the directory if it's not excluded + dirName := filepath.Base(event.Name) + if !excludeDirs[dirName] && dirName != ".konveyor" { + w.fsw.Add(event.Name) + } + } + } + dirty = true + timer.Reset(w.quietPeriod) + case err, ok := <-w.fsw.Errors: + if !ok { + return + } + logging.Warn("watcher error: %v", err) + case <-timer.C: + if dirty { + w.doCommit() + dirty = false + } + } + } +} + +func isRelevantChange(relPath string) bool { + // Check if the path itself or any of its directory components are excluded + parts := strings.Split(relPath, string(filepath.Separator)) + for _, part := range parts { + if excludeDirs[part] { + return false + } + } + + base := filepath.Base(relPath) + ext := filepath.Ext(base) + return !excludeExts[ext] +} + +func (w *Watcher) doCommit() { + if err := w.stageFiles(); err != nil { + logging.Warn("watcher stage: %v", err) + return + } + if err := w.commitFn(); err != nil { + logging.Warn("watcher commit+push: %v", err) + } +} + +func (w *Watcher) stageFiles() error { + cmd := exec.Command("git", "-C", w.dir, "add", "-u") + if out, err := cmd.CombinedOutput(); err != nil { + return fmt.Errorf("git add -u: %s: %w", out, err) + } + + entries, err := w.findNewFiles() + if err != nil { + return err + } + for _, f := range entries { + cmd := exec.Command("git", "-C", w.dir, "add", "--", f) + if out, err := cmd.CombinedOutput(); err != nil { + logging.Warn("git add %s: %s", f, out) + } + } + return nil +} + +func (w *Watcher) findNewFiles() ([]string, error) { + cmd := exec.Command("git", "-C", w.dir, "ls-files", "--others", "--exclude-standard") + out, err := cmd.Output() + if err != nil { + return nil, err + } + var staged []string + for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { + line = strings.TrimSpace(line) + if line != "" && ShouldStageNewFile(line) { + staged = append(staged, line) + } + } + return staged, nil +} + +func (w *Watcher) addDirRecursive(dir string) error { + return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return nil + } + if d.IsDir() { + name := d.Name() + if excludeDirs[name] || name == ".konveyor" { + return filepath.SkipDir + } + return w.fsw.Add(path) + } + return nil + }) +} diff --git a/harness/internal/watcher/watcher_test.go b/harness/internal/watcher/watcher_test.go new file mode 100644 index 0000000..cc355e1 --- /dev/null +++ b/harness/internal/watcher/watcher_test.go @@ -0,0 +1,112 @@ +package watcher + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "sync/atomic" + "testing" + "time" +) + +const testQuietPeriod = 1 * time.Second + +func TestWatcherDetectsFileChange(t *testing.T) { + dir := t.TempDir() + + // Initialize a git repo so git add -u works + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + w.WithQuietPeriod(testQuietPeriod) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Modify a tracked file + writeFile(t, filepath.Join(dir, "App.java"), "class App { int x; }") + + // Wait for quiet period + buffer + time.Sleep(testQuietPeriod + 2*time.Second) + + if commitCount.Load() == 0 { + t.Error("expected at least one commit after quiet period") + } +} + +func TestWatcherIgnoresExcludedDirs(t *testing.T) { + dir := t.TempDir() + runGit(t, dir, "init") + writeFile(t, filepath.Join(dir, "App.java"), "class App {}") + runGit(t, dir, "add", ".") + runGit(t, dir, "commit", "-m", "init") + + var commitCount atomic.Int32 + commitFn := func() error { + commitCount.Add(1) + return nil + } + + w, err := New(dir, commitFn) + if err != nil { + t.Fatal(err) + } + w.WithQuietPeriod(testQuietPeriod) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + if err := w.Start(ctx); err != nil { + t.Fatal(err) + } + defer w.Stop() + + // Write to an excluded dir — should NOT trigger commit + gooseDir := filepath.Join(dir, ".goose") + os.MkdirAll(gooseDir, 0755) + writeFile(t, filepath.Join(gooseDir, "cache.db"), "data") + + time.Sleep(testQuietPeriod + 2*time.Second) + + if commitCount.Load() != 0 { + t.Error("expected no commits for changes in excluded dirs") + } +} + +func writeFile(t *testing.T, path, content string) { + t.Helper() + os.MkdirAll(filepath.Dir(path), 0755) + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +func runGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", + "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", + ) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %s: %v", args, out, err) + } +} diff --git a/harness/recipes/execute.yaml b/harness/recipes/execute.yaml deleted file mode 100644 index 3e980ae..0000000 --- a/harness/recipes/execute.yaml +++ /dev/null @@ -1,105 +0,0 @@ -version: "1.0.0" -title: "Execute migration item" -description: "Migrate one item from the plan. The matching execution sub-skill auto-activates." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the source repository" - - key: plan_md_path - input_type: string - requirement: required - description: "Path to PLAN.md file with goal and steps" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus, python2-to-python3" - - key: item_n - input_type: string - requirement: required - description: "1-based item number in the migration plan" - - key: item_path - input_type: string - requirement: required - description: "File or marker path for this item" - - key: item_action - input_type: string - requirement: required - description: "Action for this item: migrate, create, or delete" - -extensions: - - type: builtin - name: developer - timeout: 1200 - bundled: true - -instructions: | - You are executing ONE migration step from a comprehensive migration plan. - - PHASE 1 — UNDERSTAND THE CONTEXT (2 reads max): - 1. Read {{ plan_md_path }} to understand: - - The migration GOAL (what we're trying to achieve overall) - - The PROJECT SUMMARY (complexity, key challenges) - - The specific step you're working on (Step #{{ item_n }}) - - What dependencies this step has (what should already be done) - - 2. Read {{ repo }}/.goosehints for: - - Project-specific guidance - - Token discipline rules - - The checklist of all steps - - PHASE 2 — EXECUTE THE STEP: - 3. Apply the transformation described in your step: - - {{ item_action }} the file: {{ item_path }} - - Follow the step's "What to do" instructions exactly - - Use the GOAL to guide any judgment calls - - 4. If you encounter problems: - - Syntax errors → document in your response - - Compilation errors → document (but don't try to compile) - - Missing files → document - - Uncertainty → document what you're unsure about - - RULES: - - Execute ONLY step #{{ item_n }}, do NOT touch other files - - Do NOT compile, test, or verify — that's a separate step - - Read files as needed to understand the change, but minimize reads - - If something is unclear, make your best attempt and document the uncertainty - - Lessons learned are OPTIONAL — only include if there's something genuinely useful - - WHAT TO RETURN: - - n: The step number ({{ item_n }}) - - path: The primary file path ({{ item_path }}) - - status: "ok" (you made the change), "failed" (couldn't do it), or "skipped" (not needed) - - files_touched: List of files you modified/created/deleted - - lesson: String describing what you learned (can be empty string) - - error_log: Any errors encountered (syntax, missing files, etc.) — empty string if none - - summary: Brief description of what was done - -prompt: | - Execute Step #{{ item_n }} from the migration plan. - - Action: {{ item_action }} - File: {{ item_path }} - Repository: {{ repo }} - Migration Type: {{ migration_type }} - - First, read {{ plan_md_path }} to understand the goal and this specific step. - Then execute the transformation and report results. - -response: - json_schema: - type: object - required: [n, path, status, files_touched, lesson, error_log] - properties: - n: { type: integer, description: "Step number" } - path: { type: string, description: "Primary file path" } - status: { type: string, enum: [ok, failed, skipped] } - files_touched: { type: array, items: { type: string }, description: "All files modified/created/deleted" } - lesson: { type: string, description: "What was learned (can be empty string)" } - error_log: { type: string, description: "Syntax errors, compilation issues, etc. (empty if none)" } - summary: { type: string, description: "Brief description of what was done" } diff --git a/harness/recipes/fix.yaml b/harness/recipes/fix.yaml deleted file mode 100644 index 6cfdccd..0000000 --- a/harness/recipes/fix.yaml +++ /dev/null @@ -1,79 +0,0 @@ -version: "1.0.0" -title: "Fix migration error" -description: "Address a single compile or test error. Conservative — minimal edits." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the repository" - - key: verification_report_path - input_type: string - requirement: required - description: "Path to verification-report.md with build errors" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus" - - key: error_file - input_type: string - requirement: required - description: "Path of the file reported in the verify error" - - key: error_message - input_type: string - requirement: required - description: "The error message text from verify" - -extensions: - - type: builtin - name: developer - timeout: 900 - bundled: true - -instructions: | - Fix exactly ONE compilation error from the verification report. - - PHASE 1 — UNDERSTAND THE ERROR: - 1. Read {{ verification_report_path }} to see: - - Full list of compilation errors - - Context from verification - - What Step 4 (verify) already attempted to fix - - 2. Focus on the specific error: - - File: {{ error_file }} - - Message: {{ error_message }} - - PHASE 2 — FIX THE ERROR: - 3. Make the minimal edit needed to fix this ONE error - 4. Only touch {{ error_file }} (or build manifest if it's a missing dependency) - 5. Do NOT re-run compile or tests (Step 5 will re-verify) - - RULES: - - Fix only ONE error at a time - - Minimal changes only (no refactoring) - - If it's a missing import/dependency, add it - - If it's a syntax error, fix the syntax - - Return success only if you made a change - -prompt: | - Fix a compilation error in {{ repo }}. - - Verification report: {{ verification_report_path }} - Migration type: {{ migration_type }} - Error file: {{ error_file }} - Error message: {{ error_message }} - - Read the verification report for context, then apply the minimal fix needed. - -response: - json_schema: - type: object - required: [fixed, summary] - properties: - fixed: { type: boolean } - file_changed: { type: string } - summary: { type: string } - pom_changed: { type: boolean, description: "True if build manifest was modified (means a re-resolve is needed)" } diff --git a/harness/recipes/verify.yaml b/harness/recipes/verify.yaml deleted file mode 100644 index 4e0aa8b..0000000 --- a/harness/recipes/verify.yaml +++ /dev/null @@ -1,104 +0,0 @@ -version: "1.0.0" -title: "Verify migration" -description: "Compile and test the migrated repo. Attempts up to 3 fix iterations for compilation errors." - -settings: - temperature: 1 - -parameters: - - key: repo - input_type: string - requirement: required - description: "Absolute path to the (now-migrated) repository" - - key: plan_md_path - input_type: string - requirement: required - description: "Path to PLAN.md with verification section" - - key: execution_log_path - input_type: string - requirement: required - description: "Path to execution-log.md with execution details" - - key: migration_type - input_type: string - requirement: required - description: "Migration key, e.g. java-ee-to-quarkus, python2-to-python3" - -extensions: - - type: builtin - name: developer - timeout: 1800 - bundled: true - -instructions: | - You are verifying a migrated codebase and can attempt fixes if needed. - - PHASE 1 — READ VERIFICATION STEPS: - 1. Read {{ plan_md_path }} and locate the "## Verification" section - 2. This section contains the verification commands to run (e.g., mvn compile, npm test) - 3. Note: Read ONLY the Verification section, ignore Goal, Summary, and Steps sections - - PHASE 2 — RUN VERIFICATION: - 4. Execute the verification commands from PLAN.md - 5. Capture: - - Build status (success/failure) - - Test results (passed/failed counts) - - Compilation errors (file, line, message) - - PHASE 3 — AUTO-FIX (if needed, max 3 iterations): - 6. If compilation/build FAILS: - - Read {{ execution_log_path }} to understand what execution attempted - - Identify root cause of failures - - Make targeted fixes to resolve compilation errors - - Re-run verification - - Repeat up to 3 times total - - 7. If still failing after 3 attempts: - - Document remaining errors - - Return current state - - RULES: - - Focus on compilation errors first (tests can fail, that's okay) - - Only fix what's broken, don't refactor working code - - Read execution-log.md only if you have compilation failures - - Track your fix attempts in the response - - Maximum 3 fix attempts, then stop - - WHAT TO RETURN: - - build_ok: true if compiles, false if not - - tests_passed/tests_total: test counts - - errors: array of remaining compilation errors - - fix_attempts: number of fix iterations you tried (0-3) - - fixes_applied: description of what you fixed - - summary: overall status - -prompt: | - Verify the migrated codebase at {{ repo }}. - - Migration type: {{ migration_type }} - Plan with verification steps: {{ plan_md_path }} - Execution log (for context if needed): {{ execution_log_path }} - - Read the Verification section from PLAN.md, run those commands, and attempt - fixes if compilation fails (up to 3 iterations). Return the final state. - -response: - json_schema: - type: object - required: [build_ok, summary, fix_attempts, fixes_applied] - properties: - build_ok: { type: boolean, description: "True if compilation succeeds" } - tests_total: { type: integer, description: "Total number of tests" } - tests_passed: { type: integer, description: "Number of tests that passed" } - tests_failed: { type: integer, description: "Number of tests that failed" } - fix_attempts: { type: integer, description: "Number of fix iterations attempted (0-3)" } - fixes_applied: { type: string, description: "Description of fixes made (empty if none)" } - errors: - type: array - description: "Remaining compilation errors after fix attempts" - items: - type: object - properties: - file: { type: string } - line: { type: integer } - message: { type: string } - summary: { type: string, description: "Overall verification status" } diff --git a/harness/skill-bundle/goose-migration/SKILL.md b/harness/skill-bundle/goose-migration/SKILL.md deleted file mode 100644 index 98058be..0000000 --- a/harness/skill-bundle/goose-migration/SKILL.md +++ /dev/null @@ -1,85 +0,0 @@ ---- -name: goose-migration -description: > - Umbrella migration skill for Goose AI agent sessions. Triggers on ANY of: "migrate - this app", "migrate with goose", "goose migration", "clone and migrate", "java ee to - quarkus", "python 2 to 3", "react class to hooks", "node upgrade", "goose keeps - hitting rate limits", "set up goose for migration", "migration script". Handles the - full end-to-end flow by routing to the correct sub-skill: first migration-plan (which - discovers the project and gets user approval), then the appropriate execution skill - (e.g. javaee-quarkus) once the plan is approved. ---- - -# Goose Migration — Umbrella Skill - -This skill orchestrates the full migration workflow in two stages: - -``` -Stage 1: migration-plan → discover → build plan → get approval -Stage 2: <execution skill> → migrate one file at a time → compile → verify -``` - ---- - -## Supported Migration Types - -| User says | Execution skill | -|---|---| -| Java EE → Quarkus | `skills/javaee-quarkus/SKILL.md` | -| Python 2 → Python 3 | `skills/python2-to-python3/SKILL.md` | - ---- - -## How to Route - -### Step 1 — Identify migration type - -Ask the user if not already stated: -- What is the **source** stack? (e.g. Java EE 7, Python 2, React class components) -- What is the **target** stack? (e.g. Quarkus 3, Python 3, React hooks) -- Is the repo already cloned, or do you have a GitHub URL? - -### Step 2 — Run migration-plan sub-skill - -Always run `skills/migration-plan/SKILL.md` first regardless of migration type. -It handles discovery, plan generation, and approval gate. - -### Step 3 — Run execution sub-skill - -Only after the plan is approved, load the matching execution skill. -Pass the approved plan and project context to it. - ---- - -## Token Safety Rules (apply throughout all sub-skills) - -These rules are non-negotiable — enforce them in every stage: - -- Read **ONE file at a time** — never `find | xargs cat` or for-loops -- After each file: **STOP and wait** for user to say `next` -- Do **NOT** re-read migrated files -- Do **NOT** reprint the full plan each turn -- Do **NOT** compile unless user types `compile` - ---- - -## Session Commands (tell user at start) - -| Command | Action | -|---|---| -| `next` | Migrate the next file, then stop | -| `compile` | Run build check, show first 30 lines only | -| `fix` | Fix first compiler error only, then stop | -| `status` | List completed vs remaining files | -| `retry` | Retry after rate limit (wait 60s first) | - ---- - -## Model Guidance - -| Model | Recommended for | -|---|---| -| claude-sonnet-5 | Complex transforms (MDB, JNDI) | -| claude-haiku-4-5 | Mechanical imports, simple renames | - -If rate limits hit: `goose configure` → switch to a faster model like `claude-haiku-4-5` diff --git a/harness/skill-bundle/goose-migration/references/README.md b/harness/skill-bundle/goose-migration/references/README.md deleted file mode 100644 index 6fb8cca..0000000 --- a/harness/skill-bundle/goose-migration/references/README.md +++ /dev/null @@ -1,301 +0,0 @@ -# Migration References — Writing Guide - -This directory contains **migration knowledge** files that teach the planner how to migrate between technology stacks. Each reference is a **pattern catalog** that the LLM reads during the planning step to generate specific migration plans. - ---- - -## What is a Reference File? - -A reference file is **NOT executable code** — it's a knowledge document that describes: -- What needs to change (imports, annotations, patterns) -- How to change it (before/after examples) -- What order to follow (layer dependencies) -- What to verify (build commands, grep checks) - -Think of it as a **migration cookbook** for a specific source → target pair. - ---- - -## When to Create a Reference - -Create a reference when you want to migrate between: -- **Frameworks**: Java EE → Quarkus, Spring Boot 2 → 3, Express 4 → 5 -- **Languages**: Python 2 → 3, JavaScript → TypeScript -- **Platforms**: .NET Framework → .NET 8, Node 16 → 20 -- **Architectures**: Monolith → Microservices, REST → GraphQL - ---- - -## Reference File Structure - -Use this template for ALL references (language-agnostic): - -```markdown ---- -name: <source>-to-<target> -description: Migration patterns for <Source X.Y> to <Target Z.W> -applies_to: - manifests: - <manifest_file_key>: true # e.g., pom_xml, package_json, go_mod, cargo_toml - graph_patterns: - - "<pattern description>" # e.g., "imports contains javax.ejb" - - "<pattern description>" # e.g., "annotations contains @MessageDriven" ---- - -# <Source> → <Target>: Migration Reference - -## Migration Order (Layer Dependency) - -Always migrate in this order — dependencies flow upward: - -1. **Build config** - <build file names> -2. **App config** - <config file names> -3. **Utils/Common** - <description> -4. **Data layer** - <description> -5. **Business logic** - <description> -6. **API layer** - <description> -7. **Cleanup** - delete legacy files - -**Why this order**: <Explain dependency flow> - ---- - -## Import/Package Transformations - -Simple find-and-replace (mechanical changes): - -| Old | New | Notes | -|-----|-----|-------| -| `old.package.*` | `new.package.*` | Optional explanation | - ---- - -## Annotation/Decorator Transformations - -| Old | New | Notes | -|-----|-----|-------| -| `@OldAnnotation` | `@NewAnnotation` | Why this changed | - ---- - -## Pattern Catalog - -For each complex structural change, provide a before/after example. - -### Pattern: <Descriptive Name> - -**BEFORE:** -```<language> -<source code showing old pattern> -``` - -**AFTER:** -```<language> -<target code showing new pattern> -``` - -**Specific changes:** -1. Remove: <what to remove> -2. Add: <what to add> -3. Replace: <what to replace> - -**Why**: <Explain what changed at the architectural level> - ---- - -## Files to DELETE - -| Delete this | Replaced by | -|-------------|-------------| -| `path/to/old/file` | `new/path/or/config` or "Not needed" | - -**Why delete**: <Explain why these files are obsolete> - ---- - -## Files to CREATE - -| File | Purpose | Key contents | -|------|---------|--------------| -| `path/to/new/file` | <What it replaces> | <Template or key properties> | - ---- - -## Build File Changes - -### <build-file-name> (e.g., pom.xml, package.json, Cargo.toml) - -``` -REMOVE: <what to remove> -ADD: <what to add> -CHANGE: <what to change> -``` - -List specific dependencies, plugins, or settings. - ---- - -## Verification Commands - -```bash -# 1. Build/compile check -<build command> 2>&1 | tail -30 - -# 2. Check for old imports/patterns -grep -rn "<old pattern>" src/ | wc -l # Should be 0 - -# 3. Check for old annotations -grep -rn "@OldAnnotation" src/ | wc -l # Should be 0 - -# 4. Run in dev mode -<start dev server command> -``` - ---- - -## Notes / Gotchas - -- **Edge case 1**: <Describe special handling needed> -- **Edge case 2**: <Describe common pitfall> -- **Performance**: <Any performance implications> -- **Breaking changes**: <List breaking changes users should know about> -``` - ---- - -## Frontmatter Fields Explained - -### `name` -Kebab-case identifier matching the filename (without `.md`). -Example: `javaee-quarkus`, `python2-to-python3`, `springboot-2-to-3` - -### `description` -One-line summary of what this migration does. Used in logs and to help the LLM understand if this reference applies. - -### `applies_to` -Rules for auto-selecting this reference based on `detect.json` output. - -#### `manifests` -Maps to `detect.json.manifests` object. Keys: -- `pom_xml` - Java/Maven (pom.xml) -- `package_json` - Node.js/npm (package.json) -- `pyproject_toml` - Python (pyproject.toml) -- `requirements_txt` - Python (requirements.txt) -- `setup_py` - Python (setup.py) -- `go_mod` - Go (go.mod) -- `cargo_toml` - Rust (Cargo.toml) -- `gemfile` - Ruby (Gemfile) -- `csproj` - .NET (.csproj) - -Example: -```yaml -applies_to: - manifests: - pom_xml: true # Only applies if pom.xml exists -``` - -#### `graph_patterns` -Describes patterns the LLM should look for in the code graph. These are **human-readable hints**, not exact jq queries. - -The planner will check `graph.json` for matching patterns by: -- Reading node attributes (`attrs.imports`, `attrs.annotations`) -- Checking edge relationships (`relation: "imports"`) -- Counting occurrences - -Examples: -```yaml -graph_patterns: - - "imports contains javax.ejb" - - "annotations contains @MessageDriven" - - "annotations contains @Stateless" - - "imports contains System.Web" - - "imports contains __future__" # Python 2 indicator -``` - -The LLM uses these hints to confirm applicability, not as strict filters. - ---- - -## How the Planner Uses References - -### Step 1: Auto-Selection (in `step-plan.sh`) - -```bash -# Planner checks detect.json against each reference's applies_to: -if detect.json.manifests.pom_xml == true - AND graph contains "imports contains javax.ejb" - THEN select references/javaee-quarkus.md -``` - -### Step 2: Read During Planning - -The selected reference is passed to goose during the planning step: -``` -YOUR JOB: -1. Read detect.json and graph.json -2. Read the build manifest (pom.xml, package.json, etc.) -3. Read ONE matching reference file (javaee-quarkus.md) -4. Write PLAN.md -``` - -### Step 3: Generate PLAN.md - -The planner uses the reference to: -- Determine migration order (layer dependencies) -- Identify which files need changes -- Write specific "What to do" instructions for each file -- Mark complex patterns with ⚠️ flags -- Generate verification commands - ---- - -## Example References - -See existing references in this directory: -- `javaee-quarkus.md` - Java EE 7/8 → Quarkus 3 (comprehensive) -- `springboot-2-to-3.md` - Spring Boot 2 → 3 (Jakarta namespace + Spring changes) -- `dotnet-framework-to-core.md` - .NET Framework 4.x → .NET 8 -- `python2-to-python3.md` - Python 2.7 → Python 3.x -- `migration-phases.md` - Generic migration concepts (no specific tech stack) - ---- - -## Testing Your Reference - -1. **Place the reference** in `skill-bundle/goose-migration/references/` -2. **Reinstall migration-harness**: `cd ~/migration-harness && ./install.sh` -3. **Run detect step** on a sample project: - ```bash - migration-harness step detect /path/to/project - ``` -4. **Check if reference is auto-selected**: - - Look for "reference used: <your-reference-name>" in planning output -5. **Review generated PLAN.md**: - - Are the steps specific? - - Are complex patterns marked with ⚠️? - - Is the layer order correct? - ---- - -## Contributing - -When adding a new reference: -1. Copy this template -2. Fill in patterns from real-world migrations -3. Test on a sample project -4. Submit a PR with: - - The reference file - - A sample `PLAN.md` it generated - - Any updates to `applies_to` logic - ---- - -## Questions? - -- **How detailed should patterns be?** Include enough detail that the planner doesn't need to read every source file. For mechanical changes (imports), a table is enough. For architectural changes (MDB → @Incoming), provide full before/after examples. - -- **Should I include tool-specific commands?** Yes! Verification commands should be real commands users can copy-paste. - -- **What if my migration doesn't fit the template?** The template is a guide, not a strict schema. Adapt sections as needed, but keep the core structure (order, patterns, verification). - -- **Can I reference other files?** Yes! You can reference other references or skills using relative paths: `See ../skills/javaee-quarkus/SKILL.md for execution details`. diff --git a/harness/skill-bundle/goose-migration/references/REFERENCES_SUMMARY.md b/harness/skill-bundle/goose-migration/references/REFERENCES_SUMMARY.md deleted file mode 100644 index 05bd730..0000000 --- a/harness/skill-bundle/goose-migration/references/REFERENCES_SUMMARY.md +++ /dev/null @@ -1,324 +0,0 @@ -# Migration References — Summary - -We've created a **language-agnostic, framework-agnostic** reference system for migration planning. - -## What We Built - -### 1. README.md (Writing Guide) -**Location**: `skill-bundle/goose-migration/references/README.md` - -Complete guide for end users explaining: -- ✅ What a reference file is -- ✅ When to create one -- ✅ Template structure (frontmatter, patterns, verification) -- ✅ How to test references -- ✅ Auto-selection logic (`applies_to` rules) - -**Key sections**: -- Reference file structure template -- Frontmatter fields (`name`, `description`, `applies_to`) -- Pattern catalog format (before/after examples) -- How the planner uses references - ---- - -### 2. Language/Framework References (4 Complete Examples) - -| Reference | Source → Target | Size | Patterns | -|-----------|----------------|------|----------| -| `javaee-quarkus.md` | Java EE 7/8 → Quarkus 3 | 9.7KB | EJB→CDI, MDB→@Incoming, JNDI removal | -| `springboot-2-to-3.md` | Spring Boot 2.x → 3.x | 16KB | Jakarta namespace, Security config, Hibernate 6 | -| `dotnet-framework-to-core.md` | .NET Framework 4.x → .NET 8 | 25KB | System.Web→AspNetCore, Web.config→appsettings, EF6→EF Core | -| `python2-to-python3.md` | Python 2.7 → Python 3.x | 15KB | print(), xrange→range, unicode→str, urllib2 | - ---- - -### 3. Frontmatter Auto-Selection - -Each reference has frontmatter for auto-detection: - -**Example (Java EE → Quarkus)**: -```yaml ---- -name: javaee-quarkus -description: Migration patterns for Java EE 7/8 to Quarkus 3 -applies_to: - manifests: - pom_xml: true - graph_patterns: - - "imports contains javax.ejb" - - "annotations contains @MessageDriven" ---- -``` - -**How it works**: -1. Detect step produces `detect.json` with manifests and graph stats -2. Planning step checks each reference's `applies_to` rules -3. Auto-selects matching reference (e.g., pom.xml + javax.ejb → javaee-quarkus.md) -4. Planner reads the reference and uses patterns to generate PLAN.md - ---- - -## What Each Reference Provides - -Every reference follows the same structure: - -### 1. Migration Order (Layer Dependencies) -``` -1. Build config (pom.xml, package.json, .csproj) -2. App config (application.properties, appsettings.json) -3. Utilities -4. Data layer -5. Business logic -6. API layer -7. Cleanup -``` - -**Why**: Ensures correct dependency order. Models don't depend on services, so migrate models first. - ---- - -### 2. Import/Package Transformations -Simple find-and-replace mappings: - -**Spring Boot 2→3**: -``` -javax.persistence.* → jakarta.persistence.* -javax.validation.* → jakarta.validation.* -``` - -**Python 2→3**: -``` -urllib2 → urllib.request -Queue → queue -``` - -**`.NET Framework→.NET 8`**: -``` -System.Web.Mvc → Microsoft.AspNetCore.Mvc -``` - ---- - -### 3. Pattern Catalog (Before/After Examples) - -For complex structural changes, show full examples: - -**Example: Java EE MDB → Quarkus @Incoming** -```java -// BEFORE -@MessageDriven(...) -public class OrderMDB implements MessageListener { - public void onMessage(Message msg) { ... } -} - -// AFTER -@ApplicationScoped -public class OrderMDB { - @Incoming("orders") - public void onMessage(String body) { ... } -} -``` - ---- - -### 4. Files to DELETE/CREATE -**Java EE → Quarkus**: -- DELETE: `persistence.xml`, `web.xml`, `beans.xml` -- CREATE: `application.properties` - -**`.NET Framework → .NET 8`**: -- DELETE: `Web.config`, `Global.asax`, `packages.config` -- CREATE: `appsettings.json`, `Program.cs` - ---- - -### 5. Build File Changes -Specific instructions for `pom.xml`, `package.json`, `.csproj`, etc. - -**Example (Spring Boot)**: -```xml -<!-- CHANGE parent version --> -<parent> - <artifactId>spring-boot-starter-parent</artifactId> - <version>3.2.0</version> <!-- Was 2.7.x --> -</parent> - -<!-- CHANGE Java version --> -<java.version>17</java.version> <!-- Was 8 or 11 --> -``` - ---- - -### 6. Verification Commands -Copy-paste commands to verify migration: - -**Python 2→3**: -```bash -# Check for print statements -grep -rn "print " . --include="*.py" | grep -v "print(" | wc -l # Should be 0 - -# Check for xrange -grep -rn "xrange" . --include="*.py" | wc -l # Should be 0 -``` - ---- - -### 7. Notes / Gotchas -Common pitfalls and edge cases: - -- **Spring Boot 3**: Java 17 is REQUIRED (not optional) -- **.NET 8**: Web Forms have no direct equivalent (use Blazor/Razor Pages) -- **Python 3**: Integer division changed (`5/2 = 2.5`, not `2`) - ---- - -## How It Works in Practice - -### Step 1: User runs migration-harness -```bash -migration-harness ~/coolstore "Migrate this Java EE app to Quarkus 3" -``` - -### Step 2: Detect step builds graph -``` -── Step 1/5 — Detect ── -✓ 1a. manifests: pom=true pkg=false ... -✓ 1b. graph: 4275 nodes, 8386 edges, 613 communities -✓ Step 1/5 complete → detect.json + graph.json -``` - -**Output**: `detect.json` -```json -{ - "manifests": {"pom_xml": true}, - "graph": {"nodes": 4275, "edges": 8386} -} -``` - -### Step 3: Planning step auto-selects reference - -```bash -# Step-plan.sh checks each reference's applies_to: -if detect.json.manifests.pom_xml == true - AND graph contains "imports contains javax.ejb" - THEN select references/javaee-quarkus.md -``` - -### Step 4: Planner reads reference and generates PLAN.md - -**Inputs**: -- `detect.json` (manifests, file counts) -- `graph.json` (code structure, communities, god nodes) -- `references/javaee-quarkus.md` (patterns, order, transformations) - -**Output**: `PLAN.md` (like the coolstore example we saw) -- 34 steps with specific file paths -- Complex patterns marked with ⚠️ -- Layer-ordered (build → config → models → services → API → cleanup) - ---- - -## Language/Framework Coverage - -| Language/Framework | Reference | Status | -|--------------------|-----------|--------| -| Java EE → Quarkus | `javaee-quarkus.md` | ✅ Complete | -| Spring Boot 2 → 3 | `springboot-2-to-3.md` | ✅ Complete | -| .NET Framework → .NET 8 | `dotnet-framework-to-core.md` | ✅ Complete | -| Python 2 → 3 | `python2-to-python3.md` | ✅ Complete | -| React Class → Hooks | `react-class-to-hooks.md` | ⏳ TODO | -| Node.js 14 → 20 | `nodejs-14-to-20.md` | ⏳ TODO | -| Go 1.16 → 1.22 | `go-116-to-122.md` | ⏳ TODO | -| Rails 6 → 7 | `rails-6-to-7.md` | ⏳ TODO | - ---- - -## Adding New References - -### 1. Copy the template from README.md -```bash -cd skill-bundle/goose-migration/references -cp README.md my-new-reference.md -``` - -### 2. Fill in the sections -- Frontmatter (`name`, `description`, `applies_to`) -- Migration order -- Import transformations -- Pattern catalog (before/after examples) -- Verification commands - -### 3. Test it -```bash -cd ~/migration-harness -./install.sh # Installs the new reference -migration-harness step detect /path/to/test-project -migration-harness step plan /path/to/test-project "Migrate to X" -``` - -### 4. Check if auto-selected -Look for: -``` -✓ 2e2. reference used: my-new-reference -``` - ---- - -## Key Benefits - -### 1. Language/Framework Agnostic -The template works for ANY migration: -- JVM (Java, Kotlin, Scala) -- .NET (C#, F#, VB.NET) -- Dynamic (Python, Ruby, JavaScript, TypeScript) -- Systems (Go, Rust, C++) - -### 2. LLM Makes the Judgment -The planner (goose + LLM) decides: -- Which reference to use (based on detect.json + graph patterns) -- Which files need migration (based on graph communities) -- Which patterns apply to which files (based on imports/annotations) -- What order to follow (based on layer dependencies) - -You just provide the knowledge (patterns, transformations). The LLM does the reasoning. - -### 3. Reusable Across Projects -Write the reference once, use it for ALL projects of that type: -- One `javaee-quarkus.md` works for WebLogic, JBoss, WildFly, GlassFish -- One `springboot-2-to-3.md` works for all Spring Boot 2.x apps -- One `dotnet-framework-to-core.md` works for MVC, Web API, Web Forms (with notes) - -### 4. Easy to Extend -Community can contribute references: -- Django 3 → 4 -- React 17 → 18 -- Angular 15 → 16 -- Vue 2 → 3 - -Just follow the template in README.md. - ---- - -## Next Steps - -1. **Test the existing references** on real projects -2. **Add more references** (React, Node.js, Go, Rails) -3. **Improve auto-selection logic** (better pattern matching) -4. **Create reference validator** (script to check reference format) - ---- - -## Files Created - -``` -skill-bundle/goose-migration/references/ -├── README.md ← 8.1KB - Writing guide -├── javaee-quarkus.md ← 9.7KB - Java EE → Quarkus -├── springboot-2-to-3.md ← 16KB - Spring Boot 2 → 3 -├── dotnet-framework-to-core.md ← 25KB - .NET Framework → .NET 8 -├── python2-to-python3.md ← 15KB - Python 2 → 3 -└── migration-phases.md ← 4.3KB - Generic concepts (existing) -``` - -Total: **~78KB of migration knowledge** covering 4 major technology stacks. diff --git a/harness/skill-bundle/goose-migration/references/dotnet-framework-to-core.md b/harness/skill-bundle/goose-migration/references/dotnet-framework-to-core.md deleted file mode 100644 index c3431f9..0000000 --- a/harness/skill-bundle/goose-migration/references/dotnet-framework-to-core.md +++ /dev/null @@ -1,926 +0,0 @@ -# .NET Framework 4.x → .NET 8: Migration Reference - ---- -name: dotnet-framework-to-core -description: Migration patterns for .NET Framework 4.x (ASP.NET MVC/Web API) to .NET 8 (ASP.NET Core) -applies_to: - manifests: - csproj: true - graph_patterns: - - "imports contains System.Web" - - "imports contains System.Web.Mvc" - - "imports contains System.Web.Http" ---- - -## Migration Order (Layer Dependency) - -Always migrate in this order — dependencies flow upward: - -1. **Build config** - .csproj (SDK-style project, TargetFramework) -2. **App config** - appsettings.json (replaces Web.config) -3. **Startup** - Program.cs / Startup.cs (replaces Global.asax) -4. **Models / DTOs** - Data models (minimal changes) -5. **Data layer** - DbContext, repositories (EF6 → EF Core) -6. **Services** - Business logic (dependency injection changes) -7. **Controllers** - MVC/API controllers (namespace changes) -8. **Views** - Razor views (if applicable, minimal changes) -9. **Middleware** - Custom HTTP modules → middleware -10. **Cleanup** - Delete Web.config, Global.asax, packages.config - -**Why this order**: Project file must be SDK-style first. Configuration and startup are foundational. Models have no deps. Data layer depends on models. Services depend on data. Controllers depend on services. - ---- - -## Import/Package Transformations - -All `System.Web.*` namespaces change to `Microsoft.AspNetCore.*`: - -| Old (.NET Framework) | New (.NET 8) | Affected Components | -|----------------------|--------------|---------------------| -| `System.Web.Mvc` | `Microsoft.AspNetCore.Mvc` | MVC controllers, views | -| `System.Web.Http` | `Microsoft.AspNetCore.Mvc` | Web API controllers | -| `System.Web.Routing` | `Microsoft.AspNetCore.Routing` | Route configuration | -| `System.Web.Optimization` | Custom bundler (Webpack, Vite) | Bundling and minification | -| `System.Web.Helpers` | `Microsoft.AspNetCore.Mvc.ViewFeatures` | HTML helpers | -| `System.Web.Security` | `Microsoft.AspNetCore.Identity` | Authentication, authorization | -| `System.Configuration` | `Microsoft.Extensions.Configuration` | App settings | -| `System.Web.HttpContext` | `Microsoft.AspNetCore.Http.HttpContext` | HTTP context | - -**Note**: Entity Framework 6 → Entity Framework Core requires additional changes (see Pattern 5). - ---- - -## Annotation/Attribute Transformations - -| Old (.NET Framework) | New (.NET 8) | Notes | -|----------------------|--------------|-------| -| `[RoutePrefix("api/users")]` | `[Route("api/users")]` | Both work, but `[Route]` is standard | -| `[HttpGet]` | `[HttpGet]` | No change (same attribute) | -| `[Authorize]` | `[Authorize]` | No change, but policy syntax updated | -| `[ValidateAntiForgeryToken]` | `[ValidateAntiForgeryToken]` | No change | - -**Key difference**: Attributes are the same, but namespaces differ. - ---- - -## Pattern Catalog - -### Pattern 1: MVC Controller (System.Web.Mvc → ASP.NET Core MVC) - -**BEFORE (.NET Framework 4.x):** -```csharp -using System.Web.Mvc; - -namespace MyApp.Controllers -{ - public class HomeController : Controller - { - public ActionResult Index() - { - ViewBag.Message = "Welcome"; - return View(); - } - - [HttpPost] - public ActionResult Create(User user) - { - if (ModelState.IsValid) - { - // Save user - return RedirectToAction("Index"); - } - return View(user); - } - } -} -``` - -**AFTER (.NET 8):** -```csharp -using Microsoft.AspNetCore.Mvc; - -namespace MyApp.Controllers -{ - public class HomeController : Controller - { - public IActionResult Index() - { - ViewBag.Message = "Welcome"; - return View(); - } - - [HttpPost] - public IActionResult Create(User user) - { - if (ModelState.IsValid) - { - // Save user - return RedirectToAction("Index"); - } - return View(user); - } - } -} -``` - -**Specific changes:** -1. Replace: `using System.Web.Mvc` → `using Microsoft.AspNetCore.Mvc` -2. Replace: `ActionResult` → `IActionResult` (preferred in .NET Core) -3. Keep: Everything else is identical (View(), RedirectToAction(), ModelState) - -**Why**: ASP.NET Core unifies MVC and Web API into a single framework under `Microsoft.AspNetCore.Mvc`. - ---- - -### Pattern 2: Web API Controller (System.Web.Http → ASP.NET Core) - -**BEFORE (.NET Framework 4.x):** -```csharp -using System.Web.Http; -using System.Collections.Generic; - -namespace MyApp.Controllers -{ - [RoutePrefix("api/users")] - public class UsersController : ApiController - { - [HttpGet] - [Route("")] - public IHttpActionResult GetAll() - { - var users = _userService.GetAll(); - return Ok(users); - } - - [HttpGet] - [Route("{id}")] - public IHttpActionResult GetById(int id) - { - var user = _userService.GetById(id); - if (user == null) - return NotFound(); - return Ok(user); - } - - [HttpPost] - [Route("")] - public IHttpActionResult Create([FromBody] User user) - { - if (!ModelState.IsValid) - return BadRequest(ModelState); - - _userService.Create(user); - return Created($"api/users/{user.Id}", user); - } - } -} -``` - -**AFTER (.NET 8):** -```csharp -using Microsoft.AspNetCore.Mvc; -using System.Collections.Generic; - -namespace MyApp.Controllers -{ - [ApiController] - [Route("api/users")] - public class UsersController : ControllerBase - { - private readonly IUserService _userService; - - public UsersController(IUserService userService) - { - _userService = userService; - } - - [HttpGet] - public IActionResult GetAll() - { - var users = _userService.GetAll(); - return Ok(users); - } - - [HttpGet("{id}")] - public IActionResult GetById(int id) - { - var user = _userService.GetById(id); - if (user == null) - return NotFound(); - return Ok(user); - } - - [HttpPost] - public IActionResult Create([FromBody] User user) - { - if (!ModelState.IsValid) - return BadRequest(ModelState); - - _userService.Create(user); - return CreatedAtAction(nameof(GetById), new { id = user.Id }, user); - } - } -} -``` - -**Specific changes:** -1. Replace: `using System.Web.Http` → `using Microsoft.AspNetCore.Mvc` -2. Replace: `ApiController` base class → `ControllerBase` -3. Add: `[ApiController]` attribute on controller class -4. Replace: `[RoutePrefix("api/users")]` → `[Route("api/users")]` -5. Simplify: `[Route("{id}")]` instead of separate attribute -6. Replace: `IHttpActionResult` → `IActionResult` -7. Replace: `Created(...)` → `CreatedAtAction(...)` -8. Add: Constructor-based dependency injection (required in ASP.NET Core) - -**Why**: ASP.NET Core uses a unified controller model. Dependency injection is built-in, not optional. - ---- - -### Pattern 3: Global.asax → Program.cs / Startup.cs - -**BEFORE (.NET Framework 4.x - Global.asax.cs):** -```csharp -using System.Web; -using System.Web.Mvc; -using System.Web.Routing; -using System.Web.Http; - -namespace MyApp -{ - public class MvcApplication : HttpApplication - { - protected void Application_Start() - { - AreaRegistration.RegisterAllAreas(); - GlobalConfiguration.Configure(WebApiConfig.Register); - RouteConfig.RegisterRoutes(RouteTable.Routes); - FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); - BundleConfig.RegisterBundles(BundleTable.Bundles); - } - - protected void Application_Error() - { - var exception = Server.GetLastError(); - // Log exception - } - } -} -``` - -**AFTER (.NET 8 - Program.cs - Minimal API style):** -```csharp -using Microsoft.AspNetCore.Builder; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; - -var builder = WebApplication.CreateBuilder(args); - -// Add services to the container -builder.Services.AddControllersWithViews(); -builder.Services.AddScoped<IUserService, UserService>(); // DI registration - -var app = builder.Build(); - -// Configure the HTTP request pipeline -if (app.Environment.IsDevelopment()) -{ - app.UseDeveloperExceptionPage(); -} -else -{ - app.UseExceptionHandler("/Home/Error"); - app.UseHsts(); -} - -app.UseHttpsRedirection(); -app.UseStaticFiles(); -app.UseRouting(); -app.UseAuthorization(); - -app.MapControllerRoute( - name: "default", - pattern: "{controller=Home}/{action=Index}/{id?}"); - -app.Run(); -``` - -**OR (Startup.cs - traditional style):** -```csharp -// Program.cs -var builder = WebApplication.CreateBuilder(args); -var startup = new Startup(builder.Configuration); -startup.ConfigureServices(builder.Services); -var app = builder.Build(); -startup.Configure(app, app.Environment); -app.Run(); - -// Startup.cs -public class Startup -{ - public IConfiguration Configuration { get; } - - public Startup(IConfiguration configuration) - { - Configuration = configuration; - } - - public void ConfigureServices(IServiceCollection services) - { - services.AddControllersWithViews(); - services.AddScoped<IUserService, UserService>(); - } - - public void Configure(IApplicationBuilder app, IWebHostEnvironment env) - { - if (env.IsDevelopment()) - { - app.UseDeveloperExceptionPage(); - } - else - { - app.UseExceptionHandler("/Home/Error"); - app.UseHsts(); - } - - app.UseHttpsRedirection(); - app.UseStaticFiles(); - app.UseRouting(); - app.UseAuthorization(); - app.UseEndpoints(endpoints => - { - endpoints.MapControllerRoute( - name: "default", - pattern: "{controller=Home}/{action=Index}/{id?}"); - }); - } -} -``` - -**Specific changes:** -1. Remove: `Global.asax` / `Global.asax.cs` files entirely -2. Create: `Program.cs` (application entry point) -3. Replace: `Application_Start()` → `builder.Services.Add*()` + `app.Use*()` -4. Replace: Route registration → `app.MapControllerRoute()` -5. Replace: Filter registration → Middleware pipeline (`app.Use*()`) -6. Replace: Bundle registration → External bundler (Webpack, Vite) or `UseStaticFiles()` -7. Add: Dependency injection in `ConfigureServices()` - -**Why**: ASP.NET Core uses a built-in DI container and middleware pipeline. Global.asax is replaced by `Program.cs` and optional `Startup.cs`. - ---- - -### Pattern 4: Web.config → appsettings.json - -**BEFORE (.NET Framework 4.x - Web.config):** -```xml -<?xml version="1.0"?> -<configuration> - <connectionStrings> - <add name="DefaultConnection" - connectionString="Server=localhost;Database=MyDb;Trusted_Connection=True;" - providerName="System.Data.SqlClient" /> - </connectionStrings> - - <appSettings> - <add key="AppName" value="MyApp" /> - <add key="MaxUploadSize" value="10485760" /> - </appSettings> - - <system.web> - <compilation debug="true" targetFramework="4.8" /> - <httpRuntime targetFramework="4.8" /> - <authentication mode="Forms"> - <forms loginUrl="~/Account/Login" timeout="2880" /> - </authentication> - </system.web> -</configuration> -``` - -**AFTER (.NET 8 - appsettings.json):** -```json -{ - "ConnectionStrings": { - "DefaultConnection": "Server=localhost;Database=MyDb;Trusted_Connection=True;" - }, - "AppSettings": { - "AppName": "MyApp", - "MaxUploadSize": 10485760 - }, - "Logging": { - "LogLevel": { - "Default": "Information", - "Microsoft.AspNetCore": "Warning" - } - }, - "AllowedHosts": "*" -} -``` - -**Accessing in code:** - -**BEFORE (.NET Framework):** -```csharp -using System.Configuration; - -var connString = ConfigurationManager.ConnectionStrings["DefaultConnection"].ConnectionString; -var appName = ConfigurationManager.AppSettings["AppName"]; -``` - -**AFTER (.NET 8):** -```csharp -using Microsoft.Extensions.Configuration; - -public class MyService -{ - private readonly IConfiguration _config; - - public MyService(IConfiguration config) - { - _config = config; - } - - public void DoSomething() - { - var connString = _config.GetConnectionString("DefaultConnection"); - var appName = _config["AppSettings:AppName"]; - } -} -``` - -**Specific changes:** -1. Remove: `Web.config` file -2. Create: `appsettings.json` (and `appsettings.Development.json`, `appsettings.Production.json`) -3. Replace: `<connectionStrings>` XML → JSON `"ConnectionStrings": {}` -4. Replace: `<appSettings>` XML → JSON `"AppSettings": {}` -5. Remove: `<system.web>` (configuration moved to code) -6. Replace: `ConfigurationManager` → `IConfiguration` injected via DI -7. Add: Environment-specific config files (appsettings.{Environment}.json) - -**Why**: .NET Core uses JSON-based configuration with built-in environment support and strongly-typed options. - ---- - -### Pattern 5: Entity Framework 6 → Entity Framework Core - -**BEFORE (.NET Framework - EF6):** -```csharp -using System.Data.Entity; - -namespace MyApp.Data -{ - public class AppDbContext : DbContext - { - public AppDbContext() : base("DefaultConnection") - { - } - - public DbSet<User> Users { get; set; } - public DbSet<Order> Orders { get; set; } - - protected override void OnModelCreating(DbModelBuilder modelBuilder) - { - modelBuilder.Entity<User>() - .HasMany(u => u.Orders) - .WithRequired(o => o.User) - .HasForeignKey(o => o.UserId); - } - } -} -``` - -**AFTER (.NET 8 - EF Core):** -```csharp -using Microsoft.EntityFrameworkCore; - -namespace MyApp.Data -{ - public class AppDbContext : DbContext - { - public AppDbContext(DbContextOptions<AppDbContext> options) - : base(options) - { - } - - public DbSet<User> Users { get; set; } - public DbSet<Order> Orders { get; set; } - - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity<User>() - .HasMany(u => u.Orders) - .WithOne(o => o.User) - .HasForeignKey(o => o.UserId); - } - } -} -``` - -**Program.cs registration:** -```csharp -builder.Services.AddDbContext<AppDbContext>(options => - options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection"))); -``` - -**Specific changes:** -1. Replace: `using System.Data.Entity` → `using Microsoft.EntityFrameworkCore` -2. Replace: Parameterless constructor → `DbContextOptions<T>` constructor -3. Replace: `DbModelBuilder` → `ModelBuilder` -4. Replace: `.WithRequired()` → `.WithOne()` -5. Add: DbContext registration in `Program.cs` with connection string -6. Remove: Connection string name from constructor (passed via DI) - -**Why**: EF Core is a rewrite with better performance, cross-platform support, and built-in DI. - ---- - -### Pattern 6: Custom HTTP Module → Middleware - -**BEFORE (.NET Framework - HTTP Module in Web.config):** -```csharp -using System; -using System.Web; - -namespace MyApp.Modules -{ - public class LoggingModule : IHttpModule - { - public void Init(HttpApplication context) - { - context.BeginRequest += Context_BeginRequest; - context.EndRequest += Context_EndRequest; - } - - private void Context_BeginRequest(object sender, EventArgs e) - { - var app = (HttpApplication)sender; - var path = app.Request.Path; - // Log request - } - - private void Context_EndRequest(object sender, EventArgs e) - { - // Log response - } - - public void Dispose() { } - } -} -``` - -```xml -<!-- Web.config --> -<system.webServer> - <modules> - <add name="LoggingModule" type="MyApp.Modules.LoggingModule" /> - </modules> -</system.webServer> -``` - -**AFTER (.NET 8 - Middleware):** -```csharp -using Microsoft.AspNetCore.Http; -using System.Threading.Tasks; - -namespace MyApp.Middleware -{ - public class LoggingMiddleware - { - private readonly RequestDelegate _next; - - public LoggingMiddleware(RequestDelegate next) - { - _next = next; - } - - public async Task InvokeAsync(HttpContext context) - { - // Log request - var path = context.Request.Path; - - await _next(context); - - // Log response - } - } -} -``` - -**Program.cs:** -```csharp -app.UseMiddleware<LoggingMiddleware>(); -// OR inline: -app.Use(async (context, next) => -{ - // Before request - await next(); - // After response -}); -``` - -**Specific changes:** -1. Remove: `IHttpModule` interface, `Web.config` registration -2. Create: Middleware class with `RequestDelegate` + `InvokeAsync(HttpContext)` -3. Replace: `BeginRequest` → code before `await _next(context)` -4. Replace: `EndRequest` → code after `await _next(context)` -5. Add: `app.UseMiddleware<T>()` in `Program.cs` - -**Why**: ASP.NET Core uses async middleware pipeline instead of event-based HTTP modules. - ---- - -## Files to DELETE - -| Delete this | Replaced by | Reason | -|-------------|-------------|--------| -| `Web.config` | `appsettings.json` | Configuration moved to JSON | -| `Global.asax` / `Global.asax.cs` | `Program.cs` / `Startup.cs` | Application startup redesigned | -| `packages.config` | PackageReference in `.csproj` | NuGet moved to SDK-style projects | -| `App_Start/*.cs` (RouteConfig, FilterConfig, BundleConfig) | `Program.cs` middleware | Configuration now in code | -| `*.cshtml` in `App_Start` | N/A | Bundling moved to external tools | - ---- - -## Files to CREATE - -| File | Purpose | Template | -|------|---------|----------| -| `appsettings.json` | Application settings | See Pattern 4 | -| `appsettings.Development.json` | Dev-specific settings | Override settings for dev | -| `Program.cs` | Application entry point | See Pattern 3 | -| `Startup.cs` (optional) | Startup configuration | See Pattern 3 (traditional style) | - ---- - -## Build File Changes - -### .csproj (SDK-style) - -**BEFORE (.NET Framework 4.x - legacy .csproj):** -```xml -<?xml version="1.0" encoding="utf-8"?> -<Project ToolsVersion="15.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003"> - <PropertyGroup> - <TargetFrameworkVersion>v4.8</TargetFrameworkVersion> - </PropertyGroup> - <ItemGroup> - <Reference Include="System.Web" /> - <Reference Include="System.Web.Mvc, Version=5.2.7.0" /> - </ItemGroup> - <ItemGroup> - <Compile Include="Controllers\HomeController.cs" /> - <Compile Include="Global.asax.cs"> - <DependentUpon>Global.asax</DependentUpon> - </Compile> - </ItemGroup> -</Project> -``` - -**AFTER (.NET 8 - SDK-style .csproj):** -```xml -<Project Sdk="Microsoft.NET.Sdk.Web"> - <PropertyGroup> - <TargetFramework>net8.0</TargetFramework> - <Nullable>enable</Nullable> - <ImplicitUsings>enable</ImplicitUsings> - </PropertyGroup> - - <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.0" /> - </ItemGroup> -</Project> -``` - -**Specific changes:** -1. Replace: `<Project ToolsVersion=...>` → `<Project Sdk="Microsoft.NET.Sdk.Web">` -2. Replace: `<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>` → `<TargetFramework>net8.0</TargetFramework>` -3. Remove: All `<Reference>` entries (implicit with SDK) -4. Remove: All `<Compile>` entries (auto-discovered by SDK) -5. Remove: `packages.config` — use `<PackageReference>` instead -6. Add: `<Nullable>enable</Nullable>` (recommended for new projects) -7. Add: `<ImplicitUsings>enable</ImplicitUsings>` (C# 10+ feature) - ---- - -## Verification Commands - -```bash -# 1. Build the project -dotnet build - -# 2. Check for System.Web references (should be 0) -grep -rn "using System.Web" . --include="*.cs" | wc -l -# Should be 0 - -# 3. Check target framework in .csproj -grep "TargetFramework" *.csproj -# Should show net8.0 or net7.0 - -# 4. Run tests -dotnet test - -# 5. Run the application -dotnet run - -# 6. Check application URL -# Default: https://localhost:5001 or http://localhost:5000 - -# 7. Verify appsettings.json is loaded -# Check logs for "Now listening on: https://localhost:5001" -``` - ---- - -## Notes / Gotchas - -### 1. **Web Forms NOT Supported** -ASP.NET Web Forms (`.aspx` files) have **no direct equivalent** in ASP.NET Core. - -**Options**: -- Rewrite as Razor Pages (similar page-based model) -- Rewrite as MVC (if complex logic) -- Use Blazor (component-based, similar to Web Forms) -- Keep Web Forms app on .NET Framework 4.8 (LTS until 2028) - -### 2. **Session State Requires Package** -Session state is not included by default. - -**Add package**: -```bash -dotnet add package Microsoft.AspNetCore.Session -``` - -**Enable in Program.cs**: -```csharp -builder.Services.AddDistributedMemoryCache(); -builder.Services.AddSession(options => -{ - options.IdleTimeout = TimeSpan.FromMinutes(30); -}); - -app.UseSession(); // Before UseRouting() -``` - -### 3. **Bundling and Minification** -`System.Web.Optimization` is not available. Use external tools: -- **Webpack** (most common) -- **Vite** (modern, fast) -- **Gulp/Grunt** (older, still used) -- **LibMan** (simple, built-in for ASP.NET Core) - -**LibMan example** (appsettings.json): -```json -{ - "provider": "cdnjs", - "library": "jquery@3.6.0", - "destination": "wwwroot/lib/jquery/" -} -``` - -### 4. **CORS Must Be Configured** -If building an API, enable CORS explicitly: - -```csharp -builder.Services.AddCors(options => -{ - options.AddDefaultPolicy(policy => - { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); -}); - -app.UseCors(); // Before UseAuthorization() -``` - -### 5. **Authentication Changes** -ASP.NET Identity works differently. For Forms Authentication → ASP.NET Core Identity: - -**Add packages**: -```bash -dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore -``` - -**Register in Program.cs**: -```csharp -builder.Services.AddIdentity<ApplicationUser, IdentityRole>() - .AddEntityFrameworkStores<AppDbContext>() - .AddDefaultTokenProviders(); - -app.UseAuthentication(); -app.UseAuthorization(); -``` - -### 6. **Static Files in wwwroot** -Move all static files (CSS, JS, images) from `Content/`, `Scripts/`, `Images/` to `wwwroot/`: - -``` -BEFORE: - Content/ - site.css - Scripts/ - site.js - -AFTER: - wwwroot/ - css/ - site.css - js/ - site.js -``` - -### 7. **Razor View Syntax Changes** -Mostly compatible, but: -- `@Html.AntiForgeryToken()` still works -- `@Url.Action()` still works -- `@Html.BeginForm()` still works - -**New**: Tag Helpers (recommended) -```html -<!-- OLD --> -@Html.TextBoxFor(m => m.Name, new { @class = "form-control" }) - -<!-- NEW (Tag Helpers) --> -<input asp-for="Name" class="form-control" /> -``` - -### 8. **Dependency Injection is Mandatory** -Unlike .NET Framework (optional), DI is **built-in and required** in .NET Core. - -**ALL services must be registered**: -```csharp -builder.Services.AddScoped<IUserService, UserService>(); -builder.Services.AddSingleton<ICacheService, CacheService>(); -builder.Services.AddTransient<IEmailService, EmailService>(); -``` - -### 9. **Async All the Way** -ASP.NET Core is designed for async. Update synchronous code to async: - -```csharp -// OLD -public ActionResult Index() -{ - var users = _userService.GetAll(); - return View(users); -} - -// NEW (preferred) -public async Task<IActionResult> Index() -{ - var users = await _userService.GetAllAsync(); - return View(users); -} -``` - -### 10. **Hosting Models** -ASP.NET Core can run: -- **Kestrel** (cross-platform, default) -- **IIS** (Windows, in-process or out-of-process) -- **Docker** (containerized) -- **Self-hosted** (console app) - -**IIS hosting** requires `web.config` (auto-generated): -```xml -<?xml version="1.0" encoding="utf-8"?> -<configuration> - <location path="." inheritInChildApplications="false"> - <system.webServer> - <handlers> - <add name="aspNetCore" path="*" verb="*" modules="AspNetCoreModuleV2" /> - </handlers> - <aspNetCore processPath="dotnet" arguments=".\MyApp.dll" stdoutLogEnabled="false" /> - </system.webServer> - </location> -</configuration> -``` - ---- - -## Migration Checklist - -- [ ] Convert .csproj to SDK-style (`<Project Sdk="Microsoft.NET.Sdk.Web">`) -- [ ] Update TargetFramework to `net8.0` -- [ ] Replace all `System.Web.*` → `Microsoft.AspNetCore.*` -- [ ] Create `appsettings.json` and migrate Web.config settings -- [ ] Create `Program.cs` and migrate Global.asax logic -- [ ] Update controllers (`ActionResult` → `IActionResult`, `ApiController` → `ControllerBase`) -- [ ] Migrate Entity Framework 6 → EF Core (if used) -- [ ] Convert HTTP modules to middleware -- [ ] Move static files to `wwwroot/` -- [ ] Set up dependency injection for all services -- [ ] Update authentication/authorization (if used) -- [ ] Test all endpoints and views -- [ ] Delete `Web.config`, `Global.asax`, `packages.config`, `App_Start/` - ---- - -## Resources - -- [.NET Upgrade Assistant](https://dotnet.microsoft.com/en-us/platform/upgrade-assistant) - Automated migration tool -- [ASP.NET Core Migration Guide](https://learn.microsoft.com/en-us/aspnet/core/migration/proper-to-2x/) -- [EF6 to EF Core Migration](https://learn.microsoft.com/en-us/ef/efcore-and-ef6/porting/) -- [.NET 8 Documentation](https://learn.microsoft.com/en-us/dotnet/core/whats-new/dotnet-8) -- [ASP.NET Core Fundamentals](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/) diff --git a/harness/skill-bundle/goose-migration/references/javaee-quarkus.md b/harness/skill-bundle/goose-migration/references/javaee-quarkus.md deleted file mode 100644 index 53b66fc..0000000 --- a/harness/skill-bundle/goose-migration/references/javaee-quarkus.md +++ /dev/null @@ -1,337 +0,0 @@ ---- -name: javaee-quarkus -description: Migration patterns for Java EE 7/8 (WebLogic, JBoss, WildFly) to Quarkus 3 -applies_to: - manifests: - pom_xml: true - graph_patterns: - - "imports contains javax.ejb" - - "imports contains javax.jms" - - "annotations contains @Stateless" - - "annotations contains @MessageDriven" - - "annotations contains @EJB" ---- - -# Java EE → Quarkus 3: Migration Reference - -This file teaches Goose the **patterns and rules** for any Java EE → Quarkus 3 migration. -The bootstrap script generates the actual project-specific file list dynamically. -Write this reference's name in the goal section of Plan.md to track which reference was used. ---- - -## Migration Order (by layer — script fills in real file paths) - -Always migrate in this layer order — dependencies flow upward: - -``` -1. Build config pom.xml -2. App config src/main/resources/application.properties (CREATE if missing) -3. Utils layer any class in utils/, common/, helper/ -4. Persistence layer any class in persistence/, repository/, dao/ -5. Model layer any class in model/, domain/, entity/ -6. Service layer non-MDB services first, then MDB/listener classes last -7. REST layer Application class first, then endpoints/resources/controllers -8. Cleanup DELETE legacy config files (listed below) -``` - -**Why this order:** models have no deps, services depend on models, REST depends on services. -MDB classes are last in services because they need messaging infrastructure set up first. - ---- - -## Files to DELETE after migration - -Remove these — they are replaced by Quarkus config: - -| Delete this | Replaced by | -|---|---| -| `src/main/resources/META-INF/persistence.xml` | `application.properties` datasource config | -| `src/main/webapp/WEB-INF/beans.xml` | Not needed — Quarkus enables CDI automatically | -| `src/main/webapp/WEB-INF/web.xml` | Not needed — Quarkus uses `application.properties` | -| Any `src/main/java/**/weblogic/` directory | Not needed — remove entirely | -| Any class that manually runs Flyway on startup | Not needed — Quarkus Flyway auto-runs | - ---- - -## Import Transformations - -Apply to every file — simple find-and-replace: - -``` -javax.ejb.* → REMOVE (handle via annotation changes below) -javax.inject.* → jakarta.inject.* -javax.enterprise.* → jakarta.enterprise.* -javax.persistence.* → jakarta.persistence.* -javax.ws.rs.* → jakarta.ws.rs.* -javax.transaction.* → jakarta.transaction.* -javax.json.* → jakarta.json.* -javax.xml.bind.* → jakarta.xml.bind.* -javax.validation.* → jakarta.validation.* -javax.annotation.* → jakarta.annotation.* -javax.jms.* → REMOVE (replace with SmallRye Reactive Messaging) -weblogic.* → REMOVE (no replacement) -org.jboss.ejb.* → REMOVE -org.wildfly.* → REMOVE -``` - ---- - -## Annotation Transformations - -``` -@Stateless → @ApplicationScoped -@Stateful → @ApplicationScoped -@Singleton (EJB) → @ApplicationScoped (use jakarta.enterprise, not javax.ejb) -@EJB → @Inject -@Local → REMOVE -@Remote → REMOVE -@TransactionAttribute → @Transactional (jakarta.transaction) -``` - ---- - -## Pattern: EJB Service → CDI Bean - -Any class annotated `@Stateless` or `@Stateful`: - -```java -// BEFORE -import javax.ejb.Stateless; -import javax.inject.Inject; - -@Stateless -public class FooService { - @EJB - private BarService bar; -} - -// AFTER -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; - -@ApplicationScoped -public class FooService { - @Inject - private BarService bar; -} -``` - ---- - -## Pattern: Remote EJB Lookup → Direct Injection - -Any class that does a JNDI lookup for a remote EJB: - -```java -// BEFORE -Hashtable<String, String> env = new Hashtable<>(); -env.put(Context.INITIAL_CONTEXT_FACTORY, "org.wildfly.naming.client..."); -Context ctx = new InitialContext(env); -FooService foo = (FooService) ctx.lookup("ejb:/ROOT/FooService!..."); - -// AFTER -@Inject -FooService foo; // inject directly — no JNDI needed in Quarkus -``` - -Remove: all `Hashtable`, `InitialContext`, `Context.lookup`, JNDI imports, Remote interfaces. - ---- - -## Pattern: MDB → SmallRye Reactive Messaging (@Incoming) - -Any class with `@MessageDriven` / `implements MessageListener`: - -```java -// BEFORE -@MessageDriven(activationConfig = { - @ActivationConfigProperty(propertyName = "destinationType", - propertyValue = "javax.jms.Queue"), - @ActivationConfigProperty(propertyName = "destination", - propertyValue = "java:/queues/myqueue") -}) -public class FooMDB implements MessageListener { - public void onMessage(Message msg) { - String body = ((TextMessage) msg).getText(); - // process body... - } -} - -// AFTER -import jakarta.enterprise.context.ApplicationScoped; -import org.eclipse.microprofile.reactive.messaging.Incoming; - -@ApplicationScoped -public class FooMDB { - @Inject Logger log; - - @Incoming("my-channel") // channel name you choose, matches application.properties - public void onMessage(String body) { - // same processing logic — body arrives as String directly - } -} -``` - -Add to `application.properties`: -```properties -# Production: real AMQP broker -mp.messaging.incoming.my-channel.connector=smallrye-amqp -mp.messaging.incoming.my-channel.address=myqueue - -# Dev: no broker needed -%dev.mp.messaging.incoming.my-channel.connector=smallrye-in-memory -``` - ---- - -## Pattern: JMS Sender → SmallRye Emitter (@Outgoing) - -Any class that sends JMS messages via `JMSContext` or `MessageProducer`: - -```java -// BEFORE -@Resource(mappedName = "java:/topic/orders") -private Topic ordersTopic; -@Inject JMSContext context; - -public void send(String payload) { - context.createProducer().send(ordersTopic, payload); -} - -// AFTER -import org.eclipse.microprofile.reactive.messaging.Channel; -import org.eclipse.microprofile.reactive.messaging.Emitter; - -@Inject @Channel("my-channel") Emitter<String> emitter; - -public void send(String payload) { - emitter.send(payload); -} -``` - -Add to `application.properties`: -```properties -mp.messaging.outgoing.my-channel.connector=smallrye-amqp -mp.messaging.outgoing.my-channel.address=orders -%dev.mp.messaging.outgoing.my-channel.connector=smallrye-in-memory -``` - ---- - -## Pattern: Server Startup Listener → Quarkus Lifecycle Events - -Any class extending a server-specific lifecycle listener (WebLogic, JBoss, etc.): - -```java -// BEFORE (WebLogic example — same idea applies to JBoss, WAS, etc.) -import weblogic.application.ApplicationLifecycleListener; -public class AppStartup extends ApplicationLifecycleListener { - public void postStart(ApplicationLifecycleEvent evt) { ... } - public void preStop(ApplicationLifecycleEvent evt) { ... } -} - -// AFTER — works the same for any server-specific listener -import io.quarkus.runtime.StartupEvent; -import io.quarkus.runtime.ShutdownEvent; -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.event.Observes; - -@ApplicationScoped -public class AppStartup { - void onStart(@Observes StartupEvent ev) { ... } - void onStop(@Observes ShutdownEvent ev) { ... } -} -``` - ---- - -## Pattern: @PostConstruct / @PreDestroy - -`@PostConstruct` still works in Quarkus CDI — keep it if the logic is simple. -For `ApplicationScoped` beans with shutdown logic, prefer lifecycle events: - -```java -// Option A: keep as-is (works fine) -@PostConstruct public void init() { ... } - -// Option B: use events (cleaner for app-scoped beans) -void onStart(@Observes StartupEvent ev) { ... } -void onStop(@Observes ShutdownEvent ev) { ... } -``` - ---- - -## Pattern: persistence.xml → application.properties - -```xml -<!-- BEFORE: src/main/resources/META-INF/persistence.xml --> -<persistence-unit name="primary"> - <jta-data-source>java:jboss/datasources/MyDS</jta-data-source> -</persistence-unit> -``` - -```properties -# AFTER: src/main/resources/application.properties -quarkus.datasource.db-kind=postgresql -quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/mydb -quarkus.datasource.username=${DB_USER:myuser} -quarkus.datasource.password=${DB_PASS:mypass} -quarkus.hibernate-orm.database.generation=none -quarkus.flyway.migrate-at-start=true -quarkus.flyway.locations=classpath:db/migration -``` - ---- - -## pom.xml Changes - -``` -REMOVE: <packaging>war</packaging> -ADD: <packaging>jar</packaging> - -REMOVE: javaee-api dependency -REMOVE: maven-war-plugin - -ADD in dependencyManagement: - io.quarkus.platform:quarkus-bom:3.8.4 (type=pom, scope=import) - -ADD in build/plugins: - io.quarkus.platform:quarkus-maven-plugin:3.8.4 -``` - -Pick extensions based on what the app actually uses: - -| Extension | Replaces | -|---|---| -| `quarkus-arc` | CDI (always include) | -| `quarkus-rest-jackson` | JAX-RS + JSON | -| `quarkus-hibernate-orm` | JPA / Hibernate | -| `quarkus-jdbc-postgresql` | PostgreSQL driver | -| `quarkus-jdbc-h2` | H2 for dev/test | -| `quarkus-flyway` | DB migrations | -| `quarkus-smallrye-reactive-messaging-amqp` | JMS / MDB | -| `quarkus-smallrye-health` | Health endpoints | -| `quarkus-oidc` | Keycloak / OAuth2 | -| `quarkus-smallrye-openapi` | Swagger UI | - ---- - -## Verification - -```bash -# Full compile check -mvn clean compile 2>&1 | tail -30 - -# Start in dev mode (hot reload, no server needed) -mvn quarkus:dev -``` - -Common first errors and fixes: - -| Error | Fix | -|---|---| -| `package javax.* does not exist` | Missed a javax import — grep and replace | -| `cannot find symbol @Incoming` | Add `quarkus-smallrye-reactive-messaging-amqp` to pom.xml | -| `@ApplicationScoped not found` | Add `quarkus-arc` to pom.xml | -| `EntityManager cannot be injected` | Add `@PersistenceContext` or use Panache | -| `ClassNotFoundException weblogic.*` | Delete the weblogic stub directory | diff --git a/harness/skill-bundle/goose-migration/references/python2-to-python3.md b/harness/skill-bundle/goose-migration/references/python2-to-python3.md deleted file mode 100644 index 278f5bb..0000000 --- a/harness/skill-bundle/goose-migration/references/python2-to-python3.md +++ /dev/null @@ -1,579 +0,0 @@ -# Python 2.7 → Python 3.x: Migration Reference - ---- -name: python2-to-python3 -description: Migration patterns for Python 2.7 to Python 3.8+ (print, unicode, imports, xrange) -applies_to: - manifests: - requirements_txt: true - setup_py: true - pyproject_toml: true - graph_patterns: - - "imports contains __future__" - - "nodes contains print statement" - - "imports contains urllib2" - - "imports contains ConfigParser" ---- - -## Migration Order (Layer Dependency) - -Always migrate in this order — dependencies flow upward: - -1. **Build config** - setup.py, requirements.txt, pyproject.toml (Python 3.8+) -2. **Package init** - `__init__.py` files (relative imports) -3. **Utilities** - helper modules, shared code -4. **Data models** - classes, schemas, data structures -5. **Business logic** - services, use cases -6. **API/Views** - Flask/Django routes, FastAPI endpoints -7. **Scripts** - CLI tools, migration scripts -8. **Tests** - unittest → pytest (optional upgrade) -9. **Cleanup** - Remove `.pyc` files, `__pycache__` dirs - -**Why this order**: Build config enables Python 3 first. Utilities have no deps. Models depend on utilities. Logic depends on models. API depends on logic. - ---- - -## Import/Package Transformations - -Many modules were renamed or reorganized in Python 3: - -| Old (Python 2) | New (Python 3) | Notes | -|----------------|----------------|-------| -| `import urllib2` | `import urllib.request` | HTTP requests | -| `import urlparse` | `import urllib.parse` | URL parsing | -| `import ConfigParser` | `import configparser` | Lowercase | -| `import Queue` | `import queue` | Lowercase | -| `import SocketServer` | `import socketserver` | Lowercase | -| `from StringIO import StringIO` | `from io import StringIO` | Text I/O | -| `from cStringIO import StringIO` | `from io import BytesIO` | Binary I/O | -| `import cPickle` | `import pickle` | C implementation merged | -| `import __builtin__` | `import builtins` | Built-in namespace | -| `import httplib` | `import http.client` | HTTP client | -| `import Cookie` | `import http.cookies` | HTTP cookies | -| `from itertools import izip` | Built-in `zip()` | Returns iterator now | -| `from itertools import imap` | Built-in `map()` | Returns iterator now | - ---- - -## Syntax Transformations - -| Old (Python 2) | New (Python 3) | Notes | -|----------------|----------------|-------| -| `print "hello"` | `print("hello")` | Function, not statement | -| `print x, y` | `print(x, y)` | Function call | -| `print >> sys.stderr, "error"` | `print("error", file=sys.stderr)` | Keyword argument | -| `xrange(100)` | `range(100)` | `range()` now returns iterator | -| `dict.iteritems()` | `dict.items()` | Returns iterator | -| `dict.iterkeys()` | `dict.keys()` | Returns iterator | -| `dict.itervalues()` | `dict.values()` | Returns iterator | -| `dict.has_key(k)` | `k in dict` | Method removed | -| `unicode("text")` | `str("text")` | `str` is Unicode now | -| `u"unicode string"` | `"unicode string"` | Default in Python 3 | -| `basestring` | `str` | `basestring` removed | -| `long(100)` | `int(100)` | `long` removed, `int` is unlimited | -| `raw_input()` | `input()` | `raw_input` removed | -| `input()` (evaluates) | `eval(input())` | Old `input()` was unsafe | -| `execfile("file.py")` | `exec(open("file.py").read())` | `execfile` removed | -| `<> ` | `!=` | `<>` operator removed | - ---- - -## Pattern Catalog - -### Pattern 1: Print Statement → Print Function - -**BEFORE (Python 2):** -```python -print "Hello, world!" -print "User:", username -print >> sys.stderr, "Error:", error_msg - -# Multi-line -print "Line 1" -print "Line 2" -``` - -**AFTER (Python 3):** -```python -print("Hello, world!") -print("User:", username) -print("Error:", error_msg, file=sys.stderr) - -# Multi-line -print("Line 1") -print("Line 2") -``` - -**Specific changes:** -1. Add parentheses around all print arguments -2. Replace `print >> file, msg` → `print(msg, file=file)` -3. Remove trailing commas (unless intentional for same-line printing) - -**Why**: `print` is a function in Python 3, not a statement. This enables better IDE support, type hints, and consistency. - ---- - -### Pattern 2: xrange() → range() - -**BEFORE (Python 2):** -```python -# xrange returns iterator (memory efficient) -for i in xrange(1000000): - process(i) - -# range returns list (memory intensive for large ranges) -numbers = range(10) # [0, 1, 2, ..., 9] -``` - -**AFTER (Python 3):** -```python -# range now returns iterator (like xrange) -for i in range(1000000): - process(i) - -# To get a list, wrap in list() -numbers = list(range(10)) # [0, 1, 2, ..., 9] -``` - -**Specific changes:** -1. Replace all `xrange()` → `range()` -2. If you need a list, use `list(range(...))` - -**Why**: Python 3's `range()` is memory-efficient (iterator-based), so `xrange` is no longer needed. - ---- - -### Pattern 3: Dictionary Methods (iteritems, iterkeys, itervalues) - -**BEFORE (Python 2):** -```python -users = {"alice": 25, "bob": 30} - -# Iterate over items (returns iterator) -for name, age in users.iteritems(): - print name, age - -# Iterate over keys -for name in users.iterkeys(): - print name - -# Check if key exists -if users.has_key("alice"): - print "Found alice" - -# Get list of items -items = users.items() # Returns list -``` - -**AFTER (Python 3):** -```python -users = {"alice": 25, "bob": 30} - -# Iterate over items (returns view object, iterator-like) -for name, age in users.items(): - print(name, age) - -# Iterate over keys -for name in users.keys(): - print(name) - -# Check if key exists -if "alice" in users: - print("Found alice") - -# Get list of items (explicit conversion) -items = list(users.items()) # Returns list -``` - -**Specific changes:** -1. Replace `.iteritems()` → `.items()` -2. Replace `.iterkeys()` → `.keys()` (or just iterate over dict directly) -3. Replace `.itervalues()` → `.values()` -4. Replace `.has_key(k)` → `k in dict` -5. If you need a list, wrap in `list()` - -**Why**: Python 3 removed the `iter*` methods because `.items()`, `.keys()`, `.values()` now return memory-efficient views (not lists). - ---- - -### Pattern 4: Unicode Strings (unicode, basestring) - -**BEFORE (Python 2):** -```python -# Explicit unicode string -text = unicode("Hello", "utf-8") -name = u"Alice" - -# Type checking -if isinstance(value, basestring): # str or unicode - print "It's a string" - -# Encoding/decoding -data = "hello".encode("utf-8") # str → bytes -text = data.decode("utf-8") # bytes → unicode -``` - -**AFTER (Python 3):** -```python -# All strings are unicode by default -text = str("Hello") # or just "Hello" -name = "Alice" # No u"" prefix needed - -# Type checking -if isinstance(value, str): # str is now unicode - print("It's a string") - -# Encoding/decoding -data = "hello".encode("utf-8") # str → bytes -text = data.decode("utf-8") # bytes → str -``` - -**Specific changes:** -1. Remove `u""` prefix (optional, but no longer needed) -2. Replace `unicode()` → `str()` -3. Replace `basestring` → `str` -4. Be careful: `str` in Python 2 is bytes, in Python 3 is unicode - -**Why**: Python 3 has unified text (`str`) and binary (`bytes`) types. All strings are Unicode by default. - ---- - -### Pattern 5: urllib2 → urllib.request - -**BEFORE (Python 2):** -```python -import urllib2 -import urlparse - -# Fetch URL -response = urllib2.urlopen("https://api.example.com/data") -data = response.read() - -# Parse URL -parsed = urlparse.urlparse("https://example.com/path?query=1") -print parsed.scheme # "https" -``` - -**AFTER (Python 3):** -```python -import urllib.request -import urllib.parse - -# Fetch URL -response = urllib.request.urlopen("https://api.example.com/data") -data = response.read() - -# Parse URL -parsed = urllib.parse.urlparse("https://example.com/path?query=1") -print(parsed.scheme) # "https" -``` - -**Specific changes:** -1. Replace `import urllib2` → `import urllib.request` -2. Replace `import urlparse` → `import urllib.parse` -3. Replace `urllib2.urlopen()` → `urllib.request.urlopen()` -4. Replace `urlparse.urlparse()` → `urllib.parse.urlparse()` - -**Why**: Python 3 reorganized `urllib` into submodules (`urllib.request`, `urllib.parse`, `urllib.error`). - ---- - -### Pattern 6: input() vs raw_input() - -**BEFORE (Python 2):** -```python -# Read string from user (safe) -name = raw_input("Enter name: ") - -# Read and evaluate expression (DANGEROUS!) -age = input("Enter age: ") # If user types "5", returns int(5) -``` - -**AFTER (Python 3):** -```python -# Read string from user (always returns string) -name = input("Enter name: ") - -# Convert to int explicitly -age = int(input("Enter age: ")) # Safe, no auto-eval -``` - -**Specific changes:** -1. Replace `raw_input()` → `input()` -2. Replace `input()` → `eval(input())` (if you really need eval, which is rare) - -**Why**: Python 2's `input()` was dangerous (auto-evaluated code). Python 3's `input()` always returns a string. - ---- - -### Pattern 7: Exception Handling (as syntax) - -**BEFORE (Python 2):** -```python -try: - risky_operation() -except ValueError, e: # Comma syntax - print "Error:", e -``` - -**AFTER (Python 3):** -```python -try: - risky_operation() -except ValueError as e: # 'as' syntax - print("Error:", e) -``` - -**Specific changes:** -1. Replace `except Exception, e:` → `except Exception as e:` - -**Why**: Python 3 requires the `as` keyword. The comma syntax is removed. - ---- - -### Pattern 8: Relative Imports - -**BEFORE (Python 2 - implicit relative):** -```python -# In mypackage/submodule.py -from utils import helper # Searches current package first -``` - -**AFTER (Python 3 - explicit relative or absolute):** -```python -# Option 1: Explicit relative import -from .utils import helper # Same package - -# Option 2: Absolute import (recommended) -from mypackage.utils import helper -``` - -**Specific changes:** -1. Replace implicit relative imports with explicit `.` prefix or absolute imports -2. Prefer absolute imports for clarity - -**Why**: Python 3 removed implicit relative imports to avoid ambiguity. - ---- - -## Files to DELETE - -| Delete this | Replaced by | Reason | -|-------------|-------------|--------| -| `*.pyc` files | Auto-regenerated | Python 3 bytecode incompatible | -| `__pycache__/` dirs | Auto-created | New bytecode cache location | - -**Note**: `.pyc` files from Python 2 won't work in Python 3. Delete them before running Python 3. - -```bash -find . -name "*.pyc" -delete -find . -type d -name "__pycache__" -exec rm -rf {} + -``` - ---- - -## Files to CREATE - -No new files required, but update: - -| File | Change | -|------|--------| -| `setup.py` | Update `python_requires='>=3.8'` | -| `requirements.txt` | Update package versions (some may need newer versions) | -| `pyproject.toml` | Add `requires-python = ">=3.8"` | - ---- - -## Build File Changes - -### setup.py - -**BEFORE (Python 2):** -```python -from distutils.core import setup - -setup( - name='mypackage', - version='1.0', - py_modules=['mypackage'], -) -``` - -**AFTER (Python 3):** -```python -from setuptools import setup, find_packages - -setup( - name='mypackage', - version='1.0', - packages=find_packages(), - python_requires='>=3.8', # Specify Python 3 requirement - install_requires=[ - 'requests>=2.28.0', - ], -) -``` - -### requirements.txt - -Check for Python 3 compatibility: -```bash -# BEFORE (Python 2) -Django==1.11.29 # Last version supporting Python 2 -requests==2.18.4 - -# AFTER (Python 3) -Django==4.2.0 # Python 3 only -requests==2.31.0 -``` - ---- - -## Verification Commands - -```bash -# 1. Check Python version -python3 --version # Should show 3.8+ - -# 2. Run 2to3 tool (automated migration helper) -2to3 -w mypackage/ # Rewrites files in-place (backup first!) - -# 3. Check for Python 2 syntax -grep -rn "print " . --include="*.py" | grep -v "print(" | wc -l -# Should be 0 (no print statements without parentheses) - -# 4. Check for xrange -grep -rn "xrange" . --include="*.py" | wc -l -# Should be 0 - -# 5. Check for old-style imports -grep -rn "import urllib2" . --include="*.py" | wc -l -# Should be 0 - -# 6. Run tests -python3 -m pytest - -# 7. Check syntax -python3 -m py_compile mypackage/**/*.py -``` - ---- - -## Notes / Gotchas - -### 1. **Integer Division** -Python 2: `5 / 2 = 2` (integer division) -Python 3: `5 / 2 = 2.5` (float division), `5 // 2 = 2` (integer division) - -**Fix**: Use `//` for integer division explicitly. - -### 2. **`map()`, `filter()`, `zip()` Return Iterators** -Python 2: `map(func, list)` returns a list -Python 3: `map(func, list)` returns an iterator - -**Fix**: Wrap in `list()` if you need a list: `list(map(func, list))` - -### 3. **bytes vs str** -In Python 2, `str` is bytes. In Python 3, `str` is unicode and `bytes` is separate. - -**Fix**: Be explicit when working with binary data: -```python -# Python 3 -data = b"binary data" # bytes -text = "unicode text" # str -``` - -### 4. **round() Behavior Changed** -Python 2: `round(2.5) = 3.0` (rounds away from zero) -Python 3: `round(2.5) = 2` (rounds to nearest even) - -**Fix**: If you need old behavior, use `math.floor(x + 0.5)`. - -### 5. **`__future__` Imports** -Many Python 2 codebases use `from __future__ import` for forward compatibility: -```python -from __future__ import print_function # Enable print() in Python 2 -from __future__ import division # Enable 3.x division in Python 2 -from __future__ import unicode_literals # Make "" strings unicode -``` - -**Fix**: These are no-ops in Python 3 (but harmless). You can remove them. - -### 6. **Automated Tool: 2to3** -Python includes a migration tool: -```bash -2to3 -w mypackage/ # Rewrites files in-place -``` - -**Caution**: Always back up your code first! The tool is good but not perfect. - -### 7. **Type Annotations (Optional Upgrade)** -Python 3.5+ supports type hints: -```python -def greet(name: str) -> str: - return f"Hello, {name}" -``` - -This is optional but recommended for new Python 3 code. - -### 8. **f-strings (Python 3.6+)** -Python 3.6 added f-strings for formatting: -```python -# OLD (Python 2) -print("User: %s, Age: %d" % (name, age)) - -# NEW (Python 3.6+) -print(f"User: {name}, Age: {age}") -``` - -### 9. **async/await (Python 3.5+)** -Python 3 has native async support: -```python -async def fetch_data(): - await asyncio.sleep(1) - return "data" -``` - -Not a requirement for migration, but a powerful feature. - -### 10. **Pathlib (Python 3.4+)** -Python 3 introduced `pathlib` for file paths: -```python -# OLD -import os -path = os.path.join("dir", "file.txt") - -# NEW -from pathlib import Path -path = Path("dir") / "file.txt" -``` - ---- - -## Migration Checklist - -- [ ] Update `setup.py` / `pyproject.toml` to require Python 3.8+ -- [ ] Run `2to3` tool on codebase (backup first) -- [ ] Replace all `print` statements with `print()` functions -- [ ] Replace `xrange()` → `range()` -- [ ] Replace `.iteritems()` → `.items()` (and similar) -- [ ] Replace `urllib2` → `urllib.request` -- [ ] Replace `raw_input()` → `input()` -- [ ] Replace `unicode()` → `str()`, remove `basestring` -- [ ] Update exception handling to use `as` syntax -- [ ] Fix relative imports (add `.` prefix or use absolute) -- [ ] Delete all `.pyc` files and `__pycache__` directories -- [ ] Update dependencies to Python 3-compatible versions -- [ ] Run full test suite with Python 3 -- [ ] Check for integer division issues (`/` vs `//`) -- [ ] Verify binary/text handling (`bytes` vs `str`) - ---- - -## Resources - -- [Python 3 Porting Guide](https://docs.python.org/3/howto/pyporting.html) -- [2to3 Documentation](https://docs.python.org/3/library/2to3.html) -- [What's New in Python 3](https://docs.python.org/3/whatsnew/3.0.html) -- [Python 3 Statement](https://python3statement.org/) -- [Conservative Python 3 Porting Guide](https://portingguide.readthedocs.io/) diff --git a/harness/skill-bundle/goose-migration/references/springboot-2-to-3.md b/harness/skill-bundle/goose-migration/references/springboot-2-to-3.md deleted file mode 100644 index fb856e9..0000000 --- a/harness/skill-bundle/goose-migration/references/springboot-2-to-3.md +++ /dev/null @@ -1,554 +0,0 @@ -# Spring Boot 2 → Spring Boot 3: Migration Reference - ---- -name: springboot-2-to-3 -description: Migration patterns for Spring Boot 2.x to Spring Boot 3.x (Jakarta EE, Java 17+) -applies_to: - manifests: - pom_xml: true - graph_patterns: - - "imports contains org.springframework.boot" - - "imports contains javax.persistence" - - "imports contains javax.validation" ---- - -## Migration Order (Layer Dependency) - -Always migrate in this order — dependencies flow upward: - -1. **Build config** - pom.xml or build.gradle (Java 17+, Spring Boot 3.x BOM) -2. **App config** - application.properties / application.yml (deprecated properties) -3. **Security config** - SecurityConfig, WebSecurityConfigurerAdapter replacements -4. **Persistence layer** - Entities, Repositories (JPA → Jakarta Persistence) -5. **Service layer** - Business logic, validators -6. **API layer** - Controllers, REST endpoints -7. **Tests** - Update test dependencies and annotations -8. **Cleanup** - Remove deprecated code - -**Why this order**: Build must support Java 17 first. Security config changes are foundational. Entities have no business logic deps. Services depend on entities. Controllers depend on services. - ---- - -## Import/Package Transformations - -All `javax.*` imports must change to `jakarta.*`: - -| Old (javax) | New (jakarta) | Affected Components | -|-------------|---------------|---------------------| -| `javax.persistence.*` | `jakarta.persistence.*` | JPA entities, repositories | -| `javax.validation.*` | `jakarta.validation.*` | Bean validation (@NotNull, @Valid) | -| `javax.servlet.*` | `jakarta.servlet.*` | Filters, servlets, HTTP utilities | -| `javax.annotation.*` | `jakarta.annotation.*` | @PostConstruct, @PreDestroy, @Resource | -| `javax.transaction.*` | `jakarta.transaction.*` | @Transactional (if using JTA) | -| `javax.xml.bind.*` | `jakarta.xml.bind.*` | JAXB (if used) | - ---- - -## Annotation Transformations - -No annotation renames — just namespace changes. But note these Spring-specific changes: - -| Old Pattern | New Pattern | Notes | -|-------------|-------------|-------| -| `@WebMvcTest` without imports | `@WebMvcTest` + explicit `@Import(SecurityConfig.class)` | Security auto-config changed | -| `WebSecurityConfigurerAdapter` | `SecurityFilterChain` bean | Adapter pattern deprecated | -| `@SpringBootTest(webEnvironment = RANDOM_PORT)` | Same, but check MockMvc setup | Some test utilities changed | - ---- - -## Pattern Catalog - -### Pattern 1: WebSecurityConfigurerAdapter → SecurityFilterChain - -**BEFORE (Spring Boot 2):** -```java -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter; - -@EnableWebSecurity -public class SecurityConfig extends WebSecurityConfigurerAdapter { - - @Override - protected void configure(HttpSecurity http) throws Exception { - http - .authorizeRequests() - .antMatchers("/public/**").permitAll() - .anyRequest().authenticated() - .and() - .formLogin(); - } -} -``` - -**AFTER (Spring Boot 3):** -```java -import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@EnableWebSecurity -public class SecurityConfig { - - @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http - .authorizeHttpRequests(authorize -> authorize - .requestMatchers("/public/**").permitAll() - .anyRequest().authenticated() - ) - .formLogin(form -> form.defaultSuccessUrl("/home")); - return http.build(); - } -} -``` - -**Specific changes:** -1. Remove: `extends WebSecurityConfigurerAdapter` -2. Remove: `@Override protected void configure(HttpSecurity http)` -3. Add: `@Bean public SecurityFilterChain filterChain(HttpSecurity http)` -4. Replace: `.authorizeRequests()` → `.authorizeHttpRequests(authorize -> ...)` -5. Replace: `.antMatchers(...)` → `.requestMatchers(...)` -6. Replace: Method chaining `.and()` → Lambda DSL -7. Add: `return http.build();` at end of method - -**Why**: Spring Security 5.7+ deprecated `WebSecurityConfigurerAdapter`. The new approach uses component-based configuration with `@Bean` methods returning `SecurityFilterChain`. - ---- - -### Pattern 2: JPA Entity (javax → jakarta) - -**BEFORE (Spring Boot 2):** -```java -import javax.persistence.*; -import javax.validation.constraints.NotNull; -import javax.validation.constraints.Size; - -@Entity -@Table(name = "users") -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @NotNull - @Size(min = 3, max = 50) - private String username; - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - private List<Order> orders; -} -``` - -**AFTER (Spring Boot 3):** -```java -import jakarta.persistence.*; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; -import java.util.List; - -@Entity -@Table(name = "users") -public class User { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @NotNull - @Size(min = 3, max = 50) - private String username; - - @OneToMany(mappedBy = "user", cascade = CascadeType.ALL) - private List<Order> orders; -} -``` - -**Specific changes:** -1. Replace: `import javax.persistence.*` → `import jakarta.persistence.*` -2. Replace: `import javax.validation.*` → `import jakarta.validation.*` - -**Why**: Jakarta EE (formerly Java EE) namespace migration. All Jakarta EE 9+ specs use `jakarta.*` instead of `javax.*`. - ---- - -### Pattern 3: REST Controller with Validation - -**BEFORE (Spring Boot 2):** -```java -import org.springframework.web.bind.annotation.*; -import javax.validation.Valid; - -@RestController -@RequestMapping("/api/users") -public class UserController { - - @PostMapping - public User createUser(@Valid @RequestBody User user) { - return userService.save(user); - } -} -``` - -**AFTER (Spring Boot 3):** -```java -import org.springframework.web.bind.annotation.*; -import jakarta.validation.Valid; - -@RestController -@RequestMapping("/api/users") -public class UserController { - - @PostMapping - public User createUser(@Valid @RequestBody User user) { - return userService.save(user); - } -} -``` - -**Specific changes:** -1. Replace: `import javax.validation.Valid` → `import jakarta.validation.Valid` - -**Why**: Jakarta namespace migration. - ---- - -### Pattern 4: Servlet Filter (javax → jakarta) - -**BEFORE (Spring Boot 2):** -```java -import javax.servlet.*; -import javax.servlet.http.HttpServletRequest; -import org.springframework.stereotype.Component; - -@Component -public class LoggingFilter implements Filter { - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { - HttpServletRequest req = (HttpServletRequest) request; - logger.info("Request URI: {}", req.getRequestURI()); - chain.doFilter(request, response); - } -} -``` - -**AFTER (Spring Boot 3):** -```java -import jakarta.servlet.*; -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.stereotype.Component; -import java.io.IOException; - -@Component -public class LoggingFilter implements Filter { - - @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) - throws IOException, ServletException { - HttpServletRequest req = (HttpServletRequest) request; - logger.info("Request URI: {}", req.getRequestURI()); - chain.doFilter(request, response); - } -} -``` - -**Specific changes:** -1. Replace: `import javax.servlet.*` → `import jakarta.servlet.*` - -**Why**: Servlet API moved to Jakarta namespace. - ---- - -### Pattern 5: @PostConstruct / @PreDestroy Lifecycle - -**BEFORE (Spring Boot 2):** -```java -import javax.annotation.PostConstruct; -import javax.annotation.PreDestroy; -import org.springframework.stereotype.Service; - -@Service -public class CacheService { - - @PostConstruct - public void init() { - // Initialize cache - } - - @PreDestroy - public void cleanup() { - // Clear cache - } -} -``` - -**AFTER (Spring Boot 3):** -```java -import jakarta.annotation.PostConstruct; -import jakarta.annotation.PreDestroy; -import org.springframework.stereotype.Service; - -@Service -public class CacheService { - - @PostConstruct - public void init() { - // Initialize cache - } - - @PreDestroy - public void cleanup() { - // Clear cache - } -} -``` - -**Specific changes:** -1. Replace: `import javax.annotation.*` → `import jakarta.annotation.*` - -**Why**: Jakarta namespace migration. - ---- - -## Files to DELETE - -| Delete this | Replaced by | Reason | -|-------------|-------------|--------| -| None typically | N/A | Spring Boot 3 is largely backward-compatible at the file level | - -**Note**: No files are typically deleted, but deprecated code should be updated (see Deprecated Properties below). - ---- - -## Files to CREATE - -No new files required — Spring Boot 3 uses the same structure as Spring Boot 2. - ---- - -## Build File Changes - -### pom.xml - -```xml -<!-- CHANGE: Parent version --> -<parent> - <groupId>org.springframework.boot</groupId> - <artifactId>spring-boot-starter-parent</artifactId> - <version>3.2.0</version> <!-- Was 2.7.x --> -</parent> - -<!-- CHANGE: Java version --> -<properties> - <java.version>17</java.version> <!-- Was 8 or 11 --> -</properties> - -<!-- REMOVE: Javax dependencies (if explicitly declared) --> -<!-- These are now provided by jakarta dependencies via Spring Boot BOM --> -<dependency> - <groupId>javax.persistence</groupId> - <artifactId>javax.persistence-api</artifactId> <!-- REMOVE --> -</dependency> - -<!-- ADD: If using Swagger/OpenAPI, update to springdoc-openapi v2 --> -<dependency> - <groupId>org.springdoc</groupId> - <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId> - <version>2.3.0</version> <!-- Was springdoc-openapi-ui 1.x --> -</dependency> - -<!-- ADD: If using Actuator with Micrometer --> -<!-- Spring Boot 3 uses Micrometer Observation API --> -<dependency> - <groupId>io.micrometer</groupId> - <artifactId>micrometer-tracing-bridge-brave</artifactId> <!-- For distributed tracing --> -</dependency> -``` - -### build.gradle - -```gradle -// CHANGE: Spring Boot plugin version -plugins { - id 'org.springframework.boot' version '3.2.0' // Was 2.7.x - id 'io.spring.dependency-management' version '1.1.4' - id 'java' -} - -// CHANGE: Java version -java { - sourceCompatibility = '17' // Was '8' or '11' -} - -// REMOVE: Javax dependencies (now jakarta via Spring Boot BOM) -// dependencies { -// implementation 'javax.persistence:javax.persistence-api' // REMOVE -// } -``` - ---- - -## application.properties / application.yml Changes - -### Deprecated Properties - -| Old Property (Spring Boot 2) | New Property (Spring Boot 3) | Notes | -|------------------------------|------------------------------|-------| -| `spring.jpa.hibernate.use-new-id-generator-mappings` | Removed | Always true in Hibernate 6 | -| `spring.mvc.throw-exception-if-no-handler-found` | `spring.mvc.problemdetails.enabled=true` | RFC 7807 Problem Details | -| `spring.security.oauth2.resourceserver.jwt.jwk-set-uri` | Same, but check OIDC config | OIDC config may need updates | -| `management.metrics.export.*` | `management.observations.export.*` | Micrometer Observation API | - -### Example application.properties - -```properties -# BEFORE (Spring Boot 2) -spring.jpa.hibernate.use-new-id-generator-mappings=true -spring.mvc.throw-exception-if-no-handler-found=true - -# AFTER (Spring Boot 3) -# spring.jpa.hibernate.use-new-id-generator-mappings — REMOVED (always true) -spring.mvc.problemdetails.enabled=true -``` - ---- - -## Verification Commands - -```bash -# 1. Update dependencies -mvn clean install -# OR -./gradlew clean build - -# 2. Check for javax imports (should be 0, except javax.sql which is OK) -grep -rn "import javax\." src/main/java/ | grep -v "javax.sql" | wc -l -# Should be 0 - -# 3. Check Java version in compiled classes -javap -v target/classes/com/example/MyClass.class | grep "major version" -# Should show "61" (Java 17) or higher - -# 4. Run tests -mvn test -# OR -./gradlew test - -# 5. Start application -mvn spring-boot:run -# OR -./gradlew bootRun - -# 6. Check actuator endpoints (if using Spring Boot Actuator) -curl http://localhost:8080/actuator/health -``` - ---- - -## Notes / Gotchas - -### 1. **Java 17 is REQUIRED** -Spring Boot 3 requires Java 17 as the minimum version. You cannot run it on Java 8 or 11. - -**Action**: Update `JAVA_HOME` and build tool configuration. - -```bash -# Verify Java version -java -version # Should show 17 or higher -``` - -### 2. **Hibernate 6.x Breaking Changes** -Spring Boot 3 uses Hibernate 6.x, which has several changes: -- `use-new-id-generator-mappings` is always `true` (property removed) -- Some HQL queries may need updates -- `@Type` annotation deprecated (use `@JdbcType` or `@JavaType`) - -**Action**: Test all JPA queries and native queries. - -### 3. **Spring Security Configuration** -The `WebSecurityConfigurerAdapter` pattern is removed. All security configs must use `@Bean` methods returning `SecurityFilterChain`. - -**Action**: Refactor all security configs (see Pattern 1). - -### 4. **Spring Cloud Compatibility** -If using Spring Cloud, ensure you're on a compatible version: -- Spring Boot 3.2.x → Spring Cloud 2023.x (codename: Leyton) -- Check: https://spring.io/projects/spring-cloud#overview - -**Action**: Update Spring Cloud BOM version in `pom.xml` / `build.gradle`. - -### 5. **Observability Changes** -Spring Boot 3 uses Micrometer Observation API for tracing and metrics. - -**Before (Spring Boot 2)**: -```java -@Timed("my.metric") -public void doSomething() { } -``` - -**After (Spring Boot 3)**: -```java -@Observed(name = "my.metric") -public void doSomething() { } -``` - -**Action**: Update metrics annotations and check Micrometer documentation. - -### 6. **Swagger/OpenAPI** -If using Swagger UI, update to `springdoc-openapi v2`: -- Old: `springdoc-openapi-ui` 1.x -- New: `springdoc-openapi-starter-webmvc-ui` 2.x - -**Action**: Update dependency and verify Swagger UI at `/swagger-ui.html`. - -### 7. **GraalVM Native Image Support** -Spring Boot 3 has first-class support for GraalVM native images. If interested: -```bash -mvn -Pnative spring-boot:build-image -``` - -### 8. **Logging Pattern Changes** -Default logging pattern may have changed. If you rely on specific log formats, verify `logging.pattern.console` in `application.properties`. - -### 9. **MockMvc Test Changes** -Some `@WebMvcTest` tests may need explicit security config imports: -```java -@WebMvcTest(controllers = UserController.class) -@Import(SecurityConfig.class) // May be required now -public class UserControllerTest { } -``` - -### 10. **Deprecation Warnings** -After migration, run with deprecation warnings enabled to catch remaining issues: -```bash -mvn clean compile -Dmaven.compiler.showDeprecation=true -``` - ---- - -## Migration Checklist - -- [ ] Update Java to 17+ (`JAVA_HOME`, `pom.xml`, `build.gradle`) -- [ ] Update Spring Boot version to 3.2.x in build file -- [ ] Replace all `javax.*` → `jakarta.*` imports -- [ ] Refactor `WebSecurityConfigurerAdapter` → `SecurityFilterChain` -- [ ] Update deprecated `application.properties` entries -- [ ] Update Spring Cloud BOM (if used) -- [ ] Update Swagger/OpenAPI dependency (if used) -- [ ] Run full test suite -- [ ] Test Actuator endpoints -- [ ] Check HQL/native queries with Hibernate 6 -- [ ] Verify application starts and basic flows work - ---- - -## Resources - -- [Spring Boot 3.0 Migration Guide](https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide) -- [Spring Security 6.0 Migration](https://docs.spring.io/spring-security/reference/6.0/migration/index.html) -- [Jakarta EE 9 Namespace](https://jakarta.ee/specifications/platform/9/) -- [Hibernate 6 Migration Guide](https://github.com/hibernate/hibernate-orm/blob/6.0/migration-guide.adoc) diff --git a/harness/skill-bundle/goose-migration/skills/javaee-quarkus/SKILL.md b/harness/skill-bundle/goose-migration/skills/javaee-quarkus/SKILL.md deleted file mode 100644 index 7b14adb..0000000 --- a/harness/skill-bundle/goose-migration/skills/javaee-quarkus/SKILL.md +++ /dev/null @@ -1,142 +0,0 @@ ---- -name: javaee-quarkus -description: > - Sub-skill of goose-migration. Handles Stage 2 execution for Java EE → Quarkus 3 - migrations. Triggers ONLY after migration-plan sub-skill has run and the user has - approved the plan. Reads .goosehints for the file list and transformation rules. - Migrates exactly one file per turn, stops after each, waits for "next". Also handles - "compile", "fix", and "status" commands during the session. - Write this skill's name in the goal section of Plan.md to track which skill was used. ---- - -# Java EE → Quarkus Execution Sub-Skill - -Executes the approved migration plan from `.goosehints`, one file at a time. -Activated after the user says "next", "start", or pastes the session opener prompt. - ---- - -## Startup Sequence - -On first message in session: - -1. Read `.goosehints` — nothing else -2. Confirm the migration order to the user (list file count only, not all paths) -3. Say: `Ready. Migrating item #1 now.` -4. Migrate item #1, stop - -Do NOT read any source files before starting item #1. -Do NOT summarize transformation rules back to the user. - ---- - -## Per-File Execution Loop - -For each file, follow this exact sequence: - -``` -1. cat <file-path> ← read current content -2. Apply transformations ← per rules in .goosehints -3. write/edit the file ← write migrated content -4. Mark as done in .goosehints ← update "Files Already Migrated" checklist -5. Report: "✅ Item #N done: <filename> — <one line summary of changes>" -6. STOP. Wait for "next". -``` - -Never proceed to the next file without being told `next`. -Never read multiple files in one turn. - ---- - -## Handling Special File Types - -### pom.xml -- Change `<packaging>war</packaging>` → `<packaging>jar</packaging>` -- Remove `javaee-api` dependency and `maven-war-plugin` -- Add Quarkus BOM in `<dependencyManagement>` -- Add `quarkus-maven-plugin` in `<build><plugins>` -- Add only the extensions the project actually needs (check what's used in source) -- Do NOT add extensions speculatively - -### application.properties (CREATE NEW if missing) -- Replaces `persistence.xml` datasource config -- Replaces `web.xml` HTTP config -- Add AMQP messaging config only if MDB files exist in the project -- Use `%dev.*` profile for local dev settings - -### Non-MDB Service files (@Stateless / @Stateful) -- Replace `javax.*` → `jakarta.*` imports -- Replace `@Stateless` / `@Stateful` → `@ApplicationScoped` -- Replace `@EJB` → `@Inject` -- Remove `@Local`, `@Remote`, JNDI lookup code -- Remove Remote interface files entirely - -### MDB files (@MessageDriven) -- Replace entire class structure — see pattern in `../references/javaee-quarkus.md` -- Use `@Incoming` channel name derived from the queue/topic name in the original config -- Add matching `mp.messaging.incoming.*` to application.properties - -### DELETE items -- Run `rm <path>` and confirm deletion -- If file doesn't exist, report as already done and move on - ---- - -## Handling User Commands - -| User types | Action | -|---|---| -| `next` | Migrate the next unchecked item in .goosehints, then stop | -| `compile` | Run `mvn clean compile 2>&1 \| tail -30`, show output, stop | -| `fix` | Read first compiler error, fix that one file only, stop | -| `status` | Count checked vs unchecked items in .goosehints, show summary | -| `retry` | Repeat the last failed step | -| `skip` | Mark current item as skipped, move to next | - ---- - -## Compile Error Handling - -When user types `compile`: - -```bash -mvn clean compile 2>&1 | grep -E "ERROR|error:" | head -10 -``` - -Show errors only. Do NOT show full Maven output. - -When user types `fix`: -1. Read the first error line only -2. Identify the file from the error -3. `cat` that file -4. Apply the fix -5. Report what changed -6. Stop — do NOT re-compile automatically - -Common errors and fixes: - -| Error | Fix | -|---|---| -| `package javax.* does not exist` | Find remaining `javax.*` import, replace with `jakarta.*` | -| `cannot find symbol @Incoming` | Add `quarkus-smallrye-reactive-messaging-amqp` to pom.xml | -| `@ApplicationScoped not found` | Add `quarkus-arc` to pom.xml | -| `cannot find symbol Emitter` | Add `@Channel` import from `org.eclipse.microprofile.reactive.messaging` | -| `ClassNotFoundException weblogic` | Delete `src/main/java/**/weblogic/` directory | -| `EntityManager cannot be injected` | Verify `@PersistenceContext` annotation is present | - ---- - -## Completion - -When the last item in .goosehints is checked off: - -``` -🎉 All items migrated. - -Next steps: -1. Type "compile" to run: mvn clean compile -2. Fix any errors with "fix" (one at a time) -3. Once clean: mvn quarkus:dev to start in dev mode -``` - -Do NOT run compile automatically — wait for user. diff --git a/harness/skill-bundle/goose-migration/skills/migration-plan/MIGRATION_PLAN_SKILL_UPDATES.md b/harness/skill-bundle/goose-migration/skills/migration-plan/MIGRATION_PLAN_SKILL_UPDATES.md deleted file mode 100644 index 2131b42..0000000 --- a/harness/skill-bundle/goose-migration/skills/migration-plan/MIGRATION_PLAN_SKILL_UPDATES.md +++ /dev/null @@ -1,236 +0,0 @@ -# Migration-Plan Skill Updates - -## What Changed - -The `migration-plan/SKILL.md` has been updated to work with the new reference system. - ---- - -## New: Reference-Driven Planning (Added to Top) - -**Before**: Generic description -**After**: Clear explanation of the reference-driven approach: - -```markdown -## How This Works - -1. **Reference-Driven**: Read a migration reference (javaee-quarkus.md) - - Migration order, transformations, patterns - -2. **Graph-Powered**: Use code graph for architecture - - Communities, edges, god nodes - -3. **Selective Reading**: Only read complex files (5-8 max) - - Not every file, just structurally complex ones - -4. **Output**: Detailed PLAN.md with specific paths and transformations -``` - ---- - -## Phase 1 Updates: Check Available References - -**Added**: -```markdown -**Check if we have a reference for this**: -- Java EE → Quarkus? → references/javaee-quarkus.md -- Spring Boot 2 → 3? → references/springboot-2-to-3.md -- .NET Framework → .NET 8? → references/dotnet-framework-to-core.md -- Python 2 → 3? → references/python2-to-python3.md -- Something else? → Proceed with generic planning -``` - -**Why**: Helps planner know what to look for in detect.json + graph.json - ---- - -## Phase 2 Updates: Reference Selection Logic - -**Before**: Generic "read the reference that matches" -**After**: Specific selection algorithm: - -```markdown -### 2a. Read Pre-Gathered Context -- detect.json (manifest flags, file counts) -- graph.json (code structure) - -### 2b. Select Migration Reference - -**How to select:** -1. Check detect.json.manifests (pom_xml? package_json?) -2. Check graph patterns (javax.ejb? System.Web?) -3. Match against reference frontmatter -4. Read the matching reference -5. Report which reference you used -``` - -**Why**: Explicit instructions for auto-selection based on frontmatter - ---- - -## Phase 3 Updates: Graph + Reference Integration - -**Before**: Generic "use graph to understand dependencies" -**After**: Specific instructions on combining graph + reference: - -```markdown -### 3a. Use Graph to Understand Architecture -- Communities = architectural layers -- God nodes = high-risk (mark as ⚠️) -- Edges = dependency flow - -### 3b. Match Reference Patterns to Graph -Example: Reference says "@MessageDriven needs conversion" -→ Query graph for nodes with @MessageDriven annotation -→ Mark those files as ⚠️ COMPLEX - -### 3c. Build Migration Order from Reference -Map reference layers to graph communities: -- Community 0 → Build layer -- Community 28 → Models layer -- Community 91 → Services layer -``` - -**Why**: Shows how to USE the reference patterns with graph data - ---- - -## Phase 4 Updates: When to Read Source Files - -**Before**: "Read complex files (max 5)" -**After**: Explicit criteria for reading vs using graph: - -```markdown -### When to Read a Source File - -**READ these**: -- MDB conversions (structural change) -- Security config changes -- Lifecycle listeners -- JNDI lookups -- HTTP modules → middleware -- God nodes using complex patterns - -**DON'T READ these** (graph is enough): -- Simple import changes (javax → jakarta) -- Simple annotation changes (@Stateless → @ApplicationScoped) -- Entity/model classes -- Basic CRUD controllers -``` - -**Why**: Clear decision criteria reduces unnecessary file reads - ---- - -## Phase 5 Updates: Reference Structure - -**Added** before the PLAN.md template: - -```markdown -**IMPORTANT**: The reference you read contains: -- **Migration Order** - Layer dependencies -- **Import/Package Transformations** - Find-and-replace -- **Pattern Catalog** - Before/after examples -- **Files to DELETE/CREATE** -- **Build File Changes** -- **Verification Commands** - -Use these sections to write specific, detailed steps. -``` - -**Also added to Goal section**: -```markdown -## Goal -<restate the goal> -- Reference used: <name of reference file, or "none"> - Example: "Reference used: javaee-quarkus.md" -``` - -**Why**: Reminds planner to USE the reference sections and REPORT which was used - ---- - -## Phase 5 Updates: Complex Step Template - -**Before**: Generic before/after template -**After**: Reference-driven template: - -```markdown -**TIP**: For each complex step, find the matching pattern in the -reference's "Pattern Catalog" section and adapt it to this file. - -### Step 14: ⚠️ COMPLEX — Convert message listener -- What to do: - - BEFORE: <copy from reference pattern catalog> - - AFTER: <copy from reference pattern catalog> - - Specific changes (from reference): - 1. Remove: <from reference> - 2. Add: <from reference> - 3. Replace: <from reference> -``` - -**Why**: Explicit instruction to copy/adapt from reference patterns - ---- - -## Phase 6 Updates: Structured Response - -**Before**: Simple text output -**After**: Required JSON schema: - -```json -{ - "plan_written": true, - "step_count": 34, - "complex_count": 5, - "reference_used": "javaee-quarkus", - "summary": "Migrate 28 Java files..." -} -``` - -**Why**: Ensures the harness can track which reference was used - ---- - -## Summary of Changes - -| Phase | Before | After | -|-------|--------|-------| -| Header | Generic description | Reference-driven workflow explanation | -| Phase 1 | Parse goal | Parse goal + check available references | -| Phase 2 | "Read reference that matches" | Explicit selection algorithm with frontmatter | -| Phase 3 | "Use graph for dependencies" | Graph + reference pattern matching | -| Phase 4 | "Read max 5 files" | Clear criteria: WHEN to read vs use graph | -| Phase 5 | Generic template | Reference-aware template with sections | -| Phase 6 | Text output | Structured JSON with `reference_used` | - ---- - -## Key Benefits - -1. **Reference Selection**: Planner knows HOW to pick the right reference (frontmatter matching) -2. **Graph Integration**: Planner knows HOW to use graph + reference together -3. **Selective Reading**: Clear criteria for when to read source files -4. **Pattern Reuse**: Explicit instruction to copy/adapt from reference catalog -5. **Tracking**: Required to report which reference was used - ---- - -## Testing the Updated Skill - -```bash -cd ~/migration-harness -./install.sh # Reinstall with updated skill - -# Test with a Java EE project -migration-harness ~/coolstore "Migrate to Quarkus 3" - -# Check the output for: -✓ 2e2. reference used: javaee-quarkus -``` - -The planner should now: -1. Auto-select `javaee-quarkus.md` based on pom.xml + javax.ejb patterns -2. Use the reference's pattern catalog for complex steps -3. Report "Reference used: javaee-quarkus.md" in PLAN.md Goal section -4. Return `"reference_used": "javaee-quarkus"` in JSON response diff --git a/harness/skill-bundle/goose-migration/skills/python2-to-python3/SKILL.md b/harness/skill-bundle/goose-migration/skills/python2-to-python3/SKILL.md deleted file mode 100644 index 1a22f8a..0000000 --- a/harness/skill-bundle/goose-migration/skills/python2-to-python3/SKILL.md +++ /dev/null @@ -1,71 +0,0 @@ ---- -name: python2-to-python3 -description: > - Sub-skill of goose-migration. Handles Stage 2 execution for Python 2 → Python 3 - migrations. Triggers ONLY after migration-plan has run and plan is approved. - Migrates one file per turn, stops after each, waits for "next". Handles - "compile", "fix", and "status" commands. ---- - -# Python 2 → Python 3 Execution Sub-Skill - -Executes the approved migration plan from `.goosehints`, one file at a time. - ---- - -## Startup Sequence - -1. Read `.goosehints` only — nothing else -2. Confirm file count to user -3. Say: `Ready. Migrating item #1 now.` -4. Migrate item #1, stop - ---- - -## Per-File Loop - -``` -1. cat <file> ← read current content -2. Apply transforms ← per rules below -3. Write file ← write migrated content -4. Update .goosehints checklist -5. Report: "✅ Item #N done: <filename> — <changes>" -6. STOP. Wait for "next". -``` - ---- - -## Transformation Rules - -``` -print "x" → print("x") -print >> sys.stderr → print(..., file=sys.stderr) -xrange(n) → range(n) -unicode(x) → str(x) -basestring → str -long → int -raw_input(...) → input(...) -except E, e: → except E as e: -raise E, msg → raise E(msg) -dict.iteritems() → dict.items() -dict.itervalues() → dict.values() -dict.iterkeys() → dict.keys() -has_key(x) → x in dict -/ (integer division) → // where integer result needed -u"string" → "string" (str is unicode in Python 3) -import cPickle → import pickle -import ConfigParser → import configparser -import Queue → import queue -``` - ---- - -## Verify Commands - -| User types | Action | -|---|---| -| `next` | Migrate next file, stop | -| `compile` | Run `python3 -m py_compile <last-file>`, show result | -| `test` | Run `python3 -m pytest -x -q 2>&1 \| head -30`, show result | -| `fix` | Fix first error in last file, stop | -| `status` | Show checked vs unchecked in .goosehints | diff --git a/images/agent-base-goose-java/Containerfile b/images/agent-base-goose-java/Containerfile deleted file mode 100644 index 098d2ac..0000000 --- a/images/agent-base-goose-java/Containerfile +++ /dev/null @@ -1,87 +0,0 @@ -# agent-base-goose-java: Migration harness image with Goose + Java toolchain. -# -# Multi-stage build: compiles the Go harness binary, then layers Goose, -# JDK 21, Maven, graphifyy, and the migration recipes/skills into a -# UBI 10 runtime image. -# -# Build context must be the repository root: -# podman build -t agent-base-goose-java -f images/agent-base-goose-java/Containerfile . - -# --- Build stage --- -FROM golang:1.26 AS builder - -WORKDIR /src -COPY harness/go.mod harness/go.sum ./ -RUN go mod download -COPY harness/cmd/ cmd/ -COPY harness/internal/ internal/ -RUN CGO_ENABLED=0 go build -o /migration-harness ./cmd/migration-harness/ - -# --- Runtime stage --- -FROM registry.access.redhat.com/ubi10/ubi:latest - -# Install system dependencies -RUN dnf install -y \ - curl \ - wget \ - git \ - unzip \ - ca-certificates \ - gcc \ - gcc-c++ \ - make \ - python3 \ - python3-pip \ - python3-devel \ - libffi-devel \ - pkg-config \ - bzip2 \ - tar \ - && dnf clean all - -# Install graphifyy (code graph generator - required for detect step) -RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 - -# Install Java (OpenJDK 21) and Maven -RUN dnf install -y \ - java-21-openjdk-devel \ - maven \ - && dnf clean all - -# Install Goose (Block's LLM orchestrator) -RUN ARCH=$(uname -m) \ - && if [ "$ARCH" = "x86_64" ]; then GOOSE_ARCH="x86_64-unknown-linux-gnu"; \ - elif [ "$ARCH" = "aarch64" ]; then GOOSE_ARCH="aarch64-unknown-linux-gnu"; \ - else echo "Unsupported architecture: $ARCH" && exit 1; fi \ - && curl -fsSL "https://github.com/block/goose/releases/download/stable/goose-${GOOSE_ARCH}.tar.bz2" -o /tmp/goose.tar.bz2 \ - && tar -xjf /tmp/goose.tar.bz2 -C /tmp \ - && mv /tmp/goose /usr/local/bin/goose \ - && chmod +x /usr/local/bin/goose \ - && rm -f /tmp/goose.tar.bz2 - -RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ - && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current -ENV JAVA_HOME=/usr/lib/jvm/java-current -ENV PATH="/opt/migration-harness/bin:${JAVA_HOME}/bin:${PATH}" -ENV PYTHONUNBUFFERED=1 -ENV HARNESS_WORK_DIR=/workspace -ENV KONVEYOR_INSTALL_DIR=/opt/migration-harness - -# Copy Go binary from build stage -COPY --from=builder /migration-harness /opt/migration-harness/bin/migration-harness - -# Copy recipes and skill bundle -COPY harness/recipes/ /opt/migration-harness/recipes/ -COPY harness/skill-bundle/ /opt/migration-harness/skill-bundle/ - -# Create directories used by the controller and harness -RUN mkdir -p /opt/skills /workspace /.konveyor /home/harness/.migration-harness \ - && useradd -r -d /home/harness -s /sbin/nologin harness \ - && chown -R harness:harness /home/harness /workspace /tmp - -WORKDIR /workspace - -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run", "--auto-approve"] diff --git a/images/agent-base/Containerfile b/images/agent-base/Containerfile new file mode 100644 index 0000000..8813c24 --- /dev/null +++ b/images/agent-base/Containerfile @@ -0,0 +1,52 @@ +# agent-base: Foundation image with goose CLI, git, and the migration harness binary. +# All stage images extend this base. +# +# Build context must be the repository root: +# podman build -t agent-base -f images/agent-base/Containerfile . + +# --- Build stage --- +FROM golang:1.26 AS builder + +WORKDIR /src +COPY harness/go.mod harness/go.sum ./ +RUN go mod download +COPY harness/cmd/ cmd/ +COPY harness/internal/ internal/ +RUN CGO_ENABLED=0 go build -o /migration-harness ./cmd/migration-harness/ + +# --- Runtime stage --- +FROM registry.access.redhat.com/ubi10/ubi:latest + +RUN dnf install -y \ + curl \ + git \ + ca-certificates \ + bzip2 \ + tar \ + && dnf clean all + +# Install Goose (Block's LLM orchestrator) +RUN ARCH=$(uname -m) \ + && if [ "$ARCH" = "x86_64" ]; then GOOSE_ARCH="x86_64-unknown-linux-gnu"; \ + elif [ "$ARCH" = "aarch64" ]; then GOOSE_ARCH="aarch64-unknown-linux-gnu"; \ + else echo "Unsupported architecture: $ARCH" && exit 1; fi \ + && curl -fsSL "https://github.com/block/goose/releases/download/stable/goose-${GOOSE_ARCH}.tar.bz2" -o /tmp/goose.tar.bz2 \ + && tar -xjf /tmp/goose.tar.bz2 -C /tmp \ + && mv /tmp/goose /usr/local/bin/goose \ + && chmod +x /usr/local/bin/goose \ + && rm -f /tmp/goose.tar.bz2 + +ENV PATH="/opt/migration-harness/bin:${PATH}" +ENV HARNESS_WORK_DIR=/workspace/repo + +COPY --from=builder /migration-harness /opt/migration-harness/bin/migration-harness + +RUN mkdir -p /opt/skills /workspace /home/harness/.migration-harness \ + && useradd -r -d /home/harness -s /sbin/nologin harness \ + && chown -R harness:harness /home/harness /workspace /tmp + +WORKDIR /workspace +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-execute/Containerfile b/images/agent-execute/Containerfile new file mode 100644 index 0000000..ecb1015 --- /dev/null +++ b/images/agent-execute/Containerfile @@ -0,0 +1,4 @@ +# agent-execute: Execute stage toolchain image. Skill content comes +# from SkillCards mounted at runtime. + +FROM agent-java-base diff --git a/images/agent-java-base/Containerfile b/images/agent-java-base/Containerfile new file mode 100644 index 0000000..6af6e32 --- /dev/null +++ b/images/agent-java-base/Containerfile @@ -0,0 +1,24 @@ +# agent-java-base: Shared base for Java migration stages (execute, verify). +# Adds JDK 21 and Maven on top of agent-base. +# +# Build context must be the repository root: +# podman build -t agent-java-base -f images/agent-java-base/Containerfile . + +FROM agent-base AS base + +USER root + +RUN dnf install -y \ + java-21-openjdk-devel \ + maven \ + && dnf clean all + +RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ + && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current +ENV JAVA_HOME=/usr/lib/jvm/java-current +ENV PATH="${JAVA_HOME}/bin:${PATH}" + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-plan/Containerfile b/images/agent-plan/Containerfile new file mode 100644 index 0000000..0dd4493 --- /dev/null +++ b/images/agent-plan/Containerfile @@ -0,0 +1,25 @@ +# agent-plan: Plan stage toolchain image. Adds graphify for code graph +# generation. Skill content comes from SkillCards mounted at runtime. + +FROM agent-base AS base + +USER root + +RUN dnf install -y \ + python3 \ + python3-pip \ + python3-devel \ + gcc \ + gcc-c++ \ + libffi-devel \ + pkg-config \ + && dnf clean all + +RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 + +ENV PYTHONUNBUFFERED=1 + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-verify/Containerfile b/images/agent-verify/Containerfile new file mode 100644 index 0000000..a0986cb --- /dev/null +++ b/images/agent-verify/Containerfile @@ -0,0 +1,4 @@ +# agent-verify: Verify stage toolchain image. Skill content comes +# from SkillCards mounted at runtime. + +FROM agent-java-base diff --git a/skills/execute/SKILL.md b/skills/execute/SKILL.md new file mode 100644 index 0000000..5ce9cd5 --- /dev/null +++ b/skills/execute/SKILL.md @@ -0,0 +1,80 @@ +--- +name: execute +description: > + Reads PLAN.md and executes each migration step sequentially. Applies + transformations file by file, consulting domain-specific reference patterns + provided by loaded migration skills. Use after the plan stage has produced + PLAN.md. +--- + +# Execute Stage + +Executes the approved migration plan from `PLAN.md`, one file at a time. +Works autonomously — processes all items in sequence without waiting. + +## References + +- Check `/opt/skills/*/references/` for domain-specific transformation patterns + (import maps, annotation replacements, file-type handling rules) from loaded + migration skills + +## Startup Sequence + +1. Read `PLAN.md` from the repo root — read it ONCE +2. Check for reference files in other loaded skills at `/opt/skills/*/references/` + for domain-specific transformation patterns +3. Begin executing steps in order, starting with Step 1 + +Do NOT read any source files before starting Step 1. + +Domain-specific transformation patterns (import maps, annotation replacements, +file-type handling rules) are provided by migration skills loaded alongside this +stage skill. Consult their reference files for detailed patterns. + +--- + +## Per-File Execution Loop + +For each step in PLAN.md, follow this exact sequence: + +``` +1. Read the target file +2. Apply transformations per the step's instructions and reference patterns +3. Write the modified file +4. Move to the next step immediately +``` + +### Guardrails + +- You MUST attempt every item in PLAN.md in order. Do not skip items. +- After completing each item, note it mentally before moving to the next. +- Do not re-read PLAN.md after every item — read it once, work through the list. +- If you cannot complete an item, note the reason and move to the next. + Do not get stuck on one item. + +--- + +## Completion + +After executing all steps, append your result to `.konveyor/result.json`: + +Read the existing file (it should have the plan stage entry), parse the +JSON array, append your entry, and write it back. + +Your entry: + +```json +{"stage": "execute", "status": "succeeded", "summary": "<1-2 sentences: what was migrated and how many steps completed>"} +``` + +Or on failure: + +```json +{"stage": "execute", "status": "failed", "reason": "<what went wrong>", "summary": "<what was completed before failure>"} +``` + +## Important + +- Work through ALL items — completeness matters more than perfection +- Do NOT run builds or tests — that is the verify stage's job +- Do NOT modify PLAN.md diff --git a/skills/execute/skill.yaml b/skills/execute/skill.yaml new file mode 100644 index 0000000..0b79129 --- /dev/null +++ b/skills/execute/skill.yaml @@ -0,0 +1,15 @@ +apiVersion: skillimage.io/v1alpha1 +kind: SkillCard +metadata: + name: execute + namespace: konveyor + version: "1.0.0" + description: > + Reads PLAN.md and executes each migration step sequentially. Applies + transformations file by file, consulting domain-specific patterns from + loaded migration skills. + tags: + - migration + - execution +spec: + prompt: SKILL.md diff --git a/skills/javaee-to-quarkus/SKILL.md b/skills/javaee-to-quarkus/SKILL.md new file mode 100644 index 0000000..43b1802 --- /dev/null +++ b/skills/javaee-to-quarkus/SKILL.md @@ -0,0 +1,44 @@ +--- +name: javaee-to-quarkus +description: Migrates Java EE 7/8 applications (WebLogic, JBoss, WildFly) to Quarkus 3. + Use when moving off a traditional Java EE application server to the Quarkus runtime, + not just renaming javax to jakarta. +license: Apache-2.0 +metadata: + source: java-ee-7 + target: quarkus-3 + language: java + build_tool: "maven: mvn compile" + guide_url: https://quarkus.io/guides/migration-guide + generated_by: migration-skills-generator + generated_at: 2026-07-20 +--- + +# Java EE 7/8 to Quarkus 3 Migration + +**Prerequisite:** Ensure your application compiles on Java EE before starting. Java 17 is the minimum JDK for Quarkus 3. + +This migration goes beyond a javax-to-jakarta namespace rename. It replaces the Java EE programming model with Quarkus: EJB → CDI managed beans, JMS/MDB → SmallRye Reactive Messaging, WAR → JAR packaging, persistence.xml → application.properties, JNDI lookups → direct injection, and app server lifecycle hooks → Quarkus lifecycle events. The result is a standalone Quarkus application with no application server dependency. + +## Phases + +Execute in order. After each phase, run the project build and stop if it fails. + +1. **Build Config** — Restructure pom.xml: WAR→JAR, add Quarkus BOM and plugin, replace Java EE dependencies with Quarkus extensions. See `modules/build-config.md`. +2. **App Config** — Replace persistence.xml with application.properties, remove web.xml and beans.xml. See `modules/app-config.md`. +3. **EJB to CDI** — Replace EJB annotations with CDI, remove Remote/Local interfaces, replace JNDI lookups with injection. See `modules/ejb-to-cdi.md`. +4. **Messaging** — Convert MDB classes to SmallRye Reactive Messaging, replace JMS producers with Emitter. See `modules/messaging.md`. +5. **Lifecycle** — Replace server-specific lifecycle listeners with Quarkus startup/shutdown events. See `modules/lifecycle.md`. +6. **Cleanup** — Delete legacy files, remove weblogic/jboss stubs, verify no javax.* EE imports remain. See `modules/cleanup.md`. + +## How to use + +Load each phase's module when starting that phase. Each module contains before/after code examples and references mapping tables in `references/`. Apply every applicable transformation to the codebase. + +## Build gate + +After completing each phase: +1. Detect the project's build tool (check metadata `build_tool` field above, or detect from project files: `pom.xml` → `mvn compile`) +2. Run the build +3. If it fails, fix the issue before proceeding +4. If you cannot fix it, stop and report to the user diff --git a/skills/javaee-to-quarkus/modules/app-config.md b/skills/javaee-to-quarkus/modules/app-config.md new file mode 100644 index 0000000..22448d8 --- /dev/null +++ b/skills/javaee-to-quarkus/modules/app-config.md @@ -0,0 +1,68 @@ +# Phase: App Config + +Replace Java EE XML configuration with Quarkus application.properties. + +## Steps + +1. Read `references/config-map.md` + +### Replace persistence.xml with application.properties + +```xml +<!-- Before: src/main/resources/META-INF/persistence.xml --> +<persistence-unit name="primary"> + <jta-data-source>java:jboss/datasources/MyDS</jta-data-source> +</persistence-unit> +``` + +```properties +# After: src/main/resources/application.properties +quarkus.datasource.db-kind=postgresql +quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/mydb +quarkus.datasource.username=${DB_USER:myuser} +quarkus.datasource.password=${DB_PASS:mypass} +quarkus.hibernate-orm.database.generation=none +quarkus.flyway.migrate-at-start=true +quarkus.flyway.locations=classpath:db/migration +``` + +Use `%dev.*` profile prefix for local dev settings: + +```properties +%dev.quarkus.datasource.db-kind=h2 +%dev.quarkus.datasource.jdbc.url=jdbc:h2:mem:test +``` + +### Remove legacy XML config files + +Delete these — they are replaced by Quarkus configuration: + +| Delete this | Replaced by | +|---|---| +| `src/main/resources/META-INF/persistence.xml` | `application.properties` datasource config | +| `src/main/webapp/WEB-INF/beans.xml` | Not needed — Quarkus enables CDI automatically | +| `src/main/webapp/WEB-INF/web.xml` | Not needed — Quarkus uses `application.properties` | + +### Add messaging config (only if MDB files exist) + +If the project has `@MessageDriven` classes, add AMQP messaging config: + +```properties +# Production: real AMQP broker +mp.messaging.incoming.my-channel.connector=smallrye-amqp +mp.messaging.incoming.my-channel.address=myqueue + +# Dev: no broker needed +%dev.mp.messaging.incoming.my-channel.connector=smallrye-in-memory +``` + +Channel names are defined in the messaging phase. + +2. Run the build gate + +## Build gate + +Run `mvn compile`. Configuration errors surface as startup failures — also run `mvn quarkus:dev` briefly to verify: +- Datasource connects +- Hibernate/JPA initializes +- No missing config properties diff --git a/skills/javaee-to-quarkus/modules/build-config.md b/skills/javaee-to-quarkus/modules/build-config.md new file mode 100644 index 0000000..a95802f --- /dev/null +++ b/skills/javaee-to-quarkus/modules/build-config.md @@ -0,0 +1,100 @@ +# Phase: Build Config + +Restructure pom.xml for Quarkus: change packaging, add Quarkus BOM and plugin, replace Java EE dependencies with Quarkus extensions. + +## Steps + +1. Read `references/dependency-map.md` +2. Open `pom.xml` + +### Packaging + +```xml +<!-- Before --> +<packaging>war</packaging> + +<!-- After --> +<packaging>jar</packaging> +``` + +### Remove Java EE umbrella dependency + +```xml +<!-- Remove --> +<dependency> + <groupId>javax</groupId> + <artifactId>javaee-api</artifactId> +</dependency> +``` + +Also remove `maven-war-plugin` from `<build><plugins>`. + +### Add Quarkus BOM + +```xml +<dependencyManagement> + <dependencies> + <dependency> + <groupId>io.quarkus.platform</groupId> + <artifactId>quarkus-bom</artifactId> + <version>3.8.4</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> +</dependencyManagement> +``` + +### Add Quarkus Maven plugin + +```xml +<build> + <plugins> + <plugin> + <groupId>io.quarkus.platform</groupId> + <artifactId>quarkus-maven-plugin</artifactId> + <version>3.8.4</version> + <extensions>true</extensions> + <executions> + <execution> + <goals> + <goal>build</goal> + <goal>generate-code</goal> + <goal>generate-code-tests</goal> + </goals> + </execution> + </executions> + </plugin> + </plugins> +</build> +``` + +### Add Quarkus extensions + +Pick extensions based on what the app actually uses. See `references/dependency-map.md` for the full mapping. + +Key examples: + +| App uses | Quarkus extension | +|---|---| +| CDI / EJB | `quarkus-arc` (always include) | +| JAX-RS + JSON | `quarkus-rest-jackson` | +| JPA / Hibernate | `quarkus-hibernate-orm` | +| PostgreSQL | `quarkus-jdbc-postgresql` | +| H2 (dev/test) | `quarkus-jdbc-h2` | +| DB migrations | `quarkus-flyway` | +| JMS / MDB | `quarkus-smallrye-reactive-messaging-amqp` | +| Health checks | `quarkus-smallrye-health` | +| OAuth2 / Keycloak | `quarkus-oidc` | +| Swagger UI | `quarkus-smallrye-openapi` | + +Do NOT add extensions speculatively — only add what the source code needs. + +3. Run the build gate + +## Build gate + +Run `mvn compile`. Common issues: +- Missing Quarkus extensions for dependencies the app uses +- Version conflicts between old Java EE JARs and Quarkus-managed dependencies +- `maven-war-plugin` left in pom.xml after packaging change diff --git a/skills/javaee-to-quarkus/modules/cleanup.md b/skills/javaee-to-quarkus/modules/cleanup.md new file mode 100644 index 0000000..83641c6 --- /dev/null +++ b/skills/javaee-to-quarkus/modules/cleanup.md @@ -0,0 +1,61 @@ +# Phase: Cleanup and Verification + +Delete legacy files, remove remaining server stubs, verify no javax.* EE imports remain. + +## Steps + +### 1. Delete legacy directories + +Remove any server-specific stub directories: +```bash +find . -type d -name "weblogic" -exec rm -rf {} + +``` + +### 2. Delete legacy config files + +If not already deleted in the app-config phase: +- `src/main/resources/META-INF/persistence.xml` +- `src/main/webapp/WEB-INF/beans.xml` +- `src/main/webapp/WEB-INF/web.xml` +- Any `src/main/java/**/weblogic/` directories + +### 3. Verify no javax.* EE imports remain + +```bash +grep -rn "import javax\." --include="*.java" src/ +``` + +All `javax.ejb`, `javax.jms`, `javax.inject`, `javax.enterprise`, `javax.persistence`, `javax.ws.rs`, `javax.transaction`, `javax.annotation` imports should be gone or replaced with `jakarta.*`. + +**Leave unchanged** (Java SE packages): `javax.sql`, `javax.crypto`, `javax.net`, `javax.naming`, `javax.xml.parsers`, `javax.xml.transform`. + +### 4. Verify no JNDI lookups remain + +```bash +grep -rn "InitialContext\|Context.lookup\|INITIAL_CONTEXT_FACTORY" --include="*.java" src/ +``` + +Should find zero results. + +### 5. Verify no EJB annotations remain + +```bash +grep -rn "@Stateless\|@Stateful\|@MessageDriven\|@EJB\|@Local\b\|@Remote\b" --include="*.java" src/ +``` + +Should find zero results. + +### 6. Final build and test + +```bash +mvn clean compile +mvn test +mvn quarkus:dev +``` + +### 7. Report to user + +- List all changes made across all phases +- Flag any remaining javax.* imports that could not be migrated +- Note any MDB classes that need manual channel configuration +- Report test results (failures are expected and documented, not fixed) diff --git a/skills/javaee-to-quarkus/modules/ejb-to-cdi.md b/skills/javaee-to-quarkus/modules/ejb-to-cdi.md new file mode 100644 index 0000000..00ac35e --- /dev/null +++ b/skills/javaee-to-quarkus/modules/ejb-to-cdi.md @@ -0,0 +1,91 @@ +# Phase: EJB to CDI + +Replace EJB annotations with CDI managed beans. Remove Remote/Local interfaces and JNDI lookups. + +## Steps + +1. Read `references/annotation-map.md` +2. Process all Java source files + +### Annotation replacements + +Apply to every EJB class: + +| Before | After | +|---|---| +| `@Stateless` | `@ApplicationScoped` | +| `@Stateful` | `@ApplicationScoped` | +| `@Singleton` (javax.ejb) | `@ApplicationScoped` (jakarta.enterprise) | +| `@EJB` | `@Inject` | +| `@Local` | REMOVE | +| `@Remote` | REMOVE | +| `@LocalBean` | REMOVE | +| `@TransactionAttribute` | `@Transactional` (jakarta.transaction) | + +### Import replacements + +``` +javax.ejb.* → REMOVE (handled via annotation changes above) +javax.inject.* → jakarta.inject.* +javax.enterprise.* → jakarta.enterprise.* +javax.persistence.* → jakarta.persistence.* +javax.ws.rs.* → jakarta.ws.rs.* +javax.transaction.* → jakarta.transaction.* +javax.json.* → jakarta.json.* +javax.xml.bind.* → jakarta.xml.bind.* +javax.validation.* → jakarta.validation.* +javax.annotation.* → jakarta.annotation.* +``` + +### Example: EJB service → CDI bean + +```java +// Before +import javax.ejb.Stateless; +import javax.ejb.EJB; + +@Stateless +public class OrderService { + @EJB + private InventoryService inventory; +} + +// After +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class OrderService { + @Inject + private InventoryService inventory; +} +``` + +### Remove Remote interfaces + +Delete any `@Remote` interface files. Update classes that implemented them to remove the `implements` clause. + +### Replace JNDI lookups with injection + +```java +// Before +Hashtable<String, String> env = new Hashtable<>(); +env.put(Context.INITIAL_CONTEXT_FACTORY, "org.wildfly.naming.client..."); +Context ctx = new InitialContext(env); +FooService foo = (FooService) ctx.lookup("ejb:/ROOT/FooService!..."); + +// After +@Inject +FooService foo; +``` + +Remove all `Hashtable`, `InitialContext`, `Context.lookup`, JNDI imports. + +3. Run the build gate + +## Build gate + +Run `mvn compile`. Common issues: +- Missing `quarkus-arc` extension in pom.xml +- Dangling references to deleted Remote interfaces +- JNDI lookup code still present diff --git a/skills/javaee-to-quarkus/modules/lifecycle.md b/skills/javaee-to-quarkus/modules/lifecycle.md new file mode 100644 index 0000000..bfd6e4a --- /dev/null +++ b/skills/javaee-to-quarkus/modules/lifecycle.md @@ -0,0 +1,72 @@ +# Phase: Lifecycle + +Replace application server lifecycle listeners with Quarkus lifecycle events. + +## Steps + +1. Identify classes extending server-specific lifecycle listeners +2. Replace with Quarkus `@Observes` events + +### Server lifecycle → Quarkus events + +```java +// Before (WebLogic example — same pattern applies to JBoss, WAS, etc.) +import weblogic.application.ApplicationLifecycleListener; +import weblogic.application.ApplicationLifecycleEvent; + +public class AppStartup extends ApplicationLifecycleListener { + public void postStart(ApplicationLifecycleEvent evt) { ... } + public void preStop(ApplicationLifecycleEvent evt) { ... } +} + +// After +import io.quarkus.runtime.StartupEvent; +import io.quarkus.runtime.ShutdownEvent; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +@ApplicationScoped +public class AppStartup { + void onStart(@Observes StartupEvent ev) { ... } + void onStop(@Observes ShutdownEvent ev) { ... } +} +``` + +### @PostConstruct / @PreDestroy + +`@PostConstruct` still works in Quarkus CDI — keep it if the logic is simple. For `ApplicationScoped` beans with shutdown logic, prefer lifecycle events: + +```java +// Option A: keep as-is (works fine) +@PostConstruct public void init() { ... } + +// Option B: use events (cleaner for app-scoped beans) +void onStart(@Observes StartupEvent ev) { ... } +void onStop(@Observes ShutdownEvent ev) { ... } +``` + +### Flyway / DB init on startup + +If the app has a class that manually runs Flyway or Liquibase on startup, delete it. Quarkus handles this automatically: + +```properties +# application.properties +quarkus.flyway.migrate-at-start=true +``` + +### Import removals + +Remove all server-specific imports: +``` +weblogic.* → REMOVE +org.jboss.ejb.* → REMOVE +org.wildfly.* → REMOVE +``` + +3. Run the build gate + +## Build gate + +Run `mvn compile`. Common issues: +- `ClassNotFoundException weblogic.*` — delete the weblogic stub directory +- Missing Quarkus lifecycle event imports diff --git a/skills/javaee-to-quarkus/modules/messaging.md b/skills/javaee-to-quarkus/modules/messaging.md new file mode 100644 index 0000000..109b760 --- /dev/null +++ b/skills/javaee-to-quarkus/modules/messaging.md @@ -0,0 +1,100 @@ +# Phase: Messaging + +Convert MDB (Message-Driven Bean) classes to SmallRye Reactive Messaging. Replace JMS producers with Emitter. + +## Steps + +1. Read `references/pattern-map.md` +2. Identify all MDB classes (`@MessageDriven` / `implements MessageListener`) +3. Identify all JMS producer classes (`JMSContext`, `MessageProducer`) + +### MDB → @Incoming + +```java +// Before +import javax.ejb.MessageDriven; +import javax.ejb.ActivationConfigProperty; +import javax.jms.Message; +import javax.jms.MessageListener; +import javax.jms.TextMessage; + +@MessageDriven(activationConfig = { + @ActivationConfigProperty(propertyName = "destinationType", + propertyValue = "javax.jms.Queue"), + @ActivationConfigProperty(propertyName = "destination", + propertyValue = "java:/queues/orders") +}) +public class OrderServiceMDB implements MessageListener { + public void onMessage(Message msg) { + String body = ((TextMessage) msg).getText(); + // process body... + } +} + +// After +import jakarta.enterprise.context.ApplicationScoped; +import org.eclipse.microprofile.reactive.messaging.Incoming; + +@ApplicationScoped +public class OrderServiceMDB { + @Incoming("orders") + public void onMessage(String body) { + // same processing logic — body arrives as String directly + } +} +``` + +Add to `application.properties`: +```properties +mp.messaging.incoming.orders.connector=smallrye-amqp +mp.messaging.incoming.orders.address=orders +%dev.mp.messaging.incoming.orders.connector=smallrye-in-memory +``` + +Derive the channel name from the queue/topic name in the original `@ActivationConfigProperty`. + +### JMS Producer → @Outgoing / Emitter + +```java +// Before +@Resource(mappedName = "java:/topic/orders") +private Topic ordersTopic; +@Inject JMSContext context; + +public void send(String payload) { + context.createProducer().send(ordersTopic, payload); +} + +// After +import org.eclipse.microprofile.reactive.messaging.Channel; +import org.eclipse.microprofile.reactive.messaging.Emitter; + +@Inject @Channel("orders-out") Emitter<String> emitter; + +public void send(String payload) { + emitter.send(payload); +} +``` + +Add to `application.properties`: +```properties +mp.messaging.outgoing.orders-out.connector=smallrye-amqp +mp.messaging.outgoing.orders-out.address=orders +%dev.mp.messaging.outgoing.orders-out.connector=smallrye-in-memory +``` + +### Import removals + +Remove all JMS imports — they have no direct replacement: +``` +javax.jms.* → REMOVE +``` + +4. Run the build gate + +## Build gate + +Run `mvn compile`. Common issues: +- Missing `quarkus-smallrye-reactive-messaging-amqp` extension in pom.xml +- `cannot find symbol @Incoming` — add the extension +- `cannot find symbol Emitter` — add `@Channel` import from `org.eclipse.microprofile.reactive.messaging` diff --git a/skills/javaee-to-quarkus/references/annotation-map.md b/skills/javaee-to-quarkus/references/annotation-map.md new file mode 100644 index 0000000..d9671ad --- /dev/null +++ b/skills/javaee-to-quarkus/references/annotation-map.md @@ -0,0 +1,56 @@ +# Annotation Map: Java EE → Quarkus/CDI + +Use this table when converting EJB classes in the EJB-to-CDI phase. + +## EJB → CDI Annotations + +| Java EE Annotation | Quarkus Replacement | Import | +|---|---|---| +| `@Stateless` | `@ApplicationScoped` | `jakarta.enterprise.context.ApplicationScoped` | +| `@Stateful` | `@ApplicationScoped` | `jakarta.enterprise.context.ApplicationScoped` | +| `@Singleton` (javax.ejb) | `@ApplicationScoped` | `jakarta.enterprise.context.ApplicationScoped` | +| `@EJB` | `@Inject` | `jakarta.inject.Inject` | +| `@Local` | REMOVE | — | +| `@Remote` | REMOVE | — | +| `@LocalBean` | REMOVE | — | +| `@TransactionAttribute(REQUIRED)` | `@Transactional` | `jakarta.transaction.Transactional` | +| `@TransactionAttribute(REQUIRES_NEW)` | `@Transactional(REQUIRES_NEW)` | `jakarta.transaction.Transactional` | +| `@TransactionAttribute(NOT_SUPPORTED)` | `@Transactional(NOT_SUPPORTED)` | `jakarta.transaction.Transactional` | +| `@Schedule` (EJB Timer) | `@Scheduled` | `io.quarkus.scheduler.Scheduled` | +| `@Timeout` (EJB Timer) | REMOVE — use `@Scheduled` | — | +| `@MessageDriven` | `@ApplicationScoped` + `@Incoming` | See messaging phase | +| `@ActivationConfigProperty` | REMOVE — config in application.properties | — | +| `@Resource` (DataSource) | `@Inject` + `@ConfigProperty` | `jakarta.inject.Inject` | +| `@Resource` (JMS) | `@Inject @Channel` | See messaging phase | +| `@PersistenceContext` | `@Inject` | `jakarta.inject.Inject` | +| `@PersistenceUnit` | `@Inject` | `jakarta.inject.Inject` | + +## JAX-RS Annotations (unchanged, just repackaged) + +These annotations keep the same name but move from `javax.ws.rs` to `jakarta.ws.rs`: + +| Annotation | New Import | +|---|---| +| `@Path` | `jakarta.ws.rs.Path` | +| `@GET`, `@POST`, `@PUT`, `@DELETE` | `jakarta.ws.rs.*` | +| `@Produces`, `@Consumes` | `jakarta.ws.rs.*` | +| `@PathParam`, `@QueryParam` | `jakarta.ws.rs.*` | + +## JPA Annotations (unchanged, just repackaged) + +| Annotation | New Import | +|---|---| +| `@Entity`, `@Table` | `jakarta.persistence.*` | +| `@Id`, `@GeneratedValue` | `jakarta.persistence.*` | +| `@Column`, `@JoinColumn` | `jakarta.persistence.*` | +| `@OneToMany`, `@ManyToOne` | `jakarta.persistence.*` | +| `@NamedQuery` | `jakarta.persistence.*` | + +## Lifecycle Annotations + +| Java EE | Quarkus | Import | +|---|---|---| +| `@PostConstruct` | `@PostConstruct` (keep) | `jakarta.annotation.PostConstruct` | +| `@PreDestroy` | `@PreDestroy` (keep) | `jakarta.annotation.PreDestroy` | +| Server startup listener | `@Observes StartupEvent` | `io.quarkus.runtime.StartupEvent` | +| Server shutdown listener | `@Observes ShutdownEvent` | `io.quarkus.runtime.ShutdownEvent` | diff --git a/skills/javaee-to-quarkus/references/config-map.md b/skills/javaee-to-quarkus/references/config-map.md new file mode 100644 index 0000000..9869e9a --- /dev/null +++ b/skills/javaee-to-quarkus/references/config-map.md @@ -0,0 +1,76 @@ +# Config Map: Java EE XML → Quarkus application.properties + +Use this table when converting configuration in the App Config phase. + +## persistence.xml → application.properties + +| persistence.xml | application.properties | +|---|---| +| `<jta-data-source>java:jboss/datasources/PostgresDS</jta-data-source>` | `quarkus.datasource.db-kind=postgresql` | +| JDBC URL in datasource config | `quarkus.datasource.jdbc.url=jdbc:postgresql://host:5432/db` | +| Username in datasource config | `quarkus.datasource.username=user` | +| Password in datasource config | `quarkus.datasource.password=pass` | +| `<property name="hibernate.dialect" value="..."/>` | Auto-detected from `db-kind` | +| `<property name="hibernate.hbm2ddl.auto" value="update"/>` | `quarkus.hibernate-orm.database.generation=update` | +| `<property name="hibernate.show_sql" value="true"/>` | `quarkus.hibernate-orm.log.sql=true` | +| `<property name="hibernate.format_sql" value="true"/>` | `quarkus.hibernate-orm.log.format-sql=true` | +| `<property name="hibernate.default_schema" value="myschema"/>` | `quarkus.hibernate-orm.database.default-schema=myschema` | + +## web.xml → application.properties + +| web.xml | application.properties | +|---|---| +| `<context-param><param-name>...</param-name>` | `app.custom-param=value` (use `@ConfigProperty`) | +| `<session-config><session-timeout>30</session-timeout>` | Not applicable — Quarkus is stateless by default | +| `<welcome-file-list>` | Not applicable — use `@Path("/")` | +| `<error-page>` | Use JAX-RS `ExceptionMapper` | +| `<servlet-mapping>` | Use JAX-RS `@Path` | +| `<filter-mapping>` | Use JAX-RS `@Provider` + `ContainerRequestFilter` | +| `<security-constraint>` | `quarkus.http.auth.policy.*` or `@RolesAllowed` | + +## Server-specific datasource XML → application.properties + +### WebLogic (weblogic-application.xml / -ds.xml) + +| WebLogic Config | application.properties | +|---|---| +| `<jdbc-data-source><name>MyDS</name>` | `quarkus.datasource.db-kind=postgresql` | +| `<url>jdbc:oracle:thin:@host:1521:orcl</url>` | `quarkus.datasource.jdbc.url=jdbc:oracle:...` | +| `<driver-name>oracle.jdbc.OracleDriver</driver-name>` | Auto-detected from URL | +| `<initial-capacity>5</initial-capacity>` | `quarkus.datasource.jdbc.min-size=5` | +| `<max-capacity>20</max-capacity>` | `quarkus.datasource.jdbc.max-size=20` | + +### JBoss / WildFly (standalone.xml / *-ds.xml) + +| JBoss Config | application.properties | +|---|---| +| `<datasource jndi-name="java:jboss/datasources/MyDS">` | `quarkus.datasource.db-kind=...` | +| `<connection-url>jdbc:postgresql://...</connection-url>` | `quarkus.datasource.jdbc.url=...` | +| `<min-pool-size>5</min-pool-size>` | `quarkus.datasource.jdbc.min-size=5` | +| `<max-pool-size>20</max-pool-size>` | `quarkus.datasource.jdbc.max-size=20` | + +## Messaging config + +| Java EE | application.properties | +|---|---| +| `@ActivationConfigProperty(destination="java:/queues/X")` | `mp.messaging.incoming.x.address=X` | +| JMS ConnectionFactory JNDI | `mp.messaging.incoming.x.connector=smallrye-amqp` | +| Queue vs Topic | `mp.messaging.incoming.x.durable=true` (for topics) | + +## Quarkus dev profiles + +Use `%dev.` prefix for local development, `%test.` for test: + +```properties +# Production +quarkus.datasource.db-kind=postgresql +quarkus.datasource.jdbc.url=jdbc:postgresql://prod-host:5432/mydb + +# Dev — H2 in-memory +%dev.quarkus.datasource.db-kind=h2 +%dev.quarkus.datasource.jdbc.url=jdbc:h2:mem:devdb + +# Test +%test.quarkus.datasource.db-kind=h2 +%test.quarkus.datasource.jdbc.url=jdbc:h2:mem:testdb +``` diff --git a/skills/javaee-to-quarkus/references/dependency-map.md b/skills/javaee-to-quarkus/references/dependency-map.md new file mode 100644 index 0000000..4148d46 --- /dev/null +++ b/skills/javaee-to-quarkus/references/dependency-map.md @@ -0,0 +1,59 @@ +# Dependency Map: Java EE → Quarkus Extensions + +Use this table when updating `pom.xml` in the Build Config phase. + +| Java EE Dependency | Quarkus Extension | Notes | +|---|---|---| +| `javax:javaee-api` | REMOVE | Replaced by individual extensions below | +| `javax:javaee-web-api` | REMOVE | Replaced by individual extensions below | +| CDI (built into javaee-api) | `io.quarkus:quarkus-arc` | Always include — Quarkus CDI engine | +| JAX-RS (built into javaee-api) | `io.quarkus:quarkus-rest` | RESTEasy Reactive | +| JAX-RS + JSON | `io.quarkus:quarkus-rest-jackson` | Adds JSON serialization | +| JPA / Hibernate | `io.quarkus:quarkus-hibernate-orm` | | +| JPA + Panache | `io.quarkus:quarkus-hibernate-orm-panache` | Optional: active record pattern | +| Bean Validation | `io.quarkus:quarkus-hibernate-validator` | | +| JTA / Transactions | `io.quarkus:quarkus-narayana-jta` | `@Transactional` support | +| JMS / MDB | `io.quarkus:quarkus-smallrye-reactive-messaging-amqp` | For AMQP brokers | +| JMS (Kafka) | `io.quarkus:quarkus-smallrye-reactive-messaging-kafka` | For Kafka | +| JSON-P | `io.quarkus:quarkus-jsonp` | | +| JSON-B | `io.quarkus:quarkus-jsonb` | Or use Jackson instead | +| JAXB | `io.quarkus:quarkus-jaxb` | XML binding | +| JAX-WS (SOAP) | `io.quarkiverse.cxf:quarkus-cxf` | Quarkiverse extension | +| `org.hibernate:hibernate-core` | `io.quarkus:quarkus-hibernate-orm` | Quarkus manages version | +| `org.postgresql:postgresql` | `io.quarkus:quarkus-jdbc-postgresql` | | +| `com.h2database:h2` | `io.quarkus:quarkus-jdbc-h2` | Dev/test only | +| `mysql:mysql-connector-java` | `io.quarkus:quarkus-jdbc-mysql` | | +| `org.flywaydb:flyway-core` | `io.quarkus:quarkus-flyway` | | +| `org.liquibase:liquibase-core` | `io.quarkus:quarkus-liquibase` | | +| `org.eclipse.microprofile.*` | `io.quarkus:quarkus-smallrye-*` | MicroProfile impls | +| MicroProfile Config | `io.quarkus:quarkus-config-yaml` | Optional: YAML config support | +| MicroProfile Health | `io.quarkus:quarkus-smallrye-health` | | +| MicroProfile Metrics | `io.quarkus:quarkus-micrometer` | Micrometer preferred over MP Metrics | +| MicroProfile OpenAPI | `io.quarkus:quarkus-smallrye-openapi` | Swagger UI at `/q/swagger-ui` | +| MicroProfile Fault Tolerance | `io.quarkus:quarkus-smallrye-fault-tolerance` | | +| MicroProfile JWT | `io.quarkus:quarkus-smallrye-jwt` | | +| JAAS / Security | `io.quarkus:quarkus-oidc` | For OAuth2 / Keycloak | +| Servlet Filter | `io.quarkus:quarkus-undertow` | Only if truly needed | +| WebSocket | `io.quarkus:quarkus-websockets` | | +| Mail (JavaMail) | `io.quarkus:quarkus-mailer` | | +| Scheduler (EJB Timer) | `io.quarkus:quarkus-scheduler` | Replaces `@Schedule` | +| `maven-war-plugin` | REMOVE | No longer producing WAR | +| `maven-ejb-plugin` | REMOVE | No EJBs | + +## Quarkus BOM + +All Quarkus extensions are version-managed by the BOM. Do NOT specify versions on individual extensions: + +```xml +<dependencyManagement> + <dependencies> + <dependency> + <groupId>io.quarkus.platform</groupId> + <artifactId>quarkus-bom</artifactId> + <version>3.8.4</version> + <type>pom</type> + <scope>import</scope> + </dependency> + </dependencies> +</dependencyManagement> +``` diff --git a/skills/javaee-to-quarkus/references/pattern-map.md b/skills/javaee-to-quarkus/references/pattern-map.md new file mode 100644 index 0000000..0d3cce8 --- /dev/null +++ b/skills/javaee-to-quarkus/references/pattern-map.md @@ -0,0 +1,168 @@ +# Pattern Map: Java EE → Quarkus + +Use this table for structural transformations that go beyond simple annotation swaps. + +## MDB Patterns + +### Queue consumer + +```java +// Before +@MessageDriven(activationConfig = { + @ActivationConfigProperty(propertyName = "destinationType", + propertyValue = "javax.jms.Queue"), + @ActivationConfigProperty(propertyName = "destination", + propertyValue = "java:/queues/OrderQueue") +}) +public class OrderMDB implements MessageListener { + public void onMessage(Message msg) { + TextMessage txt = (TextMessage) msg; + processOrder(txt.getText()); + } +} + +// After +@ApplicationScoped +public class OrderMDB { + @Incoming("order-queue") + public void onMessage(String body) { + processOrder(body); + } +} +``` + +### Topic subscriber + +```java +// Before +@MessageDriven(activationConfig = { + @ActivationConfigProperty(propertyName = "destinationType", + propertyValue = "javax.jms.Topic"), + @ActivationConfigProperty(propertyName = "destination", + propertyValue = "java:/topics/Events") +}) +public class EventMDB implements MessageListener { ... } + +// After +@ApplicationScoped +public class EventMDB { + @Incoming("events") + public CompletionStage<Void> onEvent(String body) { ... } +} +``` + +### JMS producer + +```java +// Before +@Resource(mappedName = "java:/topic/notifications") +private Topic topic; +@Inject JMSContext jms; + +void notify(String msg) { + jms.createProducer().send(topic, msg); +} + +// After +@Inject @Channel("notifications-out") +Emitter<String> emitter; + +void notify(String msg) { + emitter.send(msg); +} +``` + +## JNDI Patterns + +### EJB lookup + +```java +// Before +InitialContext ctx = new InitialContext(); +OrderService svc = (OrderService) ctx.lookup( + "java:global/myapp/OrderService"); + +// After +@Inject OrderService svc; +``` + +### DataSource lookup + +```java +// Before +Context ctx = new InitialContext(); +DataSource ds = (DataSource) ctx.lookup("java:/jdbc/myDS"); + +// After — configured in application.properties, injected via: +@Inject AgroalDataSource dataSource; +// or use Hibernate ORM with @Inject EntityManager +``` + +### Environment entry + +```java +// Before +Context ctx = new InitialContext(); +String value = (String) ctx.lookup("java:comp/env/myConfig"); + +// After +@ConfigProperty(name = "my.config") String value; +``` + +## EJB Timer → Quarkus Scheduler + +```java +// Before +@Singleton +public class BatchJob { + @Schedule(hour = "*/1", persistent = false) + public void hourlyJob(Timer timer) { ... } +} + +// After +@ApplicationScoped +public class BatchJob { + @Scheduled(every = "1h") + public void hourlyJob() { ... } +} +``` + +## Interceptor Pattern + +```java +// Before +@InterceptorBinding +@Target({TYPE, METHOD}) +@Retention(RUNTIME) +public @interface Logged {} + +@Interceptor @Logged +public class LogInterceptor { + @AroundInvoke + public Object log(InvocationContext ctx) throws Exception { + // logging... + return ctx.proceed(); + } +} + +// After — same pattern works in Quarkus CDI, just update imports: +// javax.interceptor.* → jakarta.interceptor.* +// javax.enterprise.* → jakarta.enterprise.* +``` + +## Servlet Filter → JAX-RS Filter + +```java +// Before +@WebFilter("/*") +public class AuthFilter implements Filter { + public void doFilter(ServletRequest req, ...) { ... } +} + +// After +@Provider +public class AuthFilter implements ContainerRequestFilter { + @Override + public void filter(ContainerRequestContext ctx) { ... } +} +``` diff --git a/skills/javaee-to-quarkus/references/sources.md b/skills/javaee-to-quarkus/references/sources.md new file mode 100644 index 0000000..ae790da --- /dev/null +++ b/skills/javaee-to-quarkus/references/sources.md @@ -0,0 +1,21 @@ +# Sources + +Reference documentation used for the mappings in this skill. + +## Official Guides + +- [Quarkus Migration Guide](https://quarkus.io/guides/migration-guide) — Quarkus version migration +- [Quarkus CDI Reference](https://quarkus.io/guides/cdi-reference) — CDI in Quarkus (replaces EJB) +- [Quarkus Hibernate ORM](https://quarkus.io/guides/hibernate-orm) — JPA setup in Quarkus +- [Quarkus Reactive Messaging](https://quarkus.io/guides/amqp) — SmallRye AMQP (replaces JMS/MDB) +- [Quarkus Scheduler](https://quarkus.io/guides/scheduler) — Replaces EJB Timer +- [Quarkus Lifecycle Events](https://quarkus.io/guides/lifecycle) — StartupEvent / ShutdownEvent +- [Quarkus Configuration](https://quarkus.io/guides/config) — application.properties reference +- [Quarkus All Extensions](https://quarkus.io/extensions/) — Searchable extension catalog + +## Migration References + +- [Jakarta EE 9 Migration](https://jakarta.ee/resources/jakarta-ee-9-migration/) — javax → jakarta namespace +- [SmallRye Reactive Messaging](https://smallrye.io/smallrye-reactive-messaging/) — MicroProfile Reactive Messaging +- [MicroProfile Config Spec](https://microprofile.io/microprofile-config/) — @ConfigProperty reference +- [Eclipse MicroProfile](https://microprofile.io/) — MicroProfile specifications overview diff --git a/skills/javaee-to-quarkus/references/verify-errors.md b/skills/javaee-to-quarkus/references/verify-errors.md new file mode 100644 index 0000000..b713171 --- /dev/null +++ b/skills/javaee-to-quarkus/references/verify-errors.md @@ -0,0 +1,59 @@ +# Common Compilation Errors and Fixes + +Use this table during the build gate checks after each phase. + +## Build Config Phase + +| Error | Cause | Fix | +|---|---|---| +| `package javax.ejb does not exist` | Java EE umbrella dependency removed but EJB code not yet migrated | Expected — will fix in EJB-to-CDI phase. If blocking, add `quarkus-arc` temporarily | +| `package javax.jms does not exist` | JMS dependency removed but MDB code not yet migrated | Expected — will fix in Messaging phase | +| `Duplicate dependency: hibernate-core` | Both explicit Hibernate and `quarkus-hibernate-orm` | Remove explicit `hibernate-core` — Quarkus manages it | +| `Plugin 'maven-war-plugin' not found` | Plugin removed but still referenced in profile | Remove all `maven-war-plugin` references from profiles | +| `Cannot resolve io.quarkus.platform:quarkus-bom` | Missing Quarkus repository or BOM not in `<dependencyManagement>` | Add BOM to `<dependencyManagement>` | + +## EJB-to-CDI Phase + +| Error | Cause | Fix | +|---|---|---| +| `cannot find symbol: class Stateless` | `@Stateless` import removed but annotation still present | Replace `@Stateless` with `@ApplicationScoped` | +| `cannot find symbol: class EJB` | `@EJB` import removed but annotation still present | Replace `@EJB` with `@Inject` | +| `package javax.ejb does not exist` | Leftover `import javax.ejb.*` | Replace with `import jakarta.enterprise.context.*` / `import jakarta.inject.*` | +| `incompatible types: no instance of ... FooRemote` | Class still implements deleted `@Remote` interface | Remove `implements FooRemote` clause | +| `cannot find symbol: class InitialContext` | JNDI lookup code not fully removed | Replace with `@Inject` | +| `Unsatisfied dependency for type X` | Missing `@ApplicationScoped` on the injected bean | Add scope annotation to the bean class | + +## App Config Phase + +| Error | Cause | Fix | +|---|---|---| +| `Unable to find a JDBC driver` | Missing JDBC extension in pom.xml | Add `quarkus-jdbc-postgresql` (or appropriate driver) | +| `Model classes are defined for the default persistence unit but no datasource` | No datasource config in application.properties | Add `quarkus.datasource.*` properties | +| `Could not find a suitable driver` | Wrong `db-kind` value | Check `quarkus.datasource.db-kind` matches the JDBC URL | + +## Messaging Phase + +| Error | Cause | Fix | +|---|---|---| +| `cannot find symbol: class Incoming` | Missing reactive messaging extension | Add `quarkus-smallrye-reactive-messaging-amqp` to pom.xml | +| `cannot find symbol: class Emitter` | Wrong import | Use `org.eclipse.microprofile.reactive.messaging.Emitter` | +| `SRMSG00018: No channel found for name` | Channel name mismatch between `@Incoming("x")` and `mp.messaging.incoming.y` | Make channel names match | +| `SRMSG00015: Unable to connect` | AMQP broker not running in dev mode | Add `%dev.mp.messaging.incoming.*.connector=smallrye-in-memory` | + +## Lifecycle Phase + +| Error | Cause | Fix | +|---|---|---| +| `package weblogic does not exist` | WebLogic imports still present | Delete weblogic import lines and stub directories | +| `cannot find symbol: class ApplicationLifecycleListener` | WebLogic listener base class | Replace with Quarkus `@Observes StartupEvent` / `ShutdownEvent` | +| `package org.jboss.ejb does not exist` | JBoss-specific imports | Remove — use standard CDI instead | + +## General + +| Error | Cause | Fix | +|---|---|---| +| `package javax.inject does not exist` | javax→jakarta rename not applied | Replace `javax.inject` → `jakarta.inject` | +| `package javax.persistence does not exist` | javax→jakarta rename not applied | Replace `javax.persistence` → `jakarta.persistence` | +| `package javax.ws.rs does not exist` | javax→jakarta rename not applied | Replace `javax.ws.rs` → `jakarta.ws.rs` | +| `package javax.annotation does not exist` | javax→jakarta rename not applied | Replace `javax.annotation` → `jakarta.annotation` | +| `beans.xml: Invalid content` | Leftover beans.xml with old schema | Delete beans.xml — Quarkus doesn't need it | diff --git a/skills/plan/SKILL.md b/skills/plan/SKILL.md new file mode 100644 index 0000000..e18a600 --- /dev/null +++ b/skills/plan/SKILL.md @@ -0,0 +1,257 @@ +--- +name: plan +description: > + Reads a project and a goal statement, then produces PLAN.md in the repo root. + The plan is specific to THIS project — real file paths, real dependencies, real + layer ordering. Uses graphify to generate a code graph, then selectively reads + complex files. Does NOT execute any changes. Output is always PLAN.md and + nothing else. Use when starting a new migration to create the migration plan + before any code changes. +--- + +# Plan Stage + +Reads the project, understands the goal, writes `PLAN.md`. +Does NOT modify any source files — planning only. + +## References + +- [references/migration-plan-skill.md](references/migration-plan-skill.md) — + detailed planning methodology with graph-powered discovery, selective reading + strategy, and reference-driven plan generation +- [references/migration-phases.md](references/migration-phases.md) — generic + migration phase guidance for migration types without a specific reference + +## How This Works + +1. **Reference-Driven**: You read a migration reference file (e.g., `javaee-quarkus.md`) that contains: + - Migration order (layer dependencies) + - Import/package transformations + - Pattern catalog (before/after examples for complex changes) + - Files to delete/create + - Verification commands + +2. **Graph-Powered**: The code graph (graph.json) provides: + - Architectural layers (communities) + - File relationships (edges) + - High-risk files (god nodes) + - Which files match which patterns (imports, annotations) + +3. **Selective Reading**: You DON'T read every file. You: + - Read the build manifest (1 file) + - Read the reference (1 file) + - Read 5-8 complex source files that need structural changes + - Use the graph for everything else (imports, annotations, counts) + +4. **Output**: Detailed PLAN.md with: + - Specific file paths (from graph) + - Specific transformations (from reference) + - Correct layer order (from reference + graph communities) + - Complex patterns marked with COMPLEX (from reference + god nodes) + +--- + +## Phase 1 — Generate Code Graph + +Run graphify on the project: + +```bash +graphify update +``` + +This produces `graph.json` in the repo root. + +--- + +## Phase 2 — Understand the Goal + +Your overall migration goal is provided in the prompt above under +"Migration Context" and "Stage Task". Parse these to extract: + +- **What** needs to change (e.g. javax to jakarta, Python 2 to 3, .NET Framework to .NET 8) +- **Scope** — all files? specific layers? specific patterns? +- **Target state** — what does "done" look like? +- **Constraints** — anything to preserve, avoid, or be careful about? + +--- + +## Phase 3 — Discover the Project + +### 3a. Read the Graph + +Read `graph.json` to understand the project architecture: + +1. **Communities (architectural layers)**: + - Community 0 might be build files (pom.xml, package.json) + - Smaller communities often = data models (few dependencies) + - Medium communities = services, business logic + - Large, high-degree communities = API/controllers + +2. **God nodes (high-risk abstractions)**: + - Nodes with degree > 20 are central to the system + - Mark these as COMPLEX in the plan + - Changes here ripple across many files + +3. **Dependency flow**: + - Use edges to understand: who depends on what? + - Models → Services → Controllers (typical layering) + +### 3b. Match Patterns to Graph + +Check which migration patterns exist in the graph: + +**Example (if a Java EE migration skill is loaded)**: +- Look for nodes where `attrs.annotations` contains `@MessageDriven` → Mark as COMPLEX +- Look for nodes where `attrs.imports` contains `javax.ejb` → EJB conversion needed +- Count nodes where `attrs.imports` contains `javax.persistence` → simple import replacement + +### 3c. Build Migration Order + +Map graph communities to migration layers: +- Community 0 (1 file: pom.xml) → Layer 1: Build +- Community 28 (5 files: *Entity.java) → Layer 2: Models +- Community 91 (8 files: *Service.java) → Layer 3: Services +- Community 164 (12 files: *Controller.java) → Layer 4: API + +This gives you the migration sequence WITHOUT reading every file. + +--- + +## Phase 4 — Read Selectively (max 5-8 files) + +### When to Read a Source File + +**READ these**: +- Build manifest (pom.xml, package.json, .csproj) — ALWAYS +- Files matching complex patterns: + - MDB conversions (before/after structure is very different) + - Security config changes + - Lifecycle listeners (e.g., WebLogic ApplicationLifecycleListener) + - JNDI lookups that need refactoring +- God nodes (high-degree) that use complex patterns + +**DON'T READ these** (the graph is enough): +- Files that only need import changes (javax → jakarta) +- Files that only need annotation changes (@Stateless → @ApplicationScoped) +- Simple entity/model classes +- Simple REST controllers with basic CRUD + +**Rules:** +- Read ONE file at a time +- Read ONLY files where the graph is not enough +- If uncertain about a file, mark the step COMPLEX and move on +- Total: ~8-10 file reads across all phases + +--- + +## Phase 5 — Write PLAN.md + +Write `PLAN.md` to the project root with this structure: + +```markdown +# PLAN.md + +## Goal +<restate the goal in one sentence> +- Reference used: <name of reference file, or "none"> + +## Project Summary +- Type: <Maven/Node/Python/.NET/etc> +- Files affected: <N> +- Estimated complexity: <Low/Medium/High> +- Hardest steps: <list the 1-3 most complex items> + +## Steps + +### Step 1: <title> +- File: <exact path from repo root> +- Action: <CREATE | MODIFY | DELETE> +- What to do: <specific instructions for this file> +- Why: <reason — what pattern is being changed> +- Depends on: <step numbers this must come after, or "none"> +- Verify: <how to know this step is done correctly> + +### Step 2: <title> +... + +## Verification +<exact command(s) to run after all steps are done> + +## Notes +<gotchas, special cases, decisions made> +``` + +### Rules for writing steps + +1. **One file per step** — never combine two files in one step +2. **Exact paths** — use real paths from graph.json, not placeholders +3. **Dependency order** — steps that others depend on come first +4. **Layer order** — build config → app config → utils → persistence → models → services → REST/controllers → tests → cleanup/deletions +5. **Hard steps flagged** — add `COMPLEX:` prefix to title for MDB, JNDI, architecture changes, lifecycle listeners +6. **DELETE steps last** — after all modifications are done + +### Step detail levels + +**Mechanical** (simple find-replace changes): +```markdown +### Step 5: Migrate imports in Order.java +- File: src/main/java/com/example/model/Order.java +- Action: MODIFY +- What to do: Replace all `javax.persistence.*` → `jakarta.persistence.*` +- Why: Quarkus uses Jakarta EE namespace +- Depends on: Step 1 +- Verify: No `javax.` imports remain in file +``` + +**Complex** (structural/architectural changes): +```markdown +### Step 14: COMPLEX — Convert message listener to new API +- File: <path> +- Action: MODIFY +- What to do: + - BEFORE: <old pattern — e.g., @MessageDriven listener with JMS API> + - AFTER: <new pattern — e.g., @Incoming reactive consumer> + - Specific changes: + 1. Remove: <old imports/annotations/methods> + 2. Add: <new imports/annotations> + 3. Replace: <method signatures, configuration> + - Affected files: <list config files that also need updates> +- Why: <why the old pattern is not supported> +- Depends on: Step X (prerequisite changes), Step Y (configuration) +- Verify: <grep checks, compile commands> +``` + +--- + +## Phase 6 — Write Result + +After writing PLAN.md, append your result to `.konveyor/result.json`: + +```bash +mkdir -p .konveyor +``` + +If the file exists, read it, parse the JSON array, append your entry, +and write it back. If it does not exist, create it with a single-entry array. + +Your entry: + +```json +{"stage": "plan", "status": "succeeded", "summary": "<1-2 sentences: what you found and what the plan covers>"} +``` + +Or on failure: + +```json +{"stage": "plan", "status": "failed", "reason": "<what went wrong>", "summary": "<what was attempted>"} +``` + +--- + +## Important + +- Do NOT modify source files — planning only +- Do NOT execute any migration steps +- Do NOT skip graphify — the graph is essential for later stages +- Read selectively — the graph gives you most of what you need +- Report which reference you used in the Goal section of PLAN.md diff --git a/harness/skill-bundle/goose-migration/references/migration-phases.md b/skills/plan/references/migration-phases.md similarity index 100% rename from harness/skill-bundle/goose-migration/references/migration-phases.md rename to skills/plan/references/migration-phases.md diff --git a/harness/skill-bundle/goose-migration/skills/migration-plan/SKILL.md b/skills/plan/references/migration-plan-skill.md similarity index 95% rename from harness/skill-bundle/goose-migration/skills/migration-plan/SKILL.md rename to skills/plan/references/migration-plan-skill.md index 049dd6c..8cd1cba 100644 --- a/harness/skill-bundle/goose-migration/skills/migration-plan/SKILL.md +++ b/skills/plan/references/migration-plan-skill.md @@ -50,12 +50,12 @@ Parse the goal statement to extract: - **Target state** — what does "done" look like? - **Constraints** — anything to preserve, avoid, or be careful about? -**Check if we have a reference for this**: -- Java EE → Quarkus? → references/javaee-quarkus.md -- Spring Boot 2 → 3? → references/springboot-2-to-3.md -- .NET Framework → .NET 8? → references/dotnet-framework-to-core.md -- Python 2 → 3? → references/python2-to-python3.md -- Something else? → Proceed with generic planning (use migration-phases.md) +**Check for domain-specific reference files in loaded migration skills**: +```bash +ls /opt/skills/*/references/*.md +``` +Read the matching reference based on the detected project type. +If no reference matches, proceed with generic planning (use migration-phases.md as guide). This helps you know what patterns to look for in Phase 2. @@ -107,15 +107,7 @@ cat graph.json # May be large - read selectively if needed - "annotations contains @MessageDriven" ``` -4. **Read the matching reference** using developer tools: - ```bash - cat references/javaee-quarkus.md - # OR - cat references/springboot-2-to-3.md - # OR - cat references/dotnet-framework-to-core.md - # etc. - ``` +4. **Read the matching reference file from the loaded migration skill**. 5. **If no reference matches**, proceed with generic migration planning (use migration-phases.md as guide). diff --git a/skills/plan/skill.yaml b/skills/plan/skill.yaml new file mode 100644 index 0000000..96e878d --- /dev/null +++ b/skills/plan/skill.yaml @@ -0,0 +1,15 @@ +apiVersion: skillimage.io/v1alpha1 +kind: SkillCard +metadata: + name: plan + namespace: konveyor + version: "1.0.0" + description: > + Analyzes a project using graphify, generates a code graph, and produces + a structured PLAN.md with migration steps. + tags: + - migration + - planning + - graphify +spec: + prompt: SKILL.md diff --git a/skills/verify/SKILL.md b/skills/verify/SKILL.md new file mode 100644 index 0000000..a41196a --- /dev/null +++ b/skills/verify/SKILL.md @@ -0,0 +1,102 @@ +--- +name: verify +description: > + Runs the project build command, parses compiler errors, applies conservative + fixes, and iterates until the build passes or max iterations are reached. + Use after the execute stage has finished migrating source files. +--- + +# Verify Stage + +Verifies the migrated codebase compiles and tests pass. Can attempt +targeted fixes for compilation errors, up to a configurable iteration limit. + +## References + +- Check `/opt/skills/*/references/` for domain-specific error-fix mappings + from loaded migration skills. + +--- + +## Phase 1 — Initial Build + +Run the build command from the PLAN.md Verification section. + +If the build succeeds (exit code 0), skip to Phase 4 (Run Tests). + +If the build fails, extract the errors from the build output. + +--- + +## Phase 2 — Fix Errors + +For each compiler error: + +1. Read the error message to identify the file and issue +2. Read the source file +3. Apply a minimal, conservative fix +4. Do NOT change code that is not related to the error + +Consult reference files from loaded migration skills at +`/opt/skills/*/references/` for common error-fix mappings specific to +this migration type. + +### Fix Rules + +- Fix ONLY compiler errors, not warnings +- Minimal changes only — do not refactor working code +- Only touch the file reported in the error + +--- + +## Phase 3 — Re-verify + +After fixing errors, run the build command again. + +Repeat Phases 2-3 up to the number of iterations specified by +`KONVEYOR_PARAM_MAX_FIX_ITERATIONS` (read from environment, default 3). + +If the build still fails after max iterations, report failure with +the remaining errors. + +--- + +## Phase 4 — Run Tests (if build passes) + +Run the test command from the PLAN.md Verification section. + +Report test results (passed/failed/total counts) but do NOT attempt +to fix failing tests. Test failures are expected after a migration and +are documented in the result, not fixed here. + +--- + +## Phase 5 — Write Result + +Append your result to `.konveyor/result.json`: + +Read the existing file (it should have plan and execute entries), +parse the JSON array, append your entry, and write it back. + +Your entry on success: + +```json +{"stage": "verify", "status": "succeeded", "summary": "<1-2 sentences: build/test results and any fixes applied>"} +``` + +On failure: + +```json +{"stage": "verify", "status": "failed", "reason": "build failed after N fix iterations: <remaining errors>", "summary": "<what was tried and what errors remain>"} +``` + +--- + +## Important + +- Fixes must be minimal and conservative — do not rewrite working code +- Only fix compiler errors, not warnings +- Do NOT modify PLAN.md or files unrelated to the error +- Read `KONVEYOR_PARAM_MAX_FIX_ITERATIONS` from environment for iteration cap +- Track how many fix iterations you have attempted +- Report remaining errors in the result reason if build still fails diff --git a/skills/verify/skill.yaml b/skills/verify/skill.yaml new file mode 100644 index 0000000..3f90508 --- /dev/null +++ b/skills/verify/skill.yaml @@ -0,0 +1,14 @@ +apiVersion: skillimage.io/v1alpha1 +kind: SkillCard +metadata: + name: verify + namespace: konveyor + version: "1.0.0" + description: > + Runs the project build command, parses compiler errors, applies conservative + fixes, and iterates until the build passes or max iterations are reached. + tags: + - migration + - verification +spec: + prompt: SKILL.md From 63641036b0086178570bd0f576c91bfe9020c921 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Tue, 21 Jul 2026 18:25:33 -0400 Subject: [PATCH 2/8] Add changelog fragment for PR #53 Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- changes/unreleased/53-harness-thin-runner.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changes/unreleased/53-harness-thin-runner.yaml diff --git a/changes/unreleased/53-harness-thin-runner.yaml b/changes/unreleased/53-harness-thin-runner.yaml new file mode 100644 index 0000000..f18e331 --- /dev/null +++ b/changes/unreleased/53-harness-thin-runner.yaml @@ -0,0 +1,5 @@ +kind: enhancement + +description: > + Rewrite harness as thin single-stage runner with SkillCard-based skills, + filesystem watcher for incremental commits, and per-stage container images. From 4c53e823e046e3b919654604cf2ea4d6828960d3 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Tue, 21 Jul 2026 18:28:35 -0400 Subject: [PATCH 3/8] Update CI to use new agent image targets The old agent-java-goose-build target was replaced by agent-images-build which builds all three stage images (plan, execute, verify). Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- .github/workflows/images.yml | 12 +- ...arness-thin-runner-and-skillcard-skills.md | 327 ++++ .../2026-07-15-harness-agentplaybookrun.md | 1701 ----------------- ...6-07-15-harness-agentplaybookrun-design.md | 343 ---- images/README.md | 18 +- images/agent-base-goose/Containerfile | 12 - images/agent-execute/Containerfile | 2 +- images/agent-java-base/Containerfile | 2 +- images/agent-java-goose/Containerfile | 15 - images/agent-plan/Containerfile | 2 +- images/agent-verify/Containerfile | 2 +- 11 files changed, 350 insertions(+), 2086 deletions(-) create mode 100644 docs/adr/0006-harness-thin-runner-and-skillcard-skills.md delete mode 100644 docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md delete mode 100644 docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md delete mode 100644 images/agent-base-goose/Containerfile delete mode 100644 images/agent-java-goose/Containerfile diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index e6e2614..5938be5 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -53,10 +53,10 @@ jobs: if: github.event_name != 'pull_request' run: make controller-agent-push - agent-java-goose: + agent-images: permissions: contents: read - name: Build Java migration agent image + name: Build stage agent images runs-on: ubuntu-latest steps: - name: Clone the code @@ -64,8 +64,8 @@ jobs: with: persist-credentials: false - - name: Build image - run: make agent-java-goose-build + - name: Build images + run: make agent-images-build - name: Log in to Quay.io if: github.event_name != 'pull_request' @@ -75,9 +75,9 @@ jobs: username: ${{ vars.QUAY_USERNAME }} password: ${{ secrets.QUAY_PASSWORD }} - - name: Push image + - name: Push images if: github.event_name != 'pull_request' - run: make agent-java-goose-push + run: make agent-images-push controller: permissions: diff --git a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md new file mode 100644 index 0000000..3a780e4 --- /dev/null +++ b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md @@ -0,0 +1,327 @@ +# ADR 0006: Harness as Thin Single-Stage Runner with SkillCard-Based Skills + +**Status:** Accepted +**Date:** 2026-07-21 +**Authors:** Savitha Raghunathan + +## Context + +The migration harness was a monolithic Go binary that orchestrated five +sequential stages (detect, plan, execute, verify, fix-loop) internally. +Each stage was a Go package that constructed multi-turn ACP prompts from +YAML recipe files and an embedded skill bundle. The harness owned both +stage sequencing and migration intelligence. + +With the AgentPlaybookRun controller (ADR 0001) handling stage +sequencing, the harness no longer needs orchestration logic. Meanwhile, +the SkillCard CRD and OCI-based skill packaging provide a clean +mechanism for delivering migration knowledge to agent pods at runtime. + +The harness needed to become a thin, uniform wrapper — identical in +every stage image — that handles only git plumbing and goose lifecycle. +Migration intelligence needed to move from Go code and YAML recipes into +standalone skills that can be versioned, shared, and composed +independently. + +## Decision + +### Thin single-stage runner + +The harness binary exposes a single `run` command. It does not know what +stage it is running, what language is being migrated, or what migration +patterns to apply. Its responsibilities are: + +1. Load configuration from environment variables (`config.LoadFromEnv`) +2. Clone the git repo, strip credentials from the remote, checkout the + target branch +3. Start `goose serve` with LLM provider credentials +4. Connect via ACP WebSocket, create a session +5. Discover skills from `/opt/skills/*/SKILL.md` (glob) +6. Build a single prompt from four context layers: + - `KONVEYOR_PROMPT` — agent-level standing instructions + - `KONVEYOR_PLAYBOOK_INSTRUCTIONS` — playbook guide context + - Skill content (concatenated from all discovered skills) + - `KONVEYOR_INSTRUCTIONS` — stage-specific task +7. Start a filesystem watcher for incremental commit+push +8. Send one ACP prompt and block until completion +9. Stop watcher, read `.konveyor/result.json` for exit status +10. Final commit+push, exit 0 or 1 + +No interactive commands, no file-based config, no multi-turn recipe +execution. + +#### Credential isolation + +Git credentials are stripped from the cloned repo's remote and cleared +from the environment before goose starts. This is a deliberate security +boundary — goose and any skill content it executes cannot access push +credentials. Only the harness binary pushes to git. + +### Two kinds of skills: stage and domain + +Skills are classified into two types with distinct responsibilities: + +- **Stage skills** define *process* — what to do. They encode the + workflow for a stage (plan, execute, verify) without + language-specific knowledge. Stage skills reference domain skills + with directives like "follow the migration phases from any loaded + domain skill." + +- **Domain skills** define *knowledge* — how to do it. They provide + language-specific migration intelligence: annotation maps, + dependency maps, pattern catalogs, phased migration guides. + Example: `javaee-to-quarkus`. + +An agent loads one stage skill and one or more domain skills. The +harness concatenates all discovered skills into the prompt. The stage +skill's process instructions frame the work; the domain skill's +knowledge fills in the specifics. + +Three stage skills encode what was previously spread across Go packages: + +| Skill | Replaces | Purpose | +|-------|----------|---------| +| `plan` | `detect/`, `plan/`, `recipes/plan.yaml` | Run graphify, analyze project, produce PLAN.md | +| `execute` | `execute/`, `recipes/execute.yaml` | Read PLAN.md, apply transformations file by file | +| `verify` | `verify/`, `fixloop/`, `recipes/verify.yaml` | Build, fix errors iteratively, report result | + +Skills are packaged as scratch-based OCI images (`FROM scratch; COPY . /`) +and referenced by SkillCard CRs. The controller mounts them as init +container volumes at `/opt/skills/<name>/` in the agent pod. + +### Skill discovery at runtime (not baked into images) + +The original design specified baking exactly one skill into each stage +image via `COPY skills/plan/ /opt/skills/plan/`. We chose instead to +keep stage images skill-free and mount skills at runtime via SkillCards. + +This means: +- Stage images contain only toolchain (graphify, JDK, Maven) +- Skills are versioned and released independently of images +- The same stage image works with different skill combinations +- The harness discovers all mounted skills via glob, not exactly one + +### Image hierarchy + +Five images, two intermediate bases. Image names include the language +suffix for multi-language support: + +``` +agent-base (UBI 10 + goose + git + harness binary) +├── agent-plan (+ Python 3, graphify — language-agnostic) +├── agent-java-base (+ JDK 21, Maven) +│ ├── agent-execute-java +│ └── agent-verify-java +``` + +Graphify is language-agnostic and supports multiple languages, so +`agent-plan` is shared across all migration types. Execute and verify +images are language-specific — adding a new language (e.g., Go) means +new `agent-go-base`, `agent-execute-go`, and `agent-verify-go` images. +No harness or controller changes required. + +Execute and verify share the same language base since both need the +build toolchain. They are separate images (currently identical) to +allow future divergence (e.g., verify may add test frameworks or +coverage tools). + +### Filesystem watcher + +A background goroutine watches the working directory using fsnotify and +commits+pushes after a 30-second quiet period (no new file writes). +This provides the "constant pushing to git" guarantee without goose +needing git credentials. + +The watcher uses targeted staging: +- `git add -u` for tracked files +- Selective staging of new files matching known source patterns +- Respects `.gitignore` for binary/build artifacts +- Excludes `.goose/`, `__pycache__/`, `graphify-out/`, `node_modules/` + +A final commit+push after goose exits catches anything the watcher +missed, including `.konveyor/` state files. + +The 30-second quiet period is a reasonable default for all stages. The +`WithQuietPeriod()` Go API allows overriding it. If noisy intermediate +commits become a problem (e.g., during execute where goose may pause +30+ seconds between file migrations), a `KONVEYOR_WATCHER_QUIET_PERIOD` +env var can be added to tune it per stage — deferred until there is a +real complaint. + +### Cross-stage state via git + +Git is the shared state boundary. Each stage clones the repo and reads +artifacts left by prior stages: + +- Plan writes: `PLAN.md`, `graph.json`, `.konveyor/result.json` +- Execute reads: `PLAN.md`. Writes: migrated source files +- Verify reads: migrated source. Writes: fix patches + +#### result.json contract + +Each skill MUST append to `.konveyor/result.json` as its final action. +The file is a JSON array — each stage adds an entry: + +```json +[ + {"stage": "plan", "status": "succeeded"}, + {"stage": "execute", "status": "failed", "reason": "context exhausted at step 23"} +] +``` + +The harness reads the last entry to determine exit code (0 for +`"succeeded"`, 1 for anything else). Missing `result.json` is treated +as failure. This is a hard contract — a skill that completes +successfully but does not write `result.json` is considered broken. + +The controller currently determines stage success from pod exit code +only. A planned enhancement will have the harness write a one-line +`reason` field to the AgentRun CR status before exiting, giving +operators and the UI meaningful failure context without the controller +needing to parse `result.json` from the git branch. + +#### handoff.md (redesign pending) + +The harness writes `.konveyor/handoff.md` after each stage. Currently +formatted for human readers (status summary, skills loaded, plan steps, +file counts). A redesign is planned to make handoff.md +machine-readable so that skills can consume prior-stage context +programmatically. The result.json schema will be revisited alongside +this redesign. + +### Stage timeout + +The harness will support a `KONVEYOR_STAGE_TIMEOUT` env var (default: +60 minutes). When the timeout fires, `SendPrompt` returns via context +cancellation, the harness writes a failure entry to `result.json` +("stage timed out"), performs the final commit+push to preserve partial +work, and exits 1. This provides graceful timeout with artifact +preservation, versus the Sandbox's `activeDeadlineSeconds` which kills +the pod hard and loses uncommitted progress. + +### Context window scaling + +A single prompt per stage means the LLM's context window is the primary +scaling constraint. On large projects (hundreds of files, 50+ plan +steps), the combined prompt + skill content + tool call history can +exceed the context window mid-stage. + +The mitigation is skill-level chunking: the execute skill processes N +steps at a time, committing between chunks, so each chunk fits in +context. This keeps the complexity in the skill (where domain knowledge +lives) rather than in the harness. The harness remains a single-prompt +sender regardless of project size. + +## Deleted code + +| Deleted | Reason | +|---------|--------| +| `internal/detect/`, `internal/plan/`, `internal/execute/`, `internal/verify/`, `internal/fixloop/` | Logic moved to skills | +| `internal/handoff/`, `internal/metrics/`, `internal/rundir/` | Controller handles lifecycle | +| `internal/goose/recipe.go`, `internal/goose/acp_runner.go` | Multi-turn recipe execution replaced by single prompt | +| `harness/recipes/` | Content folded into skills | +| `harness/skill-bundle/` | Replaced by OCI-packaged SkillCards | +| `images/agent-base-goose/`, `images/agent-java-goose/` | Superseded by new image hierarchy | +| `docs/superpowers/` | Replaced by this ADR | + +## Alternatives Considered + +### Keep skills baked into images + +Each stage image copies its skill at build time +(`COPY skills/plan/ /opt/skills/plan/`). Simpler build, no SkillCard +dependency. + +Rejected because: couples skill content to image releases. Updating a +skill prompt requires rebuilding and redeploying the image. SkillCard +mounting allows skill iteration without image changes and enables +composing multiple skills per stage. + +### Keep multi-turn recipe execution + +The harness constructs multiple ACP prompts per stage using recipe YAML +files, sending them sequentially with context management. + +Rejected because: the recipe system duplicated orchestration that the +LLM handles naturally. A single well-composed prompt with skill +instructions produces equivalent or better results, and the LLM +manages its own context within the session. The multi-turn approach +also required complex Go code for recipe parsing, context windowing, +and turn management. + +### Harness as a library, not a binary + +Ship the harness as a Go library that custom agent images import and +call. Each image would have its own `main.go`. + +Rejected because: the harness behavior is identical across all stages. +A single binary with env-var configuration is simpler to maintain, +test, and debug. Custom behavior belongs in skills, not in the +harness binary. + +### Harness-level session splitting for large projects + +The harness sends multiple prompts per stage (e.g., "execute steps +1-10", then "execute steps 11-20"), re-reading PLAN.md each time. + +Rejected because: adds orchestration complexity back into the harness. +Skill-level chunking (the skill itself decides how to batch work) +keeps the harness thin and puts the complexity where domain knowledge +lives. + +### Controller reads result.json directly + +The controller clones the git branch or reads result.json from the +pod filesystem to get structured stage results. + +Rejected because: adds git or filesystem dependencies to the +controller. The simpler approach is the harness writing a one-line +reason to the AgentRun CR status before exiting. The controller +stays a standard stateless reconciler. + +## Consequences + +- **Skill quality is critical.** Migration intelligence now lives + entirely in markdown skill files, not in deterministic Go code. + The LLM interprets skill instructions, which means skill authoring + quality directly impacts migration quality. Poor skill instructions + produce poor migrations. + +- **Single prompt per stage.** The harness sends one prompt. For large + projects, skills must implement their own chunking strategy to stay + within the context window. The harness has no automatic recovery if + the LLM loses context mid-stage. + +- **result.json is a hard contract.** Skills must append to + `.konveyor/result.json` as their final action. Missing result.json + is treated as stage failure. This forces skill authors to explicitly + declare success or failure. + +- **SkillCard dependency.** Stage images are non-functional without + mounted SkillCards. A misconfigured Agent CR (missing skillCards + ref) produces a pod that starts, finds no skills, and exits + immediately with an error. + +- **Image builds require dependency ordering.** CI must build + `agent-base` before `agent-plan`, and `agent-java-base` before + `agent-execute-java`/`agent-verify-java`. The Makefile encodes + these dependencies. + +- **Multi-language via image naming.** Image names include the + language suffix (`agent-execute-java`, `agent-execute-go`). The + plan image is shared across languages since graphify is + language-agnostic. + +- **Test harness scaffolding.** `hack/harness-test/setup.sh` builds + skill OCI images locally, loads them into Kind, and applies + SkillCard + Agent + AgentPlaybook + AgentPlaybookRun CRs for + end-to-end testing. + +## Planned work + +| Item | Description | +|------|-------------| +| handoff.md + result.json redesign | Make machine-readable for agent consumption | +| Image rename | `agent-execute` → `agent-execute-java`, `agent-verify` → `agent-verify-java` | +| Stage timeout | Implement `KONVEYOR_STAGE_TIMEOUT` env var | +| AgentRun reason field | Harness writes one-line failure reason to CR status | diff --git a/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md b/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md deleted file mode 100644 index 211d5ca..0000000 --- a/docs/superpowers/plans/2026-07-15-harness-agentplaybookrun.md +++ /dev/null @@ -1,1701 +0,0 @@ -# Harness + AgentPlaybookRun Integration — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Restructure the migration harness from a monolithic 5-stage orchestrator into a thin, uniform git plumbing wrapper so that AgentPlaybookRun sequences stages and goose skills carry migration intelligence. - -**Architecture:** The harness binary becomes identical in every stage image: git clone → goose serve → single ACP prompt → filesystem watcher → final commit+push → exit. The AgentPlaybookRun controller creates one AgentRun per stage sequentially. Each stage image bakes in exactly one skill at `/opt/skills/<stage>/SKILL.md`. A new `watcher` package uses fsnotify to commit+push progress in the background while goose works. - -**Tech Stack:** Go 1.26, fsnotify, go-git/v5, cobra, gorilla/websocket, Containerfile (UBI 10), YAML CRDs - -**Spec:** `docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md` - -## Global Constraints - -- Go 1.26.2 (harness), Go 1.24.2 (controller — per go.mod) -- fsnotify must be added to `harness/go.mod` -- Git credentials must never be visible to goose — harness only -- Skills live at `/opt/skills/<stage>/SKILL.md` — exactly one per image -- `git add -A` is forbidden in the watcher; use `git add -u` + known patterns -- Java-only scope — no other language images in this plan -- Existing controller and ACP tests must continue to pass - ---- - -## File Structure - -### New files - -| File | Responsibility | -|---|---| -| `harness/internal/watcher/watcher.go` | Filesystem watcher: fsnotify → quiet-period detection → git add/commit/push | -| `harness/internal/watcher/watcher_test.go` | Unit tests for watcher | -| `harness/internal/watcher/patterns.go` | Known source file patterns for safe staging | -| `harness/internal/watcher/patterns_test.go` | Tests for pattern matching | -| `images/agent-base/Containerfile` | Base image: UBI + goose + git + harness binary | -| `images/agent-plan/Containerfile` | Plan image: agent-base + graphify | -| `images/agent-execute-java/Containerfile` | Execute image: agent-base + JDK 21 + Maven | -| `images/agent-verify-java/Containerfile` | Verify image: agent-base + JDK 21 + Maven | -| `skills/plan/SKILL.md` | Plan skill (detect+plan) | -| `skills/execute-java/SKILL.md` | Java execute skill | -| `skills/execute-java/references/javaee-quarkus.md` | Java EE → Quarkus reference (copied from existing) | -| `skills/verify-java/SKILL.md` | Java verify+fix skill | -| `hack/harness-test/playbook-resources.yaml` | Example AgentPlaybook + AgentPlaybookRun CRs | - -### Modified files - -| File | What changes | -|---|---| -| `api/v1alpha1/agentplaybookrun_types.go` | Add `TargetBranch` field to spec | -| `internal/controller/agentplaybookrun_controller.go` | Inject `GIT_TARGET_BRANCH` env var | -| `harness/cmd/migration-harness/main.go` | Gut to single `run` command with thin wrapper flow | -| `harness/internal/config/config.go` | Remove file-based config, keep `LoadFromEnv` | -| `harness/go.mod` | Add fsnotify dependency | - -### Deleted files/directories - -| Path | Reason | -|---|---| -| `harness/internal/detect/` | Logic moves to plan skill | -| `harness/internal/plan/` | Logic moves to plan skill | -| `harness/internal/execute/` | Logic moves to execute skill | -| `harness/internal/verify/` | Logic moves to verify skill | -| `harness/internal/fixloop/` | Logic moves to verify skill | -| `harness/internal/handoff/` | Controller tracks stage status | -| `harness/internal/metrics/` | Controller records timing | -| `harness/internal/rundir/` | No multi-run tracking needed | -| `harness/internal/goose/goose.go` | Runner interface no longer needed | -| `harness/internal/goose/acp_runner.go` | Multi-turn recipe runner no longer needed | -| `harness/internal/goose/acp_runner_test.go` | Tests for deleted code | -| `harness/internal/goose/recipe.go` | Recipe system no longer needed | -| `harness/internal/goose/recipe_test.go` | Tests for deleted code | -| `harness/recipes/` | Recipe content folded into skills | -| `harness/skill-bundle/` | Skills now baked into per-stage images | -| `images/agent-base-goose-java/Containerfile` | Replaced by 4 new Containerfiles | - ---- - -### Task 1: Make GIT_TARGET_BRANCH required (remove fallback) - -**Files:** -- Modify: `harness/internal/git/credentials.go` - -**Interfaces:** -- Consumes: `GIT_TARGET_BRANCH` env var (set by user in `AgentPlaybookRun.spec.env`) -- Produces: error if `GIT_TARGET_BRANCH` is not set (previously fell back to timestamp) - -**Decision:** `GIT_TARGET_BRANCH` is a plain env var passed through `spec.env` on the AgentPlaybookRun. The controller already forwards `pbRun.Spec.Env` to every child AgentRun. No CRD field needed. If the user forgets to set it, the harness fails immediately. - -- [x] **Step 1: Remove timestamp fallback in credentials.go** - -In `harness/internal/git/credentials.go`, replace the fallback branch generation with an error: - -```go -branch := os.Getenv("GIT_TARGET_BRANCH") -if branch == "" { - return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TARGET_BRANCH is missing") -} -``` - -Remove the unused `time` import. - -- [x] **Step 2: Verify build** - -Run: `cd harness && go build ./... && go vet ./...` - ---- - -### Task 2: Create filesystem watcher package - -**Files:** -- Create: `harness/internal/watcher/patterns.go` -- Create: `harness/internal/watcher/patterns_test.go` -- Create: `harness/internal/watcher/watcher.go` -- Create: `harness/internal/watcher/watcher_test.go` -- Modify: `harness/go.mod` (add fsnotify) - -**Interfaces:** -- Consumes: `git.CommitAll(repo, msg)`, `git.Push(ctx, creds, repo, branch)` from `harness/internal/git` -- Produces: `watcher.New(dir string, commitFn func() error) *Watcher`, `(*Watcher).Start()`, `(*Watcher).Stop()` - -- [ ] **Step 1: Add fsnotify dependency** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go get github.com/fsnotify/fsnotify` - -Expected: fsnotify added to go.mod and go.sum - -- [ ] **Step 2: Write pattern matching (patterns.go)** - -```go -package watcher - -import ( - "path/filepath" - "strings" -) - -var sourceExts = map[string]bool{ - ".java": true, ".xml": true, ".properties": true, - ".md": true, ".json": true, ".yaml": true, ".yml": true, - ".gradle": true, ".kt": true, ".groovy": true, -} - -var excludeDirs = map[string]bool{ - ".goose": true, "__pycache__": true, ".git": true, - "node_modules": true, "target": true, -} - -var excludeExts = map[string]bool{ - ".tmp": true, ".swp": true, ".bak": true, -} - -func ShouldStageNewFile(path string) bool { - base := filepath.Base(path) - - if base == "pom.xml" || base == "result.json" { - return true - } - - for _, part := range strings.Split(filepath.Dir(path), string(filepath.Separator)) { - if excludeDirs[part] { - return false - } - } - - ext := filepath.Ext(base) - if excludeExts[ext] { - return false - } - - return sourceExts[ext] -} -``` - -- [ ] **Step 3: Write pattern tests (patterns_test.go)** - -```go -package watcher - -import "testing" - -func TestShouldStageNewFile(t *testing.T) { - tests := []struct { - path string - want bool - }{ - {"src/main/java/com/example/App.java", true}, - {"pom.xml", true}, - {"src/main/resources/application.properties", true}, - {".konveyor/result.json", true}, - {"PLAN.md", true}, - {"graph.json", true}, - {".goose/cache/foo.txt", false}, - {"__pycache__/mod.pyc", false}, - {"target/classes/App.class", false}, - {"scratch.tmp", false}, - {"file.swp", false}, - {"random.txt", false}, - {"src/main/java/.goose/internal.java", false}, - } - for _, tt := range tests { - t.Run(tt.path, func(t *testing.T) { - if got := ShouldStageNewFile(tt.path); got != tt.want { - t.Errorf("ShouldStageNewFile(%q) = %v, want %v", tt.path, got, tt.want) - } - }) - } -} -``` - -- [ ] **Step 4: Run pattern tests** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./internal/watcher/ -run TestShouldStage -v` - -Expected: PASS - -- [ ] **Step 5: Write the watcher (watcher.go)** - -```go -package watcher - -import ( - "context" - "fmt" - "os" - "os/exec" - "path/filepath" - "strings" - "sync" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/konveyor/migration-harness/internal/logging" -) - -const quietPeriod = 30 * time.Second - -type CommitPushFn func() error - -type Watcher struct { - dir string - commitFn CommitPushFn - fsw *fsnotify.Watcher - cancel context.CancelFunc - wg sync.WaitGroup -} - -func New(dir string, commitFn CommitPushFn) (*Watcher, error) { - fsw, err := fsnotify.NewWatcher() - if err != nil { - return nil, err - } - return &Watcher{ - dir: dir, - commitFn: commitFn, - fsw: fsw, - }, nil -} - -func (w *Watcher) Start(ctx context.Context) error { - if err := w.addDirRecursive(w.dir); err != nil { - return err - } - - ctx, w.cancel = context.WithCancel(ctx) - w.wg.Add(1) - go w.loop(ctx) - logging.Info("filesystem watcher started (quiet period: %s)", quietPeriod) - return nil -} - -func (w *Watcher) Stop() { - if w.cancel != nil { - w.cancel() - } - w.wg.Wait() - w.fsw.Close() -} - -func (w *Watcher) loop(ctx context.Context) { - defer w.wg.Done() - timer := time.NewTimer(quietPeriod) - timer.Stop() - dirty := false - - for { - select { - case <-ctx.Done(): - timer.Stop() - return - case event, ok := <-w.fsw.Events: - if !ok { - return - } - if event.Op&(fsnotify.Create|fsnotify.Write|fsnotify.Remove|fsnotify.Rename) == 0 { - continue - } - rel, err := filepath.Rel(w.dir, event.Name) - if err != nil { - continue - } - if !isRelevantChange(rel) { - continue - } - if event.Op&fsnotify.Create != 0 { - if info, err := os.Stat(event.Name); err == nil && info.IsDir() { - w.fsw.Add(event.Name) - } - } - dirty = true - timer.Reset(quietPeriod) - case err, ok := <-w.fsw.Errors: - if !ok { - return - } - logging.Warn("watcher error: %v", err) - case <-timer.C: - if dirty { - w.doCommit() - dirty = false - } - } - } -} - -func isRelevantChange(relPath string) bool { - for _, part := range strings.Split(filepath.Dir(relPath), string(filepath.Separator)) { - if excludeDirs[part] { - return false - } - } - base := filepath.Base(relPath) - ext := filepath.Ext(base) - return !excludeExts[ext] -} - -func (w *Watcher) doCommit() { - if err := w.stageFiles(); err != nil { - logging.Warn("watcher stage: %v", err) - return - } - if err := w.commitFn(); err != nil { - logging.Warn("watcher commit+push: %v", err) - } -} - -func (w *Watcher) stageFiles() error { - cmd := exec.Command("git", "-C", w.dir, "add", "-u") - if out, err := cmd.CombinedOutput(); err != nil { - return fmt.Errorf("git add -u: %s: %w", out, err) - } - - entries, err := w.findNewFiles() - if err != nil { - return err - } - for _, f := range entries { - cmd := exec.Command("git", "-C", w.dir, "add", "--", f) - if out, err := cmd.CombinedOutput(); err != nil { - logging.Warn("git add %s: %s", f, out) - } - } - return nil -} - -func (w *Watcher) findNewFiles() ([]string, error) { - cmd := exec.Command("git", "-C", w.dir, "ls-files", "--others", "--exclude-standard") - out, err := cmd.Output() - if err != nil { - return nil, err - } - var staged []string - for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") { - line = strings.TrimSpace(line) - if line != "" && ShouldStageNewFile(line) { - staged = append(staged, line) - } - } - return staged, nil -} - -func (w *Watcher) addDirRecursive(dir string) error { - return filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { - if err != nil { - return nil - } - if d.IsDir() { - name := d.Name() - if excludeDirs[name] || name == ".konveyor" { - return filepath.SkipDir - } - return w.fsw.Add(path) - } - return nil - }) -} -``` - -- [ ] **Step 6: Write watcher unit tests (watcher_test.go)** - -```go -package watcher - -import ( - "context" - "os" - "os/exec" - "path/filepath" - "sync/atomic" - "testing" - "time" -) - -func TestWatcherDetectsFileChange(t *testing.T) { - dir := t.TempDir() - - // Initialize a git repo so git add -u works - runGit(t, dir, "init") - writeFile(t, filepath.Join(dir, "App.java"), "class App {}") - runGit(t, dir, "add", ".") - runGit(t, dir, "commit", "-m", "init") - - var commitCount atomic.Int32 - commitFn := func() error { - commitCount.Add(1) - return nil - } - - w, err := New(dir, commitFn) - if err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := w.Start(ctx); err != nil { - t.Fatal(err) - } - defer w.Stop() - - // Modify a tracked file - writeFile(t, filepath.Join(dir, "App.java"), "class App { int x; }") - - // Wait for quiet period + buffer - time.Sleep(quietPeriod + 5*time.Second) - - if commitCount.Load() == 0 { - t.Error("expected at least one commit after quiet period") - } -} - -func TestWatcherIgnoresExcludedDirs(t *testing.T) { - dir := t.TempDir() - runGit(t, dir, "init") - writeFile(t, filepath.Join(dir, "App.java"), "class App {}") - runGit(t, dir, "add", ".") - runGit(t, dir, "commit", "-m", "init") - - var commitCount atomic.Int32 - commitFn := func() error { - commitCount.Add(1) - return nil - } - - w, err := New(dir, commitFn) - if err != nil { - t.Fatal(err) - } - - ctx, cancel := context.WithCancel(context.Background()) - defer cancel() - - if err := w.Start(ctx); err != nil { - t.Fatal(err) - } - defer w.Stop() - - // Write to an excluded dir — should NOT trigger commit - gooseDir := filepath.Join(dir, ".goose") - os.MkdirAll(gooseDir, 0755) - writeFile(t, filepath.Join(gooseDir, "cache.db"), "data") - - time.Sleep(quietPeriod + 5*time.Second) - - if commitCount.Load() != 0 { - t.Error("expected no commits for changes in excluded dirs") - } -} - -func writeFile(t *testing.T, path, content string) { - t.Helper() - os.MkdirAll(filepath.Dir(path), 0755) - if err := os.WriteFile(path, []byte(content), 0644); err != nil { - t.Fatal(err) - } -} - -func runGit(t *testing.T, dir string, args ...string) { - t.Helper() - cmd := exec.Command("git", append([]string{"-C", dir}, args...)...) - cmd.Env = append(os.Environ(), - "GIT_AUTHOR_NAME=test", "GIT_AUTHOR_EMAIL=test@test.com", - "GIT_COMMITTER_NAME=test", "GIT_COMMITTER_EMAIL=test@test.com", - ) - if out, err := cmd.CombinedOutput(); err != nil { - t.Fatalf("git %v: %s: %v", args, out, err) - } -} -``` - -- [ ] **Step 7: Run watcher tests** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./internal/watcher/ -v -timeout 120s` - -Expected: PASS (tests take ~35s each due to quiet period) - -- [ ] **Step 8: Commit** - -```bash -git add harness/go.mod harness/go.sum harness/internal/watcher/ -git commit -m "feat: add filesystem watcher with quiet-period commit+push" -``` - ---- - -### Task 3: Gut main.go to thin wrapper - -**Files:** -- Modify: `harness/cmd/migration-harness/main.go` (full rewrite) -- Modify: `harness/internal/config/config.go` (remove file-based config, Save, DefaultConfigPath) - -**Interfaces:** -- Consumes: `config.LoadFromEnv()`, `git.ReadFromEnv()`, `git.Clone()`, `git.StripCredentials()`, `git.ClearEnvCredentials()`, `git.CheckoutBranch()`, `git.CommitAll()`, `git.Push()`, `goose.StartServe()`, `acp.WaitReadyDial()`, `acp.NewSessionClient()`, `(*SessionClient).CreateSession()`, `(*SessionClient).SendPrompt()`, `watcher.New()`, `(*Watcher).Start()`, `(*Watcher).Stop()` -- Produces: `main()` — single `run` command entrypoint for the harness binary - -- [ ] **Step 1: Simplify config.go — remove file-based config** - -Remove `DefaultHome()`, `DefaultConfigPath()`, `Load()`, `Save()`, `parseConfigLine()` from `harness/internal/config/config.go`. Keep only `LoadFromEnv()`, `Config` struct, and the `Default*` constants. - -```go -package config - -import ( - "os" - "strconv" -) - -const ( - DefaultMaxTurns = 200 - DefaultMaxFixIterations = 3 -) - -type Config struct { - Model string - Provider string - Endpoint string - APIKey string - MaxTurns int - MaxFixIterations int -} - -func LoadFromEnv() *Config { - model := os.Getenv("KONVEYOR_MODEL_PRIMARY_MODEL") - provider := os.Getenv("KONVEYOR_MODEL_PRIMARY_PROVIDER") - if model == "" || provider == "" { - return nil - } - - cfg := &Config{ - Model: model, - Provider: provider, - Endpoint: os.Getenv("KONVEYOR_MODEL_PRIMARY_ENDPOINT"), - APIKey: os.Getenv("KONVEYOR_MODEL_PRIMARY_API_KEY"), - MaxTurns: DefaultMaxTurns, - MaxFixIterations: DefaultMaxFixIterations, - } - - if n, err := strconv.Atoi(os.Getenv("KONVEYOR_PARAM_MAX_TURNS")); err == nil && n > 0 { - cfg.MaxTurns = n - } - if n, err := strconv.Atoi(os.Getenv("KONVEYOR_PARAM_MAX_FIX_ITERATIONS")); err == nil && n > 0 { - cfg.MaxFixIterations = n - } - - return cfg -} -``` - -- [ ] **Step 2: Rewrite main.go** - -Replace the entire `harness/cmd/migration-harness/main.go` with the thin wrapper flow. The new `main.go` has a single `run` command: - -```go -package main - -import ( - "context" - "encoding/json" - "fmt" - "os" - "os/signal" - "path/filepath" - "strings" - "time" - - "github.com/spf13/cobra" - - "github.com/konveyor/migration-harness/internal/acp" - "github.com/konveyor/migration-harness/internal/config" - "github.com/konveyor/migration-harness/internal/git" - "github.com/konveyor/migration-harness/internal/goose" - "github.com/konveyor/migration-harness/internal/logging" - "github.com/konveyor/migration-harness/internal/watcher" -) - -var rootCmd = &cobra.Command{ - Use: "migration-harness", - Short: "Thin git plumbing wrapper for goose-based migration stages", -} - -var runCmd = &cobra.Command{ - Use: "run", - Short: "Run a single migration stage (plan, execute, or verify)", - RunE: runStage, -} - -func init() { - rootCmd.AddCommand(runCmd) -} - -func main() { - if err := rootCmd.Execute(); err != nil { - os.Exit(1) - } -} - -func runStage(cmd *cobra.Command, args []string) error { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) - defer cancel() - - // 1. Load config from env - cfg := config.LoadFromEnv() - if cfg == nil { - return fmt.Errorf("KONVEYOR_MODEL_PRIMARY_MODEL and KONVEYOR_MODEL_PRIMARY_PROVIDER are required") - } - - // 2. Read git creds - creds, err := git.ReadFromEnv() - if err != nil { - return fmt.Errorf("git credentials: %w", err) - } - if creds == nil { - return fmt.Errorf("GIT_REPO_URL is required") - } - - // 3. Clone, strip creds, checkout branch - logging.Header("Git Setup") - logging.Info("cloning %s...", creds.RepoURL) - - workDir := os.Getenv("HARNESS_WORK_DIR") - if workDir == "" { - workDir = "/workspace" - } - cloneDir := filepath.Join(workDir, "repo") - - repo, err := git.Clone(ctx, creds, cloneDir) - if err != nil { - return fmt.Errorf("clone: %w", err) - } - - if err := git.StripCredentials(repo); err != nil { - return fmt.Errorf("strip credentials: %w", err) - } - git.ClearEnvCredentials() - - if err := git.CheckoutBranch(repo, creds.Branch); err != nil { - return fmt.Errorf("checkout branch %s: %w", creds.Branch, err) - } - logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) - - // 4. Start goose serve - logging.Header("Goose Setup") - srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) - if err != nil { - return fmt.Errorf("start goose serve: %w", err) - } - defer srv.Stop() - - // 5. Connect ACP, create session - wsClient, err := acp.WaitReadyDial(ctx, "127.0.0.1", srv.Port(), srv.SecretKey(), 30*time.Second) - if err != nil { - return fmt.Errorf("connect to goose: %w", err) - } - defer wsClient.Close() - - session := acp.NewSessionClient(wsClient) - sessionID, err := session.CreateSession(ctx, cloneDir, nil) - if err != nil { - return fmt.Errorf("create session: %w", err) - } - - // 6. Discover skill - skillContent, err := discoverSkill() - if err != nil { - return fmt.Errorf("discover skill: %w", err) - } - - // 7. Build prompt from 4 context layers - prompt := buildPrompt(skillContent) - - // 8. Start filesystem watcher BEFORE blocking prompt - commitPush := func() error { - if _, err := git.CommitAll(repo, "konveyor: auto-commit progress"); err != nil { - return err - } - return git.Push(ctx, creds, repo, creds.Branch) - } - w, err := watcher.New(cloneDir, commitPush) - if err != nil { - return fmt.Errorf("create watcher: %w", err) - } - if err := w.Start(ctx); err != nil { - return fmt.Errorf("start watcher: %w", err) - } - defer w.Stop() - - // 9. Send single ACP prompt (blocks until goose finishes) - // NOTE: KONVEYOR_PARAM_MAX_TURNS is parsed by config but not enforced - // here yet. The spec flags this as needing investigation: goose serve - // may accept a turn limit via CLI flag, env var, or ACP session param. - // If none exist natively, count tool_call notifications from the ACP - // stream and terminate. This is deferred to a follow-up task. - logging.Header("Running Stage") - _, err = session.SendPrompt(ctx, sessionID, []acp.ContentBlock{ - {Type: "text", Text: prompt}, - }) - - if err != nil { - logging.Err("prompt failed: %v", err) - } - - if !srv.Alive() { - logging.Err("goose serve crashed") - } - - // 10. Stop watcher - w.Stop() - - // 11. Read result.json for exit status - exitCode := readResultStatus(cloneDir) - - // 12. Final commit + push - logging.Header("Final Push") - if _, err := git.CommitAll(repo, "konveyor: stage complete"); err != nil { - logging.Warn("final commit: %v", err) - } - if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { - logging.Warn("final push: %v", err) - } - - // 13. Exit - if exitCode != 0 { - logging.Err("stage failed (result.json)") - os.Exit(1) - } - logging.Ok("stage succeeded") - return nil -} - -func discoverSkill() (string, error) { - matches, err := filepath.Glob("/opt/skills/*/SKILL.md") - if err != nil { - return "", err - } - if len(matches) == 0 { - return "", fmt.Errorf("no skill found at /opt/skills/*/SKILL.md") - } - if len(matches) > 1 { - return "", fmt.Errorf("expected exactly one skill, found %d: %v", len(matches), matches) - } - content, err := os.ReadFile(matches[0]) - if err != nil { - return "", fmt.Errorf("read skill %s: %w", matches[0], err) - } - logging.Info("discovered skill: %s", matches[0]) - return string(content), nil -} - -func buildPrompt(skillContent string) string { - var b strings.Builder - - if v := os.Getenv("KONVEYOR_PROMPT"); v != "" { - b.WriteString(v) - b.WriteString("\n\n") - } - - if v := os.Getenv("KONVEYOR_PLAYBOOK_INSTRUCTIONS"); v != "" { - b.WriteString("## Migration Context\n\n") - b.WriteString(v) - b.WriteString("\n\n") - } - - b.WriteString("## Skill Instructions\n\n") - b.WriteString(skillContent) - b.WriteString("\n\n") - - if v := os.Getenv("KONVEYOR_INSTRUCTIONS"); v != "" { - b.WriteString("## Stage Task\n\n") - b.WriteString(v) - } - - return b.String() -} - -type stageResult struct { - Stage string `json:"stage"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` -} - -func readResultStatus(workDir string) int { - path := filepath.Join(workDir, ".konveyor", "result.json") - data, err := os.ReadFile(path) - if err != nil { - logging.Warn("no result.json found — treating as failure") - return 1 - } - - var results []stageResult - if err := json.Unmarshal(data, &results); err != nil { - logging.Warn("invalid result.json: %v", err) - return 1 - } - - if len(results) == 0 { - logging.Warn("result.json is empty — treating as failure") - return 1 - } - - last := results[len(results)-1] - if last.Status == "succeeded" { - return 0 - } - - logging.Err("stage %s failed: %s", last.Stage, last.Reason) - return 1 -} -``` - -- [ ] **Step 3: Verify the harness builds** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go build ./cmd/migration-harness/` - -Expected: builds without errors - -- [ ] **Step 4: Commit** - -```bash -git add harness/cmd/migration-harness/main.go harness/internal/config/config.go -git commit -m "feat: rewrite harness as thin wrapper with single run command" -``` - ---- - -### Task 4: Delete obsolete harness packages and files - -**Files:** -- Delete: `harness/internal/detect/` (entire directory) -- Delete: `harness/internal/plan/` (entire directory) -- Delete: `harness/internal/execute/` (entire directory) -- Delete: `harness/internal/verify/` (entire directory) -- Delete: `harness/internal/fixloop/` (entire directory) -- Delete: `harness/internal/handoff/` (entire directory) -- Delete: `harness/internal/metrics/` (entire directory) -- Delete: `harness/internal/rundir/` (entire directory) -- Delete: `harness/internal/goose/goose.go` -- Delete: `harness/internal/goose/acp_runner.go` -- Delete: `harness/internal/goose/acp_runner_test.go` -- Delete: `harness/internal/goose/recipe.go` -- Delete: `harness/internal/goose/recipe_test.go` -- Delete: `harness/recipes/` (entire directory) -- Delete: `harness/skill-bundle/` (entire directory) -- Delete: `images/agent-base-goose-java/Containerfile` - -**Interfaces:** -- Consumes: nothing (pure deletion) -- Produces: clean harness codebase with only: `git/`, `goose/lifecycle.go`, `config/`, `logging/`, `acp/`, `watcher/` - -- [ ] **Step 1: Verify no imports of deleted packages remain in main.go** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && grep -rn 'internal/detect\|internal/plan\|internal/execute\|internal/verify\|internal/fixloop\|internal/handoff\|internal/metrics\|internal/rundir\|goose\.Runner\|goose\.NewACPRunner\|goose\.ParseRecipe' cmd/ internal/` - -Expected: no matches (main.go was rewritten in Task 3) - -- [ ] **Step 2: Delete obsolete packages** - -```bash -rm -rf harness/internal/detect \ - harness/internal/plan \ - harness/internal/execute \ - harness/internal/verify \ - harness/internal/fixloop \ - harness/internal/handoff \ - harness/internal/metrics \ - harness/internal/rundir -``` - -- [ ] **Step 3: Delete obsolete goose files** - -```bash -rm harness/internal/goose/goose.go \ - harness/internal/goose/acp_runner.go \ - harness/internal/goose/acp_runner_test.go \ - harness/internal/goose/recipe.go \ - harness/internal/goose/recipe_test.go -``` - -- [ ] **Step 4: Delete recipes and skill-bundle** - -```bash -rm -rf harness/recipes \ - harness/skill-bundle -``` - -- [ ] **Step 5: Delete the monolithic Containerfile** - -```bash -rm images/agent-base-goose-java/Containerfile -rmdir images/agent-base-goose-java 2>/dev/null || true -``` - -- [ ] **Step 6: Run go mod tidy to clean unused dependencies** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go mod tidy` - -Expected: unused dependencies (e.g., yaml.v3 from recipe.go) are removed from go.mod - -- [ ] **Step 7: Verify harness builds and tests pass** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go build ./cmd/migration-harness/ && go test ./...` - -Expected: builds cleanly, all remaining tests pass - -- [ ] **Step 8: Commit** - -```bash -git add -u harness/ images/ -git commit -m "chore: delete obsolete harness packages, recipes, skill-bundle, and monolithic image" -``` - ---- - -### Task 5: Create stage images (Containerfiles) - -**Files:** -- Create: `images/agent-base/Containerfile` -- Create: `images/agent-plan/Containerfile` -- Create: `images/agent-execute-java/Containerfile` -- Create: `images/agent-verify-java/Containerfile` - -**Interfaces:** -- Consumes: harness binary from `harness/cmd/migration-harness/`, skills from `skills/` directory (Task 6) -- Produces: 4 container images buildable with `podman build` - -- [ ] **Step 1: Create agent-base Containerfile** - -Create `images/agent-base/Containerfile`: - -```dockerfile -# agent-base: Foundation image with goose CLI, git, and the migration harness binary. -# All stage images extend this base. -# -# Build context must be the repository root: -# podman build -t agent-base -f images/agent-base/Containerfile . - -# --- Build stage --- -FROM golang:1.26 AS builder - -WORKDIR /src -COPY harness/go.mod harness/go.sum ./ -RUN go mod download -COPY harness/cmd/ cmd/ -COPY harness/internal/ internal/ -RUN CGO_ENABLED=0 go build -o /migration-harness ./cmd/migration-harness/ - -# --- Runtime stage --- -FROM registry.access.redhat.com/ubi10/ubi:latest - -RUN dnf install -y \ - curl \ - git \ - ca-certificates \ - && dnf clean all - -# Install Goose -RUN ARCH=$(uname -m) \ - && if [ "$ARCH" = "x86_64" ]; then GOOSE_ARCH="x86_64-unknown-linux-gnu"; \ - elif [ "$ARCH" = "aarch64" ]; then GOOSE_ARCH="aarch64-unknown-linux-gnu"; \ - else echo "Unsupported architecture: $ARCH" && exit 1; fi \ - && curl -fsSL "https://github.com/block/goose/releases/download/stable/goose-${GOOSE_ARCH}.tar.bz2" -o /tmp/goose.tar.bz2 \ - && tar -xjf /tmp/goose.tar.bz2 -C /tmp \ - && mv /tmp/goose /usr/local/bin/goose \ - && chmod +x /usr/local/bin/goose \ - && rm -f /tmp/goose.tar.bz2 - -ENV PATH="/opt/migration-harness/bin:${PATH}" -ENV HARNESS_WORK_DIR=/workspace - -COPY --from=builder /migration-harness /opt/migration-harness/bin/migration-harness - -RUN mkdir -p /opt/skills /workspace /home/harness/.migration-harness \ - && useradd -r -d /home/harness -s /sbin/nologin harness \ - && chown -R harness:harness /home/harness /workspace /tmp - -WORKDIR /workspace -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run"] -``` - -- [ ] **Step 2: Create agent-plan Containerfile** - -Create `images/agent-plan/Containerfile`: - -```dockerfile -# agent-plan: Plan stage image. Adds graphify for code graph generation. -# -# Build context must be the repository root: -# podman build -t agent-plan -f images/agent-plan/Containerfile . - -FROM agent-base AS base - -USER root - -RUN dnf install -y \ - python3 \ - python3-pip \ - python3-devel \ - gcc \ - gcc-c++ \ - && dnf clean all - -RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 - -ENV PYTHONUNBUFFERED=1 - -COPY skills/plan/ /opt/skills/plan/ - -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run"] -``` - -- [ ] **Step 3: Create agent-execute-java Containerfile** - -Create `images/agent-execute-java/Containerfile`: - -```dockerfile -# agent-execute-java: Execute stage image for Java migrations. -# Adds JDK 21 and Maven for building/compiling Java projects. -# -# Build context must be the repository root: -# podman build -t agent-execute-java -f images/agent-execute-java/Containerfile . - -FROM agent-base AS base - -USER root - -RUN dnf install -y \ - java-21-openjdk-devel \ - maven \ - && dnf clean all - -RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ - && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current -ENV JAVA_HOME=/usr/lib/jvm/java-current -ENV PATH="${JAVA_HOME}/bin:${PATH}" - -COPY skills/execute-java/ /opt/skills/execute/ - -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run"] -``` - -- [ ] **Step 4: Create agent-verify-java Containerfile** - -Create `images/agent-verify-java/Containerfile`: - -```dockerfile -# agent-verify-java: Verify stage image for Java migrations. -# Same toolchain as execute (JDK + Maven) for running builds and tests. -# -# Build context must be the repository root: -# podman build -t agent-verify-java -f images/agent-verify-java/Containerfile . - -FROM agent-base AS base - -USER root - -RUN dnf install -y \ - java-21-openjdk-devel \ - maven \ - && dnf clean all - -RUN JAVA_HOME_PATH=$(dirname $(dirname $(readlink -f $(which java)))) \ - && ln -sf "$JAVA_HOME_PATH" /usr/lib/jvm/java-current -ENV JAVA_HOME=/usr/lib/jvm/java-current -ENV PATH="${JAVA_HOME}/bin:${PATH}" - -COPY skills/verify-java/ /opt/skills/verify/ - -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run"] -``` - -- [ ] **Step 5: Verify Containerfile syntax** - -Run: `for f in images/agent-base/Containerfile images/agent-plan/Containerfile images/agent-execute-java/Containerfile images/agent-verify-java/Containerfile; do echo "--- $f ---"; podman build --no-cache --layers=false -f "$f" --target=builder . 2>&1 | tail -5 || echo "syntax ok"; done` - -Expected: builder stages parse without Dockerfile syntax errors (full builds require skills from Task 6) - -- [ ] **Step 6: Commit** - -```bash -git add images/agent-base/ images/agent-plan/ images/agent-execute-java/ images/agent-verify-java/ -git commit -m "feat: add stage-specific Containerfiles (base, plan, execute-java, verify-java)" -``` - ---- - -### Task 6: Write skills (plan, execute-java, verify-java) - -**Files:** -- Create: `skills/plan/SKILL.md` -- Create: `skills/execute-java/SKILL.md` -- Create: `skills/execute-java/references/javaee-quarkus.md` (copy from `harness/skill-bundle/goose-migration/references/javaee-quarkus.md`) -- Create: `skills/verify-java/SKILL.md` - -**Interfaces:** -- Consumes: knowledge from deleted packages (`detect/`, `plan/`, `execute/`, `verify/`, `fixloop/`) and deleted recipes (`recipes/*.yaml`) -- Produces: 3 skill files baked into stage images, each instructing goose how to perform one stage - -- [ ] **Step 1: Write plan skill** - -Create `skills/plan/SKILL.md`: - -```markdown ---- -name: migration-plan -description: > - Runs graphify to generate the code graph, analyzes the project, and produces - PLAN.md with structured migration items. Does NOT modify source files. ---- - -# Plan Stage Skill - -You are a migration planner. Your job is to analyze a project and produce -a detailed `PLAN.md` at the repo root. Do NOT modify any source files. - -## Steps - -### 1. Generate the code graph - -Run graphify on the project: - -```bash -graphify update -``` - -This produces `graph.json` in the repo root. - -### 2. Read project structure - -- Read `graph.json` to understand the project architecture: - - Communities (architectural layers) - - God nodes (high-degree, high-risk files) - - File relationships (edges) -- Read the build manifest (`pom.xml`, `package.json`, etc.) — always one file -- Read 5-8 complex source files that need structural changes (MDB classes, - security config, lifecycle listeners) - -### 3. Read the migration context - -Your overall migration goal is provided in the prompt above under -"Migration Context" and "Stage Task". Use these to understand: -- What needs to change (e.g., Java EE → Quarkus) -- Target state -- Constraints - -### 4. Write PLAN.md - -Write `PLAN.md` to the repo root with this structure: - -```markdown -# PLAN.md - -## Goal -<one sentence goal> - -## Project Summary -- Type: <Maven/Node/Python/.NET/etc> -- Files affected: <N> -- Estimated complexity: <Low/Medium/High> -- Hardest steps: <list 1-3 most complex items> - -## Steps - -### Step 1: <title> -- File: <exact path> -- Action: CREATE | MODIFY | DELETE -- What to do: <specific instructions> -- Why: <reason> -- Depends on: <step numbers or "none"> -- Verify: <how to confirm> - -### Step 2: ... -``` - -#### Rules for steps - -1. **One file per step** — never combine two files -2. **Exact paths** — real paths from graph.json, not placeholders -3. **Layer order** — build config → app config → utils → persistence → models → services → REST → cleanup -4. **Flag hard steps** — prefix with `⚠️ COMPLEX:` for MDB, JNDI, architectural changes -5. **DELETE steps last** — after all modifications -6. **Dependency order** — steps that others depend on come first - -### 5. Write result - -After writing PLAN.md, append your result to `.konveyor/result.json`: - -```bash -mkdir -p .konveyor -``` - -If the file exists, read it, parse the JSON array, append your entry, -and write it back. If it doesn't exist, create it with a single-entry array. - -Your entry: - -```json -{"stage": "plan", "status": "succeeded"} -``` - -Or on failure: - -```json -{"stage": "plan", "status": "failed", "reason": "<what went wrong>"} -``` - -## Important - -- Do NOT modify source files — planning only -- Do NOT execute any migration steps -- Do NOT skip graphify — the graph is essential for later stages -- Read selectively — the graph gives you most of what you need -``` - -- [ ] **Step 2: Copy the Java EE → Quarkus reference** - -```bash -mkdir -p skills/execute-java/references -cp harness/skill-bundle/goose-migration/references/javaee-quarkus.md skills/execute-java/references/ -``` - -- [ ] **Step 3: Write execute-java skill** - -Create `skills/execute-java/SKILL.md`: - -```markdown ---- -name: migration-execute-java -description: > - Reads PLAN.md and executes each migration step sequentially. Applies - Java EE → Quarkus transformations file by file. Writes modified source - files to disk. ---- - -# Execute Stage Skill (Java) - -You are a migration executor. Read `PLAN.md` (produced by the plan stage) -and execute every step in order. The reference file at -`/opt/skills/execute/references/javaee-quarkus.md` contains the pattern -catalog for Java EE → Quarkus transformations. - -## Startup - -1. Read `PLAN.md` from the repo root — read it ONCE, work through the list -2. Read `/opt/skills/execute/references/javaee-quarkus.md` for transformation patterns -3. Begin executing steps in order - -## Execution Loop - -For each step in PLAN.md: - -1. Read the target file -2. Apply the transformation described in the step -3. Write the modified file -4. Move to the next step immediately — do NOT wait for confirmation - -### Guardrails - -- You MUST attempt every item in PLAN.md in order. Do not skip items. -- After completing each item, note it mentally before moving to the next. -- Do not re-read PLAN.md after every item — read it once, work through the list. -- If you cannot complete an item, note the reason and move to the next. - Do not get stuck on one item. - -## Common Java EE → Quarkus Transformations - -### Import replacements -- `javax.ejb.*` → `jakarta.enterprise.context.*` + `jakarta.inject.*` -- `javax.persistence.*` → `jakarta.persistence.*` -- `javax.ws.rs.*` → `jakarta.ws.rs.*` -- `javax.inject.*` → `jakarta.inject.*` - -### Annotation replacements -- `@Stateless` → `@ApplicationScoped` -- `@Stateful` → `@ApplicationScoped` -- `@EJB` → `@Inject` -- Remove `@Local`, `@Remote` - -### MDB conversions (⚠️ COMPLEX) -See the reference file for before/after patterns. These require -structural changes, not just import swaps. - -### pom.xml -- Change `<packaging>war</packaging>` → `<packaging>jar</packaging>` -- Remove `javaee-api` dependency -- Add Quarkus BOM and extensions - -### Config files -- Create `application.properties` from `persistence.xml` and `web.xml` settings -- Delete legacy XML config files (`persistence.xml`, `web.xml`, `beans.xml`) - -## Completion - -After executing all steps, append your result to `.konveyor/result.json`: - -Read the existing file (it should have the plan stage entry), parse the -JSON array, append your entry, and write it back. - -Your entry: - -```json -{"stage": "execute", "status": "succeeded"} -``` - -Or on failure: - -```json -{"stage": "execute", "status": "failed", "reason": "<what went wrong>"} -``` - -## Important - -- Work through ALL items — completeness matters more than perfection -- Follow the reference file for complex patterns (MDB, JNDI) -- Do NOT run builds or tests — that is the verify stage's job -- Do NOT modify PLAN.md -``` - -- [ ] **Step 4: Write verify-java skill** - -Create `skills/verify-java/SKILL.md`: - -```markdown ---- -name: migration-verify-java -description: > - Runs mvn clean compile, parses errors, applies conservative fixes, - and iterates until the build passes or max iterations are reached. ---- - -# Verify Stage Skill (Java) - -You are a migration verifier. Your job is to run the build, identify -errors, and fix them iteratively until the build passes. - -## Steps - -### 1. Run the build - -```bash -mvn clean compile 2>&1 | tail -50 -``` - -If the build succeeds (exit code 0), skip to step 4. - -### 2. Fix errors - -For each compiler error: - -1. Read the error message to identify the file and issue -2. Read the source file -3. Apply a minimal, conservative fix -4. Do NOT change code that isn't related to the error - -Common errors and fixes: - -| Error | Fix | -|---|---| -| `package javax.* does not exist` | Replace remaining `javax.*` import with `jakarta.*` | -| `cannot find symbol` for CDI annotations | Add missing Quarkus extension to pom.xml | -| `cannot find symbol` for removed class | Check if a deleted interface/class is still referenced; update the reference | -| Missing `application.properties` keys | Add the required config property | - -### 3. Re-verify - -After fixing errors, run the build again: - -```bash -mvn clean compile 2>&1 | tail -50 -``` - -Repeat steps 2-3 up to the number of iterations specified by -`KONVEYOR_PARAM_MAX_FIX_ITERATIONS` (read from environment, default 3). - -If the build still fails after max iterations, report failure. - -### 4. Run tests (if build passes) - -```bash -mvn test 2>&1 | tail -80 -``` - -Report test results but do NOT attempt to fix failing tests. - -### 5. Write result - -Append your result to `.konveyor/result.json`: - -Read the existing file (it should have plan and execute entries), -parse the JSON array, append your entry, and write it back. - -Your entry on success: - -```json -{"stage": "verify", "status": "succeeded"} -``` - -On failure: - -```json -{"stage": "verify", "status": "failed", "reason": "mvn compile failed after N fix iterations"} -``` - -## Important - -- Fixes must be minimal and conservative — don't rewrite working code -- Only fix compiler errors, not warnings -- Do NOT modify PLAN.md or files unrelated to the error -- Read KONVEYOR_PARAM_MAX_FIX_ITERATIONS from environment for iteration cap -``` - -- [ ] **Step 5: Verify skill files exist and are valid** - -Run: `for f in skills/plan/SKILL.md skills/execute-java/SKILL.md skills/execute-java/references/javaee-quarkus.md skills/verify-java/SKILL.md; do echo "--- $f ---"; head -5 "$f"; done` - -Expected: all 4 files exist with correct frontmatter - -- [ ] **Step 6: Commit** - -```bash -git add skills/ -git commit -m "feat: add plan, execute-java, and verify-java skills" -``` - ---- - -### Task 7: Update hack/ with AgentPlaybook + AgentPlaybookRun example resources - -**Files:** -- Create: `hack/harness-test/playbook-resources.yaml` -- Modify: `hack/harness-test/resources.yaml` (update Agent CRs for new images) - -**Interfaces:** -- Consumes: CRD types from `api/v1alpha1/` (Agent, AgentPlaybook, AgentPlaybookRun) -- Produces: example YAML resources for testing the full playbook flow - -- [ ] **Step 1: Create playbook example resources** - -Create `hack/harness-test/playbook-resources.yaml`: - -```yaml -# Example resources for testing the AgentPlaybook + AgentPlaybookRun flow. -# Migrates the coolstore Java EE app to Quarkus using 3 stages. -# -# Prerequisites: -# - LLMProvider "gcp-vertex-ai" from resources.yaml -# - git-credentials Secret from setup.sh -# -# Usage: -# kubectl apply -f hack/harness-test/resources.yaml -# kubectl apply -f hack/harness-test/playbook-resources.yaml - ---- -apiVersion: konveyor.io/v1alpha1 -kind: Agent -metadata: - name: migration-plan-agent -spec: - image: quay.io/konveyor/agent-plan:dev - providers: - - ref: gcp-vertex-ai - params: - - name: max_turns - type: number - default: "200" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: Agent -metadata: - name: migration-execute-java -spec: - image: quay.io/konveyor/agent-execute-java:dev - providers: - - ref: gcp-vertex-ai - params: - - name: max_turns - type: number - default: "200" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: Agent -metadata: - name: migration-verify-java -spec: - image: quay.io/konveyor/agent-verify-java:dev - providers: - - ref: gcp-vertex-ai - params: - - name: max_turns - type: number - default: "200" - - name: max_fix_iterations - type: number - default: "3" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: AgentPlaybook -metadata: - name: java-ee-to-quarkus -spec: - guide: "Migrate a Java EE application to Quarkus 3" - stages: - - name: plan - agentRef: migration-plan-agent - instructions: "Analyze the project structure using graphify and produce PLAN.md with migration steps" - - name: execute - agentRef: migration-execute-java - instructions: "Execute each step in PLAN.md to migrate the code from Java EE to Quarkus" - - name: verify - agentRef: migration-verify-java - instructions: "Run mvn clean compile, fix any compilation errors, then run tests" - ---- -apiVersion: konveyor.io/v1alpha1 -kind: AgentPlaybookRun -metadata: - name: coolstore-migration -spec: - playbookRef: java-ee-to-quarkus - models: - - role: primary - provider: gcp-vertex-ai - model: claude-sonnet-4-5 - env: - - name: GIT_REPO_URL - value: "https://github.com/savitharaghunathan/coolstore.git" - - name: GIT_TOKEN - valueFrom: - secretKeyRef: - name: git-credentials - key: token - - name: GIT_TARGET_BRANCH - value: "konveyor/coolstore-migration" - - name: GCP_PROJECT_ID - value: "__GCP_PROJECT_ID__" - - name: GCP_LOCATION - value: "global" -``` - -- [ ] **Step 2: Update resources.yaml — keep LLMProvider, keep legacy Agent for backwards compat** - -The existing `resources.yaml` has LLMProvider and the monolithic Agent CR. Keep -the LLMProvider (it's used by both old and new flows). Add a comment noting the -new agents are in `playbook-resources.yaml`. Remove the old AgentRun CR since -users should now use AgentPlaybookRun. - -In `hack/harness-test/resources.yaml`, remove the `AgentRun` CR (lines 39-60 starting at `---` before the AgentRun). Keep the LLMProvider and Agent. Add a comment at the top: - -```yaml -# Harness integration test resources for Kind. -# Creates: Secret → LLMProvider → Agent (legacy monolithic agent) -# -# For the new playbook flow, also apply playbook-resources.yaml -# which creates per-stage Agents + AgentPlaybook + AgentPlaybookRun. -# -# Usage: -# hack/harness-test/setup.sh -``` - -- [ ] **Step 3: Verify YAML syntax** - -Run: `python3 -c "import yaml; [yaml.safe_load_all(open(f)) for f in ['hack/harness-test/playbook-resources.yaml', 'hack/harness-test/resources.yaml']]" && echo "YAML OK"` - -Expected: "YAML OK" - -- [ ] **Step 4: Commit** - -```bash -git add hack/harness-test/playbook-resources.yaml hack/harness-test/resources.yaml -git commit -m "feat: add AgentPlaybook + AgentPlaybookRun example resources for coolstore" -``` - ---- - -### Task 8: End-to-end build verification - -**Files:** -- No new files — verification only - -**Interfaces:** -- Consumes: all files from Tasks 1-7 -- Produces: confirmation that everything compiles, tests pass, and images build - -- [ ] **Step 1: Run full controller tests** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && go test ./... -count=1` - -Expected: all tests pass - -- [ ] **Step 2: Run full harness tests** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go test ./... -count=1` - -Expected: all tests pass - -- [ ] **Step 3: Run go vet on both modules** - -Run: -```bash -cd /Users/sraghuna/local_dev/konveyor/agentic-controller && go vet ./... -cd /Users/sraghuna/local_dev/konveyor/agentic-controller/harness && go vet ./... -``` - -Expected: no issues - -- [ ] **Step 4: Build agent-base image** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-base -f images/agent-base/Containerfile .` - -Expected: image builds successfully - -- [ ] **Step 5: Build agent-plan image** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-plan -f images/agent-plan/Containerfile .` - -Expected: image builds successfully (depends on agent-base) - -- [ ] **Step 6: Build agent-execute-java image** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-execute-java -f images/agent-execute-java/Containerfile .` - -Expected: image builds successfully (depends on agent-base) - -- [ ] **Step 7: Build agent-verify-java image** - -Run: `cd /Users/sraghuna/local_dev/konveyor/agentic-controller && podman build -t agent-verify-java -f images/agent-verify-java/Containerfile .` - -Expected: image builds successfully (depends on agent-base) - -- [ ] **Step 8: Verify skill discovery in the built images** - -Run: -```bash -podman run --rm agent-plan ls /opt/skills/plan/SKILL.md -podman run --rm agent-execute-java ls /opt/skills/execute/SKILL.md -podman run --rm agent-verify-java ls /opt/skills/verify/SKILL.md -``` - -Expected: each file exists - -- [ ] **Step 9: Commit any fixes from verification** - -If any issues were found and fixed, commit them: - -```bash -git add -u -git commit -m "fix: address issues found during end-to-end verification" -``` diff --git a/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md b/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md deleted file mode 100644 index b52949e..0000000 --- a/docs/superpowers/specs/2026-07-15-harness-agentplaybookrun-design.md +++ /dev/null @@ -1,343 +0,0 @@ -# Harness + AgentPlaybookRun Integration Design - -## Overview - -Restructure the migration harness from a monolithic 5-stage orchestrator into a thin, uniform wrapper that runs inside AgentPlaybookRun-managed pods. Stage sequencing moves up to the controller (AgentPlaybookRun), migration intelligence moves down to goose skills, and the harness becomes pure git plumbing + goose lifecycle management. - -## Principles (carried forward from AGENTS.md) - -- **Git credentials stay in the harness** — goose never sees push credentials. -- **Constant pushing to git** — progress is committed and pushed regularly, not just at stage boundaries. -- **Proper handoff** — each stage reads prior-stage artifacts from the git repo (PLAN.md, migrated files, etc.). -- **Controller is domain-agnostic** — it sequences stages, it doesn't interpret migration logic. - -## Sequence - -``` -User creates AgentPlaybookRun - └─ AgentPlaybookRun controller - ├─ Stage 1 (plan): - │ ├─ Creates AgentRun (agentRef: migration-plan-agent) - │ ├─ AgentRun controller creates Sandbox pod - │ ├─ Pod: harness → git clone → goose serve → ACP prompt → watcher → commit+push → exit 0 - │ └─ Controller sees Succeeded → advances to stage 2 - ├─ Stage 2 (execute): - │ ├─ Creates AgentRun (agentRef: migration-execute-java) - │ ├─ Pod: harness → git clone (picks up PLAN.md) → goose → watcher → commit+push → exit - │ └─ Controller sees Succeeded → advances to stage 3 - └─ Stage 3 (verify): - ├─ Creates AgentRun (agentRef: migration-verify-java) - ├─ Pod: harness → git clone (picks up migrated code) → goose → watcher → commit+push → exit - └─ Controller sees Succeeded → marks run Succeeded -``` - -## Architecture - -### Stage Sequencing - -AgentPlaybookRun controller creates one AgentRun per stage, sequentially. Each AgentRun creates a Sandbox (pod) with the stage's agent image. The harness binary is the entrypoint in every image. - -Three playbook stages: - -1. **Plan** — runs graphify (detect), feeds output into planner skill, produces PLAN.md -2. **Execute** — goose iterates PLAN.md items, migrates each file/component -3. **Verify** — goose runs build/test, fixes errors iteratively - -### Image Hierarchy (Java only) - -All stage images extend a common base: - -| Image | Base | Adds | Baked-in Skill | -|---|---|---|---| -| `agent-base` | ubi | goose CLI, git, harness binary, cred wiring | (none) | -| `agent-plan` | agent-base | graphify | `/opt/skills/plan/SKILL.md` | -| `agent-execute-java` | agent-base | JDK 21, Maven | `/opt/skills/execute/SKILL.md` | -| `agent-verify-java` | agent-base | JDK 21, Maven | `/opt/skills/verify/SKILL.md` | - -Convention: each image places exactly one skill at `/opt/skills/<stage>/SKILL.md`. The harness discovers it via glob (`/opt/skills/*/SKILL.md`). - -Adding a new language = new execute + verify images with language-specific toolchain and skills. No harness or controller changes. - -### Harness Binary (Thin Wrapper) - -Same binary, same behavior in every image. Does not know what stage it's running. - -**Entrypoint flow (`migration-harness run`):** - -1. Load config from env vars (`KONVEYOR_MODEL_PRIMARY_*`) -2. Read git creds from env (`GIT_REPO_URL`, `GIT_TOKEN`, `GIT_TARGET_BRANCH`) -3. Git clone, strip credentials from remote, checkout branch -4. Start goose serve with `--with-builtin developer` (gives goose shell access, file editing) and providerEnv wiring for LLM credentials -5. Connect via ACP WebSocket, create session (passing cloned repo path as `cwd` so goose operates in the right directory) -6. Discover skill: glob `/opt/skills/*/SKILL.md` (exactly one match) -7. Read env vars: `KONVEYOR_INSTRUCTIONS` (stage task), `KONVEYOR_PLAYBOOK_INSTRUCTIONS` (overall guide), `KONVEYOR_PROMPT` (agent-level standing instructions) -8. Start filesystem watcher (background goroutine) — must start BEFORE the blocking prompt call -9. Send single ACP prompt combining all four context layers: - - `KONVEYOR_PROMPT` — agent-level standing instructions (from Agent CR) - - `KONVEYOR_PLAYBOOK_INSTRUCTIONS` — overall migration context (from playbook Guide) - - Skill content (harness reads `/opt/skills/*/SKILL.md` file and embeds it inline in the prompt) - - `KONVEYOR_INSTRUCTIONS` — specific stage task (from playbook stage) -10. `SendPrompt()` blocks until goose finishes (see Session Completion below) -11. Stop watcher -12. Read `.konveyor/result.json` for exit status (see Failure Propagation below) -13. Final commit + push -14. Exit 0 (success) or exit 1 (failure based on result.json) - -Controller determines Succeeded/Failed from pod exit code. No session.json or handoff files needed. - -### Session Completion - -The harness needs to know when goose has finished its autonomous work. -The ACP WebSocket session delivers messages as goose works. When the -session prompt completes, the ACP protocol returns a final response to -the `session/prompt` JSON-RPC call. The harness's blocking -`SessionClient.Prompt()` call returns at that point. This is the same -mechanism used today — the difference is there's only one prompt call -instead of many. - -If goose serve crashes (process exits), the harness detects this via -`ServeProcess.Alive()` and exits with code 1. - -### Failure Propagation - -Each skill appends its result to `.konveyor/result.json` as its final -action. The file is a JSON array — each stage adds an entry: - -```json -[ - {"stage": "plan", "status": "succeeded"}, - {"stage": "execute", "status": "succeeded"}, - {"stage": "verify", "status": "failed", "reason": "mvn compile failed after 3 fix iterations"} -] -``` - -The harness reads the last entry after the ACP session completes. If the -file is missing or the last entry contains `"failed"`, the harness exits -with code 1. This gives the controller a clear Succeeded/Failed signal -without the harness needing to interpret skill-specific output. - -The file accumulates across stages (each stage clones the repo and picks -up prior entries), providing a complete run history in a single file. - -Skills must append to this file even on failure — it's part of the skill -contract. - -### Filesystem Watcher (New Component) - -Runs as a background goroutine while goose works. Provides the "constant -pushing to git" guarantee without goose needing git credentials. - -**Behavior:** -- Watches working directory for file changes using `fsnotify` -- After detecting changes, waits for a quiet period (~30 seconds of no new writes). The longer quiet period avoids committing mid-write — goose may pause between file writes while waiting for LLM responses, and a short quiet period could fire during that gap. -- On quiet: `git add` (tracked files + known source patterns) → `git commit` → `git push` -- Commit message: `"konveyor: auto-commit progress"` -- If push fails, logs warning and retries on next quiet period -- Final commit+push after goose exits catches anything the watcher missed - -**Safe staging (not `git add -A`):** -The watcher must not blindly stage everything. Goose and its extensions -may create temp files, cache directories, or internal state in the -working directory. The watcher uses a targeted approach: -- Stage changes to files already tracked by git (`git add -u`) -- Stage new files only if they match known source patterns (`.java`, `.xml`, `.properties`, `.md`, `pom.xml`, etc.) -- Respect `.gitignore` — the repo's `.gitignore` (and a harness-managed `.gitignore` entry for `.konveyor/tmp/`) excludes goose internals and harness temp files -- Never stage files matching: `*.tmp`, `*.swp`, `.goose/`, `__pycache__/` -- `.konveyor/result.json` IS staged (it's a tracked contract file). Only `.konveyor/tmp/` is excluded. - -### Inter-Stage State Passing - -Git repo is the shared state boundary. Each stage clones the repo and reads artifacts left by prior stages: - -- Plan stage writes: `PLAN.md`, `graph.json`, `.konveyor/result.json` -- Execute stage reads: `PLAN.md`, `graph.json` (for project structure context). Writes: migrated source files, `.konveyor/result.json` -- Verify stage reads: `PLAN.md` (to know what was migrated and the migration type), migrated source files. Writes: fix patches, `.konveyor/result.json` - -Each stage can read any artifact from prior stages via git. The key -contract files are `PLAN.md` (migration plan) and `graph.json` (project -structure). Skills should reference these explicitly in their -instructions. - -No PVCs, no shared volumes. Clean, auditable, recoverable. - -### Branch Strategy - -All stages must operate on the same git branch. `GIT_TARGET_BRANCH` is -a plain environment variable set by the user in `AgentPlaybookRun.spec.env`. -The controller forwards it to every child AgentRun unchanged (via -`pbRun.Spec.Env`). No CRD field — if the user forgets to set it, the -harness fails immediately. - -**Full env var injection flow in `createAgentRunForStage()`:** - -``` -AgentPlaybookRun - ├─ KONVEYOR_PLAYBOOK_INSTRUCTIONS ← playbook.Spec.Guide - ├─ pbRun.Spec.Env ← user env vars (GIT_REPO_URL, GIT_TOKEN, GIT_TARGET_BRANCH, etc.) - └─ All set on → AgentRun.spec.env - └─ AgentRun controller buildEnvVars() - ├─ KONVEYOR_PARAM_* ← from Agent params - ├─ KONVEYOR_ACP_SECRET_KEY ← generated per run - ├─ KONVEYOR_INSTRUCTIONS ← stage instructions - ├─ KONVEYOR_PROMPT ← Agent CR prompt - ├─ KONVEYOR_MODEL_* ← LLM provider/model/endpoint/apiKey - └─ All forwarded env ← GIT_TARGET_BRANCH, GIT_REPO_URL, etc. - └─ Injected into Sandbox pod container -``` - -- Stage 1 (plan) creates the branch from the repo's default branch and - pushes. Stages 2+ clone and checkout the existing branch, picking up - prior-stage artifacts. -- The harness reads `GIT_TARGET_BRANCH` and fails if it's not set (no - timestamp fallback). -- Concurrent playbook runs against the same repo use different branches - (user sets different `GIT_TARGET_BRANCH` values). - -## Skills - -We author three skills. Each encodes the migration knowledge that was previously spread across harness Go packages and recipe YAML files. - -### Knowledge Migration - -| Deleted Harness Package | Knowledge Moves To | -|---|---| -| `detect/` | **Plan skill** — run graphify, read graph.json, identify manifests | -| `plan/` | **Plan skill** — context gathering, plan structure (items with path/action/risk/layer), PLAN.md format | -| `execute/` | **Execute skill** — iterate PLAN.md items, migration rules, per-item approach | -| `verify/` + `fixloop/` | **Verify skill** — run `mvn clean compile`, parse errors, iterative fix loop | -| `recipes/*.yaml` | Folded directly into corresponding skills as instructions | -| `handoff/` | Not needed — controller tracks stage status, git carries artifacts | -| `metrics/` | Not needed — controller records start/completion times per stage | - -### Plan Skill (`/opt/skills/plan/SKILL.md`) - -Absorbs: `detect/`, `plan/`, `recipes/plan.yaml` logic. - -Instructs goose to: -1. Run `graphify update` on the repo to generate the code graph -2. Read `graph.json`, identify manifests (pom.xml, etc.), count source files -3. Analyze the project structure and migration requirements -4. Produce `PLAN.md` with structured items (number, path, action, risk level, layer) -5. Append result to `.konveyor/result.json` - -### Execute Skill (`/opt/skills/execute/SKILL.md`) - -Absorbs: `execute/`, `recipes/execute.yaml` logic. - -Instructs goose to: -1. Read `PLAN.md` from the repo -2. For each plan item, migrate the file/component following migration rules -3. Work through items sequentially, one at a time -4. Apply language-specific migration patterns (Java EE → Quarkus for the Java variant) -5. Write `.konveyor/result.json` with final status - -Reference docs (javaee-quarkus.md, etc.) are bundled alongside the skill. - -**Iteration guardrails.** Moving plan-item iteration from deterministic -Go code to an LLM-driven skill is the riskiest change. The skill must -include explicit guardrails: -- "You MUST attempt every item in PLAN.md in order. Do not skip items." -- "After completing each item, mark it done in your working notes before moving to the next." -- "Do not re-read PLAN.md after every item — read it once, work through the list." -- "If you cannot complete an item, note the reason and move to the next. Do not get stuck." - -These rules prevent the LLM from skipping items, burning context on one -item, or losing track of progress. - -### Verify Skill (`/opt/skills/verify/SKILL.md`) - -Absorbs: `verify/`, `fixloop/`, `recipes/verify.yaml`, `recipes/fix.yaml` logic. - -Instructs goose to: -1. Run `mvn clean compile` (or equivalent build command) -2. If build succeeds, run tests -3. If errors found, apply minimal conservative fixes -4. Re-verify after each fix -5. Repeat up to N iterations (configurable via `KONVEYOR_PARAM_MAX_FIX_ITERATIONS`) -6. Append result to `.konveyor/result.json` - -## Harness Codebase Changes - -### Packages Kept - -| Package | What stays | -|---|---| -| `git/` | Clone, StripCredentials, CommitAll, Push, CheckoutBranch, ClearEnvCredentials | -| `goose/` | StartServe, Stop, providerEnv, writeADCFile (lifecycle only) | -| `config/` | LoadFromEnv | -| `logging/` | Header, Info, Ok, Warn, Err | -| `acp/` | WSClient, SessionClient (connect, session/new, single prompt) | - -### Packages Deleted - -`detect/`, `plan/`, `execute/`, `verify/`, `fixloop/`, `handoff/`, `metrics/`, `rundir/` - -### Files Deleted - -- `harness/recipes/` (all YAML recipe files) -- `harness/skill-bundle/` (replaced by per-image skills) -- `goose/recipe.go` (recipe rendering) -- `goose/acprunner.go` (multi-turn ACP runner) - -### New Package - -`watcher/` — filesystem watcher with quiet-period detection and git commit+push. - -### main.go - -Simplified to a single `run` command. Remove `init`, `status`, `resume`, `step` subcommands. Remove the 5-step pipeline. Replace with the thin wrapper flow described above. - -## Example Resources (`hack/`) - -Coolstore Java EE → Quarkus example showing the full CR chain: - -```yaml -# Agent CRs -Agent: migration-plan-agent (image: agent-plan) -Agent: migration-execute-java (image: agent-execute-java) -Agent: migration-verify-java (image: agent-verify-java) - -# Playbook -AgentPlaybook: java-ee-to-quarkus - guide: "Migrate a Java EE application to Quarkus" - stages: - - name: plan - agentRef: migration-plan-agent - instructions: "Analyze the project and produce PLAN.md" - - name: execute - agentRef: migration-execute-java - instructions: "Execute each step in PLAN.md" - - name: verify - agentRef: migration-verify-java - instructions: "Run mvn clean compile, fix any errors" - -# Run (user creates this) -AgentPlaybookRun: coolstore-migration - playbookRef: java-ee-to-quarkus - models: [...] - env: - - name: GIT_REPO_URL - value: "https://github.com/savitharaghunathan/coolstore.git" - - name: GIT_TOKEN - valueFrom: { secretKeyRef: { name: git-credentials, key: token } } - - name: GIT_TARGET_BRANCH - value: "konveyor/coolstore-migration" -``` - -## Turn Limits - -With autonomous goose, a runaway stage could burn through API credits. -`KONVEYOR_PARAM_MAX_TURNS` (default 200) should cap how long goose runs -per stage. **Needs investigation during implementation:** how does goose -serve accept a turn limit? Options include a CLI flag, env var, or ACP -session parameter. If none exist natively, the harness may need to count -`tool_call` notifications from the ACP stream and terminate the session -when the limit is reached. - -`KONVEYOR_PARAM_MAX_FIX_ITERATIONS` (default 3) is consumed by the -verify skill to cap its fix loop. It's not a harness concern — the skill -reads it and enforces it. - -## Scope - -This design covers Java only. Other languages follow the same pattern: new execute + verify images with language-specific toolchain and skills, new playbook YAML. No harness or controller changes required. diff --git a/images/README.md b/images/README.md index 4f7daa5..0942dcd 100644 --- a/images/README.md +++ b/images/README.md @@ -16,12 +16,20 @@ make controller-agent-build # build locally make controller-agent-push CONTAINER_TOOL=podman # push to quay ``` -## Stream 4 placeholders +## Stage agent images -Placeholder Containerfiles for the production agent image hierarchy. -These will be implemented as part of Stream 4. +Production agent image hierarchy for migration stages. Skills are +mounted at runtime via SkillCards, not baked into images. ```text -agent-base-goose Goose runtime (extends future agent-base) -agent-java-goose JDK 21 + Maven (extends agent-base-goose) +agent-base UBI 10 + goose CLI + git + harness binary +├── agent-plan + Python 3, graphify +├── agent-java-base + JDK 21, Maven +│ ├── agent-execute (inherits java-base) +│ └── agent-verify (inherits java-base) +``` + +```bash +make agent-images-build # build all stage images +make agent-images-push CONTAINER_TOOL=podman # push to quay ``` diff --git a/images/agent-base-goose/Containerfile b/images/agent-base-goose/Containerfile deleted file mode 100644 index e613a38..0000000 --- a/images/agent-base-goose/Containerfile +++ /dev/null @@ -1,12 +0,0 @@ -# agent-base-goose: Goose runtime image. -# -# Extends agent-base with the Goose agent runtime. -# Goose exposes ACP over HTTP/WebSocket via `goose serve --port 4000`. -# -# TODO: This is a placeholder. Goose binary installation will be added -# as part of Stream 4 (Base Image). - -FROM quay.io/konveyor/agent-base:latest - -# TODO: Install goose binary -# COPY goose /usr/local/bin/goose diff --git a/images/agent-execute/Containerfile b/images/agent-execute/Containerfile index ecb1015..9f61a37 100644 --- a/images/agent-execute/Containerfile +++ b/images/agent-execute/Containerfile @@ -1,4 +1,4 @@ # agent-execute: Execute stage toolchain image. Skill content comes # from SkillCards mounted at runtime. -FROM agent-java-base +FROM quay.io/konveyor/agent-java-base:latest diff --git a/images/agent-java-base/Containerfile b/images/agent-java-base/Containerfile index 6af6e32..ab35fd5 100644 --- a/images/agent-java-base/Containerfile +++ b/images/agent-java-base/Containerfile @@ -4,7 +4,7 @@ # Build context must be the repository root: # podman build -t agent-java-base -f images/agent-java-base/Containerfile . -FROM agent-base AS base +FROM quay.io/konveyor/agent-base:latest AS base USER root diff --git a/images/agent-java-goose/Containerfile b/images/agent-java-goose/Containerfile deleted file mode 100644 index d09dfb9..0000000 --- a/images/agent-java-goose/Containerfile +++ /dev/null @@ -1,15 +0,0 @@ -# agent-java-goose: Java language image with Goose runtime. -# -# Extends agent-base-goose with JDK 21 and Maven for Java -# migration and development workloads. -# -# TODO: This is a placeholder. Toolchain installation will be added -# as part of Stream 4 (Base Image). - -FROM quay.io/konveyor/agent-base-goose:latest - -# TODO: Install JDK 21 -# RUN microdnf install -y java-21-openjdk-devel && microdnf clean all - -# TODO: Install Maven -# RUN microdnf install -y maven && microdnf clean all diff --git a/images/agent-plan/Containerfile b/images/agent-plan/Containerfile index 0dd4493..a04db73 100644 --- a/images/agent-plan/Containerfile +++ b/images/agent-plan/Containerfile @@ -1,7 +1,7 @@ # agent-plan: Plan stage toolchain image. Adds graphify for code graph # generation. Skill content comes from SkillCards mounted at runtime. -FROM agent-base AS base +FROM quay.io/konveyor/agent-base:latest AS base USER root diff --git a/images/agent-verify/Containerfile b/images/agent-verify/Containerfile index a0986cb..72f5f53 100644 --- a/images/agent-verify/Containerfile +++ b/images/agent-verify/Containerfile @@ -1,4 +1,4 @@ # agent-verify: Verify stage toolchain image. Skill content comes # from SkillCards mounted at runtime. -FROM agent-java-base +FROM quay.io/konveyor/agent-java-base:latest From bdfd47d9c29183f264b2c77711101fc3e7fc82ca Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Tue, 21 Jul 2026 20:51:58 -0400 Subject: [PATCH 4/8] Rename images to -java suffix and align results.json naming Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- Makefile | 18 ++++++------ ...arness-thin-runner-and-skillcard-skills.md | 29 +++++++++---------- hack/harness-test/playbook-resources.yaml | 4 +-- hack/harness-test/setup.sh | 4 +-- harness/cmd/migration-harness/main.go | 18 ++++++------ harness/internal/watcher/patterns.go | 2 +- harness/internal/watcher/patterns_test.go | 2 +- harness/internal/watcher/watcher.go | 2 +- images/README.md | 4 +-- .../Containerfile | 2 +- .../Containerfile | 2 +- skills/execute/SKILL.md | 2 +- skills/plan/SKILL.md | 8 +++-- skills/verify/SKILL.md | 2 +- 14 files changed, 51 insertions(+), 48 deletions(-) rename images/{agent-verify => agent-execute-java}/Containerfile (51%) rename images/{agent-execute => agent-verify-java}/Containerfile (52%) diff --git a/Makefile b/Makefile index e30e334..4379577 100644 --- a/Makefile +++ b/Makefile @@ -103,8 +103,8 @@ CONTROLLER_AGENT_IMG ?= quay.io/konveyor/agentic-controller-agent:latest AGENT_BASE_IMG ?= quay.io/konveyor/agent-base:latest AGENT_JAVA_BASE_IMG ?= quay.io/konveyor/agent-java-base:latest AGENT_PLAN_IMG ?= quay.io/konveyor/agent-plan:latest -AGENT_EXECUTE_IMG ?= quay.io/konveyor/agent-execute:latest -AGENT_VERIFY_IMG ?= quay.io/konveyor/agent-verify:latest +AGENT_EXECUTE_IMG ?= quay.io/konveyor/agent-execute-java:latest +AGENT_VERIFY_IMG ?= quay.io/konveyor/agent-verify-java:latest .PHONY: build build: manifests generate fmt vet ## Build manager binary. @@ -130,16 +130,16 @@ agent-java-base-build: agent-base-build ## Build the shared Java base image (JDK agent-plan-build: agent-base-build ## Build the plan stage agent image. $(CONTAINER_TOOL) build -t $(AGENT_PLAN_IMG) -f images/agent-plan/Containerfile . -.PHONY: agent-execute-build -agent-execute-build: agent-java-base-build ## Build the execute stage agent image. - $(CONTAINER_TOOL) build -t $(AGENT_EXECUTE_IMG) -f images/agent-execute/Containerfile . +.PHONY: agent-execute-java-build +agent-execute-java-build: agent-java-base-build ## Build the Java execute stage agent image. + $(CONTAINER_TOOL) build -t $(AGENT_EXECUTE_IMG) -f images/agent-execute-java/Containerfile . -.PHONY: agent-verify-build -agent-verify-build: agent-java-base-build ## Build the verify stage agent image. - $(CONTAINER_TOOL) build -t $(AGENT_VERIFY_IMG) -f images/agent-verify/Containerfile . +.PHONY: agent-verify-java-build +agent-verify-java-build: agent-java-base-build ## Build the Java verify stage agent image. + $(CONTAINER_TOOL) build -t $(AGENT_VERIFY_IMG) -f images/agent-verify-java/Containerfile . .PHONY: agent-images-build -agent-images-build: agent-plan-build agent-execute-build agent-verify-build ## Build all stage agent images. +agent-images-build: agent-plan-build agent-execute-java-build agent-verify-java-build ## Build all stage agent images. .PHONY: agent-images-push agent-images-push: agent-images-build ## Build and push all stage agent images. diff --git a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md index 3a780e4..60137e2 100644 --- a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md +++ b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md @@ -44,7 +44,7 @@ patterns to apply. Its responsibilities are: - `KONVEYOR_INSTRUCTIONS` — stage-specific task 7. Start a filesystem watcher for incremental commit+push 8. Send one ACP prompt and block until completion -9. Stop watcher, read `.konveyor/result.json` for exit status +9. Stop watcher, read `.konveyor/results.json` for exit status 10. Final commit+push, exit 0 or 1 No interactive commands, no file-based config, no multi-turn recipe @@ -153,13 +153,13 @@ real complaint. Git is the shared state boundary. Each stage clones the repo and reads artifacts left by prior stages: -- Plan writes: `PLAN.md`, `graph.json`, `.konveyor/result.json` +- Plan writes: `PLAN.md`, `graph.json`, `.konveyor/results.json` - Execute reads: `PLAN.md`. Writes: migrated source files - Verify reads: migrated source. Writes: fix patches -#### result.json contract +#### results.json contract -Each skill MUST append to `.konveyor/result.json` as its final action. +Each skill MUST append to `.konveyor/results.json` as its final action. The file is a JSON array — each stage adds an entry: ```json @@ -170,15 +170,15 @@ The file is a JSON array — each stage adds an entry: ``` The harness reads the last entry to determine exit code (0 for -`"succeeded"`, 1 for anything else). Missing `result.json` is treated +`"succeeded"`, 1 for anything else). Missing `results.json` is treated as failure. This is a hard contract — a skill that completes -successfully but does not write `result.json` is considered broken. +successfully but does not write `results.json` is considered broken. The controller currently determines stage success from pod exit code only. A planned enhancement will have the harness write a one-line `reason` field to the AgentRun CR status before exiting, giving operators and the UI meaningful failure context without the controller -needing to parse `result.json` from the git branch. +needing to parse `results.json` from the git branch. #### handoff.md (redesign pending) @@ -186,14 +186,14 @@ The harness writes `.konveyor/handoff.md` after each stage. Currently formatted for human readers (status summary, skills loaded, plan steps, file counts). A redesign is planned to make handoff.md machine-readable so that skills can consume prior-stage context -programmatically. The result.json schema will be revisited alongside +programmatically. The results.json schema will be revisited alongside this redesign. ### Stage timeout The harness will support a `KONVEYOR_STAGE_TIMEOUT` env var (default: 60 minutes). When the timeout fires, `SendPrompt` returns via context -cancellation, the harness writes a failure entry to `result.json` +cancellation, the harness writes a failure entry to `results.json` ("stage timed out"), performs the final commit+push to preserve partial work, and exits 1. This provides graceful timeout with artifact preservation, versus the Sandbox's `activeDeadlineSeconds` which kills @@ -269,9 +269,9 @@ Skill-level chunking (the skill itself decides how to batch work) keeps the harness thin and puts the complexity where domain knowledge lives. -### Controller reads result.json directly +### Controller reads results.json directly -The controller clones the git branch or reads result.json from the +The controller clones the git branch or reads results.json from the pod filesystem to get structured stage results. Rejected because: adds git or filesystem dependencies to the @@ -292,8 +292,8 @@ stays a standard stateless reconciler. within the context window. The harness has no automatic recovery if the LLM loses context mid-stage. -- **result.json is a hard contract.** Skills must append to - `.konveyor/result.json` as their final action. Missing result.json +- **results.json is a hard contract.** Skills must append to + `.konveyor/results.json` as their final action. Missing results.json is treated as stage failure. This forces skill authors to explicitly declare success or failure. @@ -321,7 +321,6 @@ stays a standard stateless reconciler. | Item | Description | |------|-------------| -| handoff.md + result.json redesign | Make machine-readable for agent consumption | -| Image rename | `agent-execute` → `agent-execute-java`, `agent-verify` → `agent-verify-java` | +| handoff.md + results.json redesign | Make machine-readable for agent consumption | | Stage timeout | Implement `KONVEYOR_STAGE_TIMEOUT` env var | | AgentRun reason field | Harness writes one-line failure reason to CR status | diff --git a/hack/harness-test/playbook-resources.yaml b/hack/harness-test/playbook-resources.yaml index 3c987a3..0916a53 100644 --- a/hack/harness-test/playbook-resources.yaml +++ b/hack/harness-test/playbook-resources.yaml @@ -97,7 +97,7 @@ kind: Agent metadata: name: migration-execute-agent spec: - image: quay.io/konveyor/agent-execute:dev + image: quay.io/konveyor/agent-execute-java:dev providers: - ref: gcp-vertex-ai skillCards: @@ -114,7 +114,7 @@ kind: Agent metadata: name: migration-verify-agent spec: - image: quay.io/konveyor/agent-verify:dev + image: quay.io/konveyor/agent-verify-java:dev providers: - ref: gcp-vertex-ai skillCards: diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index abfee11..40c6e59 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -66,8 +66,8 @@ echo "=== Loading images into Kind ===" IMAGES=( "quay.io/konveyor/agent-plan" - "quay.io/konveyor/agent-execute" - "quay.io/konveyor/agent-verify" + "quay.io/konveyor/agent-execute-java" + "quay.io/konveyor/agent-verify-java" ) for IMG in "${IMAGES[@]}"; do diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index a495064..08e6fa6 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -152,7 +152,7 @@ func runStage(cmd *cobra.Command, args []string) error { // 10. Stop watcher w.Stop() - // 11. Read result.json for exit status + // 11. Read results.json for exit status result, exitCode := readResultStatus(cloneDir) // 11b. Write handoff.md for next stage @@ -171,7 +171,7 @@ func runStage(cmd *cobra.Command, args []string) error { // 13. Exit if exitCode != 0 { - logging.Err("stage failed (result.json)") + logging.Err("stage failed (results.json)") os.Exit(1) } logging.Ok("stage succeeded") @@ -246,22 +246,22 @@ type stageResult struct { } func readResultStatus(workDir string) (stageResult, int) { - path := filepath.Join(workDir, ".konveyor", "result.json") + path := filepath.Join(workDir, ".konveyor", "results.json") data, err := os.ReadFile(path) if err != nil { - logging.Warn("no result.json found — treating as failure") - return stageResult{Stage: "unknown", Status: "failed", Reason: "no result.json"}, 1 + logging.Warn("no results.json found — treating as failure") + return stageResult{Stage: "unknown", Status: "failed", Reason: "no results.json"}, 1 } var results []stageResult if err := json.Unmarshal(data, &results); err != nil { - logging.Warn("invalid result.json: %v", err) - return stageResult{Stage: "unknown", Status: "failed", Reason: "invalid result.json"}, 1 + logging.Warn("invalid results.json: %v", err) + return stageResult{Stage: "unknown", Status: "failed", Reason: "invalid results.json"}, 1 } if len(results) == 0 { - logging.Warn("result.json is empty — treating as failure") - return stageResult{Stage: "unknown", Status: "failed", Reason: "empty result.json"}, 1 + logging.Warn("results.json is empty — treating as failure") + return stageResult{Stage: "unknown", Status: "failed", Reason: "empty results.json"}, 1 } last := results[len(results)-1] diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go index a5b8d9c..32c5f93 100644 --- a/harness/internal/watcher/patterns.go +++ b/harness/internal/watcher/patterns.go @@ -23,7 +23,7 @@ var excludeExts = map[string]bool{ func ShouldStageNewFile(path string) bool { base := filepath.Base(path) - if base == "pom.xml" || base == "result.json" { + if base == "pom.xml" || base == "results.json" { return true } diff --git a/harness/internal/watcher/patterns_test.go b/harness/internal/watcher/patterns_test.go index a9e73ae..3ffa155 100644 --- a/harness/internal/watcher/patterns_test.go +++ b/harness/internal/watcher/patterns_test.go @@ -10,7 +10,7 @@ func TestShouldStageNewFile(t *testing.T) { {"src/main/java/com/example/App.java", true}, {"pom.xml", true}, {"src/main/resources/application.properties", true}, - {".konveyor/result.json", true}, + {".konveyor/results.json", true}, {"PLAN.md", true}, {"graph.json", true}, {".goose/cache/foo.txt", false}, diff --git a/harness/internal/watcher/watcher.go b/harness/internal/watcher/watcher.go index cb517e5..381222c 100644 --- a/harness/internal/watcher/watcher.go +++ b/harness/internal/watcher/watcher.go @@ -16,7 +16,7 @@ import ( // DefaultQuietPeriod is the debounce window: after the last relevant // filesystem event, the watcher waits this long before committing. -// .konveyor/ is excluded from watching (result.json is written once at +// .konveyor/ is excluded from watching (results.json is written once at // stage end) but NOT from ShouldStageNewFile, so the final CommitAll // in main.go picks it up. const DefaultQuietPeriod = 30 * time.Second diff --git a/images/README.md b/images/README.md index 0942dcd..07665eb 100644 --- a/images/README.md +++ b/images/README.md @@ -25,8 +25,8 @@ mounted at runtime via SkillCards, not baked into images. agent-base UBI 10 + goose CLI + git + harness binary ├── agent-plan + Python 3, graphify ├── agent-java-base + JDK 21, Maven -│ ├── agent-execute (inherits java-base) -│ └── agent-verify (inherits java-base) +│ ├── agent-execute-java (inherits java-base) +│ └── agent-verify-java (inherits java-base) ``` ```bash diff --git a/images/agent-verify/Containerfile b/images/agent-execute-java/Containerfile similarity index 51% rename from images/agent-verify/Containerfile rename to images/agent-execute-java/Containerfile index 72f5f53..02bfd2f 100644 --- a/images/agent-verify/Containerfile +++ b/images/agent-execute-java/Containerfile @@ -1,4 +1,4 @@ -# agent-verify: Verify stage toolchain image. Skill content comes +# agent-execute-java: Java execute stage toolchain image. Skill content comes # from SkillCards mounted at runtime. FROM quay.io/konveyor/agent-java-base:latest diff --git a/images/agent-execute/Containerfile b/images/agent-verify-java/Containerfile similarity index 52% rename from images/agent-execute/Containerfile rename to images/agent-verify-java/Containerfile index 9f61a37..81263bd 100644 --- a/images/agent-execute/Containerfile +++ b/images/agent-verify-java/Containerfile @@ -1,4 +1,4 @@ -# agent-execute: Execute stage toolchain image. Skill content comes +# agent-verify-java: Java verify stage toolchain image. Skill content comes # from SkillCards mounted at runtime. FROM quay.io/konveyor/agent-java-base:latest diff --git a/skills/execute/SKILL.md b/skills/execute/SKILL.md index 5ce9cd5..c5dc913 100644 --- a/skills/execute/SKILL.md +++ b/skills/execute/SKILL.md @@ -56,7 +56,7 @@ For each step in PLAN.md, follow this exact sequence: ## Completion -After executing all steps, append your result to `.konveyor/result.json`: +After executing all steps, append your result to `.konveyor/results.json`: Read the existing file (it should have the plan stage entry), parse the JSON array, append your entry, and write it back. diff --git a/skills/plan/SKILL.md b/skills/plan/SKILL.md index e18a600..0e7efc1 100644 --- a/skills/plan/SKILL.md +++ b/skills/plan/SKILL.md @@ -98,7 +98,11 @@ Read `graph.json` to understand the project architecture: ### 3b. Match Patterns to Graph -Check which migration patterns exist in the graph: +Check `/opt/skills/*/references/` for domain-specific migration patterns +from loaded migration skills. These references contain migration order, +import mappings, and pattern catalogs for the specific migration type. + +Use these patterns to identify which graph nodes need migration: **Example (if a Java EE migration skill is loaded)**: - Look for nodes where `attrs.annotations` contains `@MessageDriven` → Mark as COMPLEX @@ -225,7 +229,7 @@ Write `PLAN.md` to the project root with this structure: ## Phase 6 — Write Result -After writing PLAN.md, append your result to `.konveyor/result.json`: +After writing PLAN.md, append your result to `.konveyor/results.json`: ```bash mkdir -p .konveyor diff --git a/skills/verify/SKILL.md b/skills/verify/SKILL.md index a41196a..f29f247 100644 --- a/skills/verify/SKILL.md +++ b/skills/verify/SKILL.md @@ -73,7 +73,7 @@ are documented in the result, not fixed here. ## Phase 5 — Write Result -Append your result to `.konveyor/result.json`: +Append your result to `.konveyor/results.json`: Read the existing file (it should have plan and execute entries), parse the JSON array, append your entry, and write it back. From a0a59b7bac416900956bc2d0f356bc3bfd00a532 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Wed, 22 Jul 2026 12:28:30 -0400 Subject: [PATCH 5/8] =?UTF-8?q?Drop=20results.json=20=E2=80=94=20use=20ACP?= =?UTF-8?q?=20completion=20for=20exit=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- ...arness-thin-runner-and-skillcard-skills.md | 71 ++++++-------- harness/cmd/migration-harness/main.go | 95 +++---------------- harness/internal/watcher/patterns.go | 4 +- harness/internal/watcher/patterns_test.go | 2 +- harness/internal/watcher/watcher.go | 3 - skills/execute/SKILL.md | 19 ---- skills/plan/SKILL.md | 25 ----- skills/verify/SKILL.md | 21 ---- 8 files changed, 42 insertions(+), 198 deletions(-) diff --git a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md index 60137e2..9220f42 100644 --- a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md +++ b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md @@ -44,7 +44,7 @@ patterns to apply. Its responsibilities are: - `KONVEYOR_INSTRUCTIONS` — stage-specific task 7. Start a filesystem watcher for incremental commit+push 8. Send one ACP prompt and block until completion -9. Stop watcher, read `.konveyor/results.json` for exit status +9. Stop watcher, determine exit status from ACP completion (error = failure, clean return = success) 10. Final commit+push, exit 0 or 1 No interactive commands, no file-based config, no multi-turn recipe @@ -153,49 +153,33 @@ real complaint. Git is the shared state boundary. Each stage clones the repo and reads artifacts left by prior stages: -- Plan writes: `PLAN.md`, `graph.json`, `.konveyor/results.json` +- Plan writes: `PLAN.md`, `graph.json` - Execute reads: `PLAN.md`. Writes: migrated source files - Verify reads: migrated source. Writes: fix patches -#### results.json contract +#### Exit status -Each skill MUST append to `.konveyor/results.json` as its final action. -The file is a JSON array — each stage adds an entry: +The harness determines success or failure from ACP/goose signals: +`SendPrompt` returning without error and goose still alive = success +(exit 0). Any error or goose crash = failure (exit 1). The harness +does not read any skill-written status file — git commits are the +primary cross-stage context. Quality assessment is the eval stage's +responsibility, not the harness's. -```json -[ - {"stage": "plan", "status": "succeeded"}, - {"stage": "execute", "status": "failed", "reason": "context exhausted at step 23"} -] -``` - -The harness reads the last entry to determine exit code (0 for -`"succeeded"`, 1 for anything else). Missing `results.json` is treated -as failure. This is a hard contract — a skill that completes -successfully but does not write `results.json` is considered broken. - -The controller currently determines stage success from pod exit code -only. A planned enhancement will have the harness write a one-line -`reason` field to the AgentRun CR status before exiting, giving -operators and the UI meaningful failure context without the controller -needing to parse `results.json` from the git branch. - -#### handoff.md (redesign pending) +#### handoff.md -The harness writes `.konveyor/handoff.md` after each stage. Currently -formatted for human readers (status summary, skills loaded, plan steps, -file counts). A redesign is planned to make handoff.md -machine-readable so that skills can consume prior-stage context -programmatically. The results.json schema will be revisited alongside -this redesign. +The harness writes `.konveyor/handoff.md` after each stage with +status, skills loaded, and file counts. Git commits are the primary +cross-stage context — handoff.md supplements them with metadata that +isn't in the diff. If git commits prove insufficient for agent +consumption, handoff.md can be redesigned for machine readability. ### Stage timeout The harness will support a `KONVEYOR_STAGE_TIMEOUT` env var (default: 60 minutes). When the timeout fires, `SendPrompt` returns via context -cancellation, the harness writes a failure entry to `results.json` -("stage timed out"), performs the final commit+push to preserve partial -work, and exits 1. This provides graceful timeout with artifact +cancellation, the harness performs the final commit+push to preserve +partial work, and exits 1. This provides graceful timeout with artifact preservation, versus the Sandbox's `activeDeadlineSeconds` which kills the pod hard and loses uncommitted progress. @@ -269,15 +253,15 @@ Skill-level chunking (the skill itself decides how to batch work) keeps the harness thin and puts the complexity where domain knowledge lives. -### Controller reads results.json directly +### Controller reads status files from git -The controller clones the git branch or reads results.json from the +The controller clones the git branch or reads status files from the pod filesystem to get structured stage results. Rejected because: adds git or filesystem dependencies to the -controller. The simpler approach is the harness writing a one-line -reason to the AgentRun CR status before exiting. The controller -stays a standard stateless reconciler. +controller. The harness exit code (derived from ACP completion +status) is sufficient. The controller stays a standard stateless +reconciler that watches pod exit codes. ## Consequences @@ -292,10 +276,10 @@ stays a standard stateless reconciler. within the context window. The harness has no automatic recovery if the LLM loses context mid-stage. -- **results.json is a hard contract.** Skills must append to - `.konveyor/results.json` as their final action. Missing results.json - is treated as stage failure. This forces skill authors to explicitly - declare success or failure. +- **Exit status from ACP, not files.** The harness determines + success/failure from the ACP completion signal, not from any + skill-written file. Git commits are the cross-stage context. + Quality assessment is deferred to the eval stage. - **SkillCard dependency.** Stage images are non-functional without mounted SkillCards. A misconfigured Agent CR (missing skillCards @@ -321,6 +305,5 @@ stays a standard stateless reconciler. | Item | Description | |------|-------------| -| handoff.md + results.json redesign | Make machine-readable for agent consumption | +| handoff.md redesign | Redesign for machine readability if git commits prove insufficient for cross-stage context | | Stage timeout | Implement `KONVEYOR_STAGE_TIMEOUT` env var | -| AgentRun reason field | Harness writes one-line failure reason to CR status | diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index 08e6fa6..2997d9a 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -2,13 +2,10 @@ package main import ( "context" - "bufio" - "encoding/json" "fmt" "os" "os/signal" "path/filepath" - "regexp" "strings" "time" @@ -152,11 +149,16 @@ func runStage(cmd *cobra.Command, args []string) error { // 10. Stop watcher w.Stop() - // 11. Read results.json for exit status - result, exitCode := readResultStatus(cloneDir) + // 11. Determine exit status from ACP/goose signals + stageFailed := err != nil || !srv.Alive() + + status := "succeeded" + if stageFailed { + status = "failed" + } // 11b. Write handoff.md for next stage - if err := writeHandoff(cloneDir, skillPaths, result, repo); err != nil { + if err := writeHandoff(cloneDir, skillPaths, status, repo); err != nil { logging.Warn("handoff: %v", err) } @@ -170,8 +172,8 @@ func runStage(cmd *cobra.Command, args []string) error { } // 13. Exit - if exitCode != 0 { - logging.Err("stage failed (results.json)") + if stageFailed { + logging.Err("stage failed") os.Exit(1) } logging.Ok("stage succeeded") @@ -238,46 +240,11 @@ func buildPrompt(skillContent string) string { return b.String() } -type stageResult struct { - Stage string `json:"stage"` - Status string `json:"status"` - Reason string `json:"reason,omitempty"` - Summary string `json:"summary,omitempty"` -} - -func readResultStatus(workDir string) (stageResult, int) { - path := filepath.Join(workDir, ".konveyor", "results.json") - data, err := os.ReadFile(path) - if err != nil { - logging.Warn("no results.json found — treating as failure") - return stageResult{Stage: "unknown", Status: "failed", Reason: "no results.json"}, 1 - } - - var results []stageResult - if err := json.Unmarshal(data, &results); err != nil { - logging.Warn("invalid results.json: %v", err) - return stageResult{Stage: "unknown", Status: "failed", Reason: "invalid results.json"}, 1 - } - - if len(results) == 0 { - logging.Warn("results.json is empty — treating as failure") - return stageResult{Stage: "unknown", Status: "failed", Reason: "empty results.json"}, 1 - } - - last := results[len(results)-1] - if last.Status == "succeeded" { - return last, 0 - } - - logging.Err("stage %s failed: %s", last.Stage, last.Reason) - return last, 1 -} - func skillName(path string) string { return filepath.Base(filepath.Dir(path)) } -func writeHandoff(workDir string, skills []string, result stageResult, repo *gogit.Repository) error { +func writeHandoff(workDir string, skills []string, status string, repo *gogit.Repository) error { handoffPath := filepath.Join(workDir, ".konveyor", "handoff.md") if err := os.MkdirAll(filepath.Dir(handoffPath), 0o755); err != nil { return fmt.Errorf("create .konveyor dir: %w", err) @@ -292,33 +259,14 @@ func writeHandoff(workDir string, skills []string, result stageResult, repo *gog b.WriteString("\n---\n\n") } - fmt.Fprintf(&b, "## Stage: %s\n\n", result.Stage) - fmt.Fprintf(&b, "**Status:** %s \n", result.Status) + fmt.Fprintf(&b, "**Status:** %s \n", status) fmt.Fprintf(&b, "**Completed:** %s\n", time.Now().UTC().Format(time.RFC3339)) - if result.Reason != "" { - fmt.Fprintf(&b, "**Reason:** %s\n", result.Reason) - } - - if result.Summary != "" { - b.WriteString("\n### Summary\n\n") - b.WriteString(result.Summary) - b.WriteString("\n") - } b.WriteString("\n### Skills\n\n") for _, s := range skills { fmt.Fprintf(&b, "- %s\n", skillName(s)) } - if result.Stage == "plan" { - if steps := planSteps(workDir); len(steps) > 0 { - b.WriteString("\n### Migration Steps (from PLAN.md)\n\n") - for _, s := range steps { - fmt.Fprintf(&b, "- %s\n", s) - } - } - } - if n := changedFileCount(repo); n > 0 { fmt.Fprintf(&b, "\n**Files changed:** %d\n", n) } @@ -358,22 +306,3 @@ func changedFileCount(repo *gogit.Repository) int { return n } -var stepRe = regexp.MustCompile(`^###\s+Step\s+\d+`) - -func planSteps(workDir string) []string { - f, err := os.Open(filepath.Join(workDir, "PLAN.md")) - if err != nil { - return nil - } - defer f.Close() - var steps []string - sc := bufio.NewScanner(f) - for sc.Scan() { - line := sc.Text() - if stepRe.MatchString(line) { - title := strings.TrimPrefix(line, "### ") - steps = append(steps, title) - } - } - return steps -} diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go index 32c5f93..3f7b754 100644 --- a/harness/internal/watcher/patterns.go +++ b/harness/internal/watcher/patterns.go @@ -13,7 +13,7 @@ var sourceExts = map[string]bool{ var excludeDirs = map[string]bool{ ".goose": true, "__pycache__": true, ".git": true, - "node_modules": true, "target": true, "graphify-out": true, + ".konveyor": true, "node_modules": true, "target": true, "graphify-out": true, } var excludeExts = map[string]bool{ @@ -23,7 +23,7 @@ var excludeExts = map[string]bool{ func ShouldStageNewFile(path string) bool { base := filepath.Base(path) - if base == "pom.xml" || base == "results.json" { + if base == "pom.xml" { return true } diff --git a/harness/internal/watcher/patterns_test.go b/harness/internal/watcher/patterns_test.go index 3ffa155..b9f12f7 100644 --- a/harness/internal/watcher/patterns_test.go +++ b/harness/internal/watcher/patterns_test.go @@ -10,7 +10,7 @@ func TestShouldStageNewFile(t *testing.T) { {"src/main/java/com/example/App.java", true}, {"pom.xml", true}, {"src/main/resources/application.properties", true}, - {".konveyor/results.json", true}, + {".konveyor/results.json", false}, {"PLAN.md", true}, {"graph.json", true}, {".goose/cache/foo.txt", false}, diff --git a/harness/internal/watcher/watcher.go b/harness/internal/watcher/watcher.go index 381222c..8a3b18b 100644 --- a/harness/internal/watcher/watcher.go +++ b/harness/internal/watcher/watcher.go @@ -16,9 +16,6 @@ import ( // DefaultQuietPeriod is the debounce window: after the last relevant // filesystem event, the watcher waits this long before committing. -// .konveyor/ is excluded from watching (results.json is written once at -// stage end) but NOT from ShouldStageNewFile, so the final CommitAll -// in main.go picks it up. const DefaultQuietPeriod = 30 * time.Second type CommitPushFn func() error diff --git a/skills/execute/SKILL.md b/skills/execute/SKILL.md index c5dc913..b67b950 100644 --- a/skills/execute/SKILL.md +++ b/skills/execute/SKILL.md @@ -54,25 +54,6 @@ For each step in PLAN.md, follow this exact sequence: --- -## Completion - -After executing all steps, append your result to `.konveyor/results.json`: - -Read the existing file (it should have the plan stage entry), parse the -JSON array, append your entry, and write it back. - -Your entry: - -```json -{"stage": "execute", "status": "succeeded", "summary": "<1-2 sentences: what was migrated and how many steps completed>"} -``` - -Or on failure: - -```json -{"stage": "execute", "status": "failed", "reason": "<what went wrong>", "summary": "<what was completed before failure>"} -``` - ## Important - Work through ALL items — completeness matters more than perfection diff --git a/skills/plan/SKILL.md b/skills/plan/SKILL.md index 0e7efc1..89ce3a8 100644 --- a/skills/plan/SKILL.md +++ b/skills/plan/SKILL.md @@ -227,31 +227,6 @@ Write `PLAN.md` to the project root with this structure: --- -## Phase 6 — Write Result - -After writing PLAN.md, append your result to `.konveyor/results.json`: - -```bash -mkdir -p .konveyor -``` - -If the file exists, read it, parse the JSON array, append your entry, -and write it back. If it does not exist, create it with a single-entry array. - -Your entry: - -```json -{"stage": "plan", "status": "succeeded", "summary": "<1-2 sentences: what you found and what the plan covers>"} -``` - -Or on failure: - -```json -{"stage": "plan", "status": "failed", "reason": "<what went wrong>", "summary": "<what was attempted>"} -``` - ---- - ## Important - Do NOT modify source files — planning only diff --git a/skills/verify/SKILL.md b/skills/verify/SKILL.md index f29f247..196ba3a 100644 --- a/skills/verify/SKILL.md +++ b/skills/verify/SKILL.md @@ -71,27 +71,6 @@ are documented in the result, not fixed here. --- -## Phase 5 — Write Result - -Append your result to `.konveyor/results.json`: - -Read the existing file (it should have plan and execute entries), -parse the JSON array, append your entry, and write it back. - -Your entry on success: - -```json -{"stage": "verify", "status": "succeeded", "summary": "<1-2 sentences: build/test results and any fixes applied>"} -``` - -On failure: - -```json -{"stage": "verify", "status": "failed", "reason": "build failed after N fix iterations: <remaining errors>", "summary": "<what was tried and what errors remain>"} -``` - ---- - ## Important - Fixes must be minimal and conservative — do not rewrite working code From 1b670b893448cd8415541ec0a98ab1fec1167f99 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Wed, 22 Jul 2026 16:49:30 -0400 Subject: [PATCH 6/8] Resolve app metadata and git creds from Hub API at startup Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- ...arness-thin-runner-and-skillcard-skills.md | 44 +++++--- hack/harness-test/playbook-resources.yaml | 19 ++-- hack/harness-test/setup.sh | 24 ++-- harness/cmd/migration-harness/main.go | 106 +++++++++++++++--- harness/go.mod | 10 +- harness/go.sum | 20 +++- harness/internal/config/config.go | 14 +++ harness/internal/config/config_test.go | 11 ++ harness/internal/git/credentials.go | 36 ------ harness/internal/git/git.go | 9 +- harness/internal/git/git_test.go | 67 ----------- harness/internal/hub/client.go | 73 ++++++++++++ 12 files changed, 267 insertions(+), 166 deletions(-) create mode 100644 harness/internal/hub/client.go diff --git a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md index 9220f42..0b1f800 100644 --- a/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md +++ b/docs/adr/0006-harness-thin-runner-and-skillcard-skills.md @@ -32,29 +32,37 @@ stage it is running, what language is being migrated, or what migration patterns to apply. Its responsibilities are: 1. Load configuration from environment variables (`config.LoadFromEnv`) -2. Clone the git repo, strip credentials from the remote, checkout the - target branch -3. Start `goose serve` with LLM provider credentials -4. Connect via ACP WebSocket, create a session -5. Discover skills from `/opt/skills/*/SKILL.md` (glob) -6. Build a single prompt from four context layers: +2. Resolve application metadata, git credentials, and analysis results + from the Konveyor Hub API (`HUB_BASE_URL`, `HUB_TOKEN`, `APP_ID`) +3. Clone the git repo using Hub-provided credentials, strip credentials + from the remote, checkout the controller-provided target branch + (`TARGET_BRANCH`) +4. Write analysis insights to `.konveyor/analysis.json` (if available) +5. Clear Hub credentials from the environment +6. Start `goose serve` with LLM provider credentials +7. Connect via ACP WebSocket, create a session +8. Discover skills from `/opt/skills/*/SKILL.md` (glob) +9. Build a single prompt from four context layers: - `KONVEYOR_PROMPT` — agent-level standing instructions - `KONVEYOR_PLAYBOOK_INSTRUCTIONS` — playbook guide context - Skill content (concatenated from all discovered skills) - `KONVEYOR_INSTRUCTIONS` — stage-specific task -7. Start a filesystem watcher for incremental commit+push -8. Send one ACP prompt and block until completion -9. Stop watcher, determine exit status from ACP completion (error = failure, clean return = success) -10. Final commit+push, exit 0 or 1 +10. Start a filesystem watcher for incremental commit+push +11. Send one ACP prompt and block until completion +12. Stop watcher, determine exit status from ACP completion (error = + failure, clean return = success) +13. Final commit+push — push failure is fatal (exit 1) No interactive commands, no file-based config, no multi-turn recipe execution. #### Credential isolation -Git credentials are stripped from the cloned repo's remote and cleared -from the environment before goose starts. This is a deliberate security -boundary — goose and any skill content it executes cannot access push +Hub credentials (`HUB_BASE_URL`, `HUB_TOKEN`, `APP_ID`) are cleared +from the environment after resolution via `hub.ClearEnv()`. Git +credentials are stripped from the cloned repo's remote. Both happen +before goose starts. This is a deliberate security boundary — goose +and any skill content it executes cannot access Hub or push credentials. Only the harness binary pushes to git. ### Two kinds of skills: stage and domain @@ -161,10 +169,12 @@ artifacts left by prior stages: The harness determines success or failure from ACP/goose signals: `SendPrompt` returning without error and goose still alive = success -(exit 0). Any error or goose crash = failure (exit 1). The harness -does not read any skill-written status file — git commits are the -primary cross-stage context. Quality assessment is the eval stage's -responsibility, not the harness's. +(exit 0). Any error, goose crash, or final push failure = failure +(exit 1). Push failure is fatal because the next stage clones the +branch — if the push didn't land, the next stage has no artifacts to +work with. The harness does not read any skill-written status file — +git commits are the primary cross-stage context. Quality assessment +is the eval stage's responsibility, not the harness's. #### handoff.md diff --git a/hack/harness-test/playbook-resources.yaml b/hack/harness-test/playbook-resources.yaml index 0916a53..13d15bc 100644 --- a/hack/harness-test/playbook-resources.yaml +++ b/hack/harness-test/playbook-resources.yaml @@ -3,7 +3,7 @@ # # Prerequisites: # - LLMProvider "gcp-vertex-ai" from resources.yaml -# - git-credentials Secret from setup.sh +# - Hub running with app + identity configured # # Usage: # kubectl apply -f hack/harness-test/resources.yaml @@ -158,15 +158,14 @@ spec: provider: gcp-vertex-ai model: claude-sonnet-4-5 env: - - name: GIT_REPO_URL - value: "https://github.com/savitharaghunathan/coolstore.git" - - name: GIT_TOKEN - valueFrom: - secretKeyRef: - name: git-credentials - key: token - - name: GIT_TARGET_BRANCH - value: "konveyor/playbook-__TIMESTAMP__" + - name: HUB_BASE_URL + value: "http://host.containers.internal:8080" + - name: HUB_TOKEN + value: "__HUB_TOKEN__" + - name: APP_ID + value: "1" + - name: TARGET_BRANCH + value: "konveyor/migration-__TIMESTAMP__" - name: GCP_PROJECT_ID value: "__GCP_PROJECT_ID__" - name: GCP_LOCATION diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index 40c6e59..6c680dd 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -29,16 +29,14 @@ kubectl create secret generic vertex-credentials \ --dry-run=client -o yaml | kubectl apply -f - echo " vertex-credentials created" -# Git token from gh CLI -GIT_TOKEN=$(gh auth token 2>/dev/null || echo "") -if [ -z "$GIT_TOKEN" ]; then - echo "ERROR: Could not get GitHub token. Run: gh auth login" - exit 1 -fi -kubectl create secret generic git-credentials \ - --from-literal=token="$GIT_TOKEN" \ - --dry-run=client -o yaml | kubectl apply -f - -echo " git-credentials created" +# Hub token (JWT signed with default key "tackle") +HUB_KEY="${HUB_KEY:-tackle}" +EXP=$(( $(date +%s) + 86400 )) +HEADER_B64=$(printf '{"typ":"JWT","alg":"HS512"}' | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +PAYLOAD_B64=$(printf '{"sub":"admin","scope":"*:*","exp":%d}' "$EXP" | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +SIGNATURE=$(printf '%s.%s' "$HEADER_B64" "$PAYLOAD_B64" | openssl dgst -sha512 -hmac "$HUB_KEY" -binary | base64 | tr -d '=' | tr '+/' '-_' | tr -d '\n') +HUB_TOKEN="${HEADER_B64}.${PAYLOAD_B64}.${SIGNATURE}" +echo " hub token generated (expires in 24h)" echo "" echo "=== Building agent images ===" @@ -105,9 +103,11 @@ fi echo " GCP project: (set)" sed "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/" "$SCRIPT_DIR/resources.yaml" | kubectl apply -f - TIMESTAMP=$(date +%s) -sed -e "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/g" -e "s/__TIMESTAMP__/$TIMESTAMP/g" "$SCRIPT_DIR/playbook-resources.yaml" | kubectl apply -f - +sed -e "s/__GCP_PROJECT_ID__/$GCP_PROJECT_ID/g" \ + -e "s/__TIMESTAMP__/$TIMESTAMP/g" \ + -e "s|__HUB_TOKEN__|$HUB_TOKEN|g" \ + "$SCRIPT_DIR/playbook-resources.yaml" | kubectl apply -f - echo " AgentPlaybookRun: coolstore-migration-$TIMESTAMP" -echo " Branch: konveyor/playbook-$TIMESTAMP" echo "" echo "=== Done ===" diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index 2997d9a..041123a 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "fmt" "os" "os/signal" @@ -17,6 +18,7 @@ import ( "github.com/konveyor/migration-harness/internal/config" "github.com/konveyor/migration-harness/internal/git" "github.com/konveyor/migration-harness/internal/goose" + "github.com/konveyor/migration-harness/internal/hub" "github.com/konveyor/migration-harness/internal/logging" "github.com/konveyor/migration-harness/internal/watcher" ) @@ -49,27 +51,31 @@ func runStage(cmd *cobra.Command, args []string) error { // 1. Load config from env cfg := config.LoadFromEnv() if cfg == nil { - return fmt.Errorf("KONVEYOR_MODEL_PRIMARY_MODEL and KONVEYOR_MODEL_PRIMARY_PROVIDER are required") + return fmt.Errorf("required env vars: KONVEYOR_MODEL_PRIMARY_MODEL, KONVEYOR_MODEL_PRIMARY_PROVIDER, HUB_BASE_URL, APP_ID") } - // 2. Read git creds - creds, err := git.ReadFromEnv() + // 2. Resolve app info + git creds from Hub + cloneDir := os.Getenv("HARNESS_WORK_DIR") + if cloneDir == "" { + cloneDir = "/workspace/repo" + } + + creds, hubClient, err := resolveFromHub(cfg) if err != nil { - return fmt.Errorf("git credentials: %w", err) + return fmt.Errorf("hub resolution: %w", err) } - if creds == nil { - return fmt.Errorf("GIT_REPO_URL is required") + + // Controller must set the target branch — Hub branch is the source, not the push target. + targetBranch := os.Getenv("TARGET_BRANCH") + if targetBranch == "" { + return fmt.Errorf("TARGET_BRANCH is required") } + creds.Branch = targetBranch // 3. Clone, strip creds, checkout branch logging.Header("Git Setup") logging.Info("cloning %s...", creds.RepoURL) - cloneDir := os.Getenv("HARNESS_WORK_DIR") - if cloneDir == "" { - cloneDir = "/workspace/repo" - } - repo, err := git.Clone(ctx, creds, cloneDir) if err != nil { return fmt.Errorf("clone: %w", err) @@ -78,13 +84,20 @@ func runStage(cmd *cobra.Command, args []string) error { if err := git.StripCredentials(repo); err != nil { return fmt.Errorf("strip credentials: %w", err) } - git.ClearEnvCredentials() + hub.ClearEnv() if err := git.CheckoutBranch(repo, creds.Branch); err != nil { return fmt.Errorf("checkout branch %s: %w", creds.Branch, err) } logging.Ok("cloned to %s, branch %s", cloneDir, creds.Branch) + // 3b. Write analysis to workspace (if resolved from Hub) + if hubClient != nil { + if err := fetchAndWriteAnalysis(hubClient, cfg.AppID, cloneDir); err != nil { + logging.Warn("analysis fetch: %v", err) + } + } + // 4. Start goose serve logging.Header("Goose Setup") srv, err := goose.StartServe(ctx, 0, cfg.Provider, cfg.Model, cfg.APIKey, cfg.Endpoint) @@ -168,7 +181,7 @@ func runStage(cmd *cobra.Command, args []string) error { logging.Warn("final commit: %v", err) } if err := git.Push(ctx, creds, repo, creds.Branch); err != nil { - logging.Warn("final push: %v", err) + return fmt.Errorf("final push: %w", err) } // 13. Exit @@ -279,6 +292,73 @@ func writeHandoff(workDir string, skills []string, status string, repo *gogit.Re return nil } +func resolveFromHub(cfg *config.Config) (*git.Credentials, *hub.Client, error) { + logging.Header("Hub Resolution") + + appID, err := hub.ParseAppID(cfg.AppID) + if err != nil { + return nil, nil, fmt.Errorf("invalid APP_ID %q: %w", cfg.AppID, err) + } + + hubClient := hub.NewClient(cfg.HubBaseURL, cfg.HubToken) + + app, err := hubClient.FetchApp(appID) + if err != nil { + return nil, nil, fmt.Errorf("fetch app: %w", err) + } + logging.Ok("app: %s (id=%d), repo: %s", app.Name, app.ID, app.Repository.URL) + + identity, err := hubClient.FetchGitCreds(appID) + if err != nil { + return nil, nil, fmt.Errorf("fetch git creds: %w", err) + } + + creds := &git.Credentials{ + RepoURL: app.Repository.URL, + Branch: app.Repository.Branch, + } + if identity != nil { + creds.Username = identity.User + creds.Token = identity.Password + if creds.Username == "" { + creds.Username = "x-access-token" + } + logging.Ok("git identity: %s", identity.Name) + } + + return creds, hubClient, nil +} + +func fetchAndWriteAnalysis(hubClient *hub.Client, appIDStr string, workDir string) error { + appID, _ := hub.ParseAppID(appIDStr) + insights, err := hubClient.FetchAnalysis(appID) + if err != nil { + return err + } + if len(insights) == 0 { + logging.Info("no analysis results for app %s", appIDStr) + return nil + } + + analysisDir := filepath.Join(workDir, ".konveyor") + if err := os.MkdirAll(analysisDir, 0o755); err != nil { + return fmt.Errorf("create .konveyor dir: %w", err) + } + + data, err := json.MarshalIndent(insights, "", " ") + if err != nil { + return fmt.Errorf("marshal analysis: %w", err) + } + + analysisPath := filepath.Join(analysisDir, "analysis.json") + if err := os.WriteFile(analysisPath, data, 0o644); err != nil { + return fmt.Errorf("write analysis: %w", err) + } + + logging.Ok("wrote %d analysis insights to %s", len(insights), analysisPath) + return nil +} + var excludeDirs = []string{"graphify-out/", ".konveyor/", "target/", ".git/"} func changedFileCount(repo *gogit.Repository) int { diff --git a/harness/go.mod b/harness/go.mod index 9e1e21e..e9f703a 100644 --- a/harness/go.mod +++ b/harness/go.mod @@ -6,6 +6,7 @@ require ( github.com/fsnotify/fsnotify v1.10.1 github.com/go-git/go-git/v5 v5.19.1 github.com/gorilla/websocket v1.5.3 + github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703 github.com/spf13/cobra v1.10.2 go.uber.org/zap v1.27.1 ) @@ -19,20 +20,23 @@ require ( github.com/emirpasic/gods v1.18.1 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jortel/go-utils v0.1.5 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/pjbgf/sha1cd v0.6.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect go.uber.org/multierr v1.10.0 // indirect - golang.org/x/crypto v0.52.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/crypto v0.54.0 // indirect + golang.org/x/net v0.57.0 // indirect golang.org/x/sys v0.47.0 // indirect - golang.org/x/term v0.45.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/harness/go.sum b/harness/go.sum index a4a6dcb..13125e2 100644 --- a/harness/go.sum +++ b/harness/go.sum @@ -33,6 +33,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -43,10 +45,14 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jortel/go-utils v0.1.5 h1:fBkvlojnbrx5rqJt+1fx9dlGV5NjjnCyGgWyXsQ12Ws= +github.com/jortel/go-utils v0.1.5/go.mod h1:R9W67T6eTYPcofmSuvvv3lOcNuF4zh7ZoBfaoTA9zss= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703 h1:Gzjt6DhN/qj276MkBLTc88vMV5q4Nvj6tWWu0OG/gHU= +github.com/konveyor/tackle2-hub/shared v0.0.0-20260718010151-0438af807703/go.mod h1:DBpy7S/qWyOmxK9QaqiHUHKkQ3gMRXXnEY8/FsA6fgA= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= @@ -68,6 +74,8 @@ github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -89,13 +97,13 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= -golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= +golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw= +golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -108,8 +116,8 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs= +golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/harness/internal/config/config.go b/harness/internal/config/config.go index 282e87c..9eb03d6 100644 --- a/harness/internal/config/config.go +++ b/harness/internal/config/config.go @@ -17,6 +17,10 @@ type Config struct { APIKey string MaxTurns int MaxFixIterations int + + HubBaseURL string + HubToken string + AppID string } func LoadFromEnv() *Config { @@ -42,5 +46,15 @@ func LoadFromEnv() *Config { cfg.MaxFixIterations = n } + cfg.HubBaseURL = os.Getenv("HUB_BASE_URL") + if cfg.HubBaseURL == "" { + return nil + } + cfg.HubToken = os.Getenv("HUB_TOKEN") + cfg.AppID = os.Getenv("APP_ID") + if cfg.AppID == "" { + return nil + } + return cfg } diff --git a/harness/internal/config/config_test.go b/harness/internal/config/config_test.go index c3c3533..ed363dd 100644 --- a/harness/internal/config/config_test.go +++ b/harness/internal/config/config_test.go @@ -14,15 +14,25 @@ func clearKonveyorEnv(t *testing.T) { "KONVEYOR_MODEL_PRIMARY_API_KEY", "KONVEYOR_PARAM_MAX_TURNS", "KONVEYOR_PARAM_MAX_FIX_ITERATIONS", + "HUB_BASE_URL", + "HUB_TOKEN", + "APP_ID", } { t.Setenv(k, "") os.Unsetenv(k) } } +func setHubEnv(t *testing.T) { + t.Helper() + t.Setenv("HUB_BASE_URL", "https://hub.example.com") + t.Setenv("APP_ID", "42") +} + func TestLoadFromEnv(t *testing.T) { t.Run("returns config from env", func(t *testing.T) { clearKonveyorEnv(t) + setHubEnv(t) t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "claude-sonnet-4-5") t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "anthropic") t.Setenv("KONVEYOR_MODEL_PRIMARY_ENDPOINT", "https://api.anthropic.com") @@ -59,6 +69,7 @@ func TestLoadFromEnv(t *testing.T) { }) t.Run("reads optional param overrides", func(t *testing.T) { + setHubEnv(t) t.Setenv("KONVEYOR_MODEL_PRIMARY_MODEL", "gemini-2.5-pro") t.Setenv("KONVEYOR_MODEL_PRIMARY_PROVIDER", "gcp_vertex_ai") t.Setenv("KONVEYOR_PARAM_MAX_TURNS", "500") diff --git a/harness/internal/git/credentials.go b/harness/internal/git/credentials.go index c7547f5..b28f6b0 100644 --- a/harness/internal/git/credentials.go +++ b/harness/internal/git/credentials.go @@ -1,9 +1,6 @@ package git import ( - "fmt" - "os" - "github.com/go-git/go-git/v5/plumbing/transport/http" ) @@ -14,42 +11,9 @@ type Credentials struct { Branch string } -func ReadFromEnv() (*Credentials, error) { - repoURL := os.Getenv("GIT_REPO_URL") - if repoURL == "" { - return nil, nil - } - - username := os.Getenv("GIT_USERNAME") - if username == "" { - username = "x-access-token" - } - - token := os.Getenv("GIT_TOKEN") - if token == "" { - return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TOKEN is missing") - } - - branch := os.Getenv("GIT_TARGET_BRANCH") - if branch == "" { - return nil, fmt.Errorf("GIT_REPO_URL is set but GIT_TARGET_BRANCH is missing") - } - - return &Credentials{ - Username: username, - Token: token, - RepoURL: repoURL, - Branch: branch, - }, nil -} - func (c *Credentials) Auth() *http.BasicAuth { return &http.BasicAuth{ Username: c.Username, Password: c.Token, } } - -func ClearEnvCredentials() { - os.Unsetenv("GIT_TOKEN") -} diff --git a/harness/internal/git/git.go b/harness/internal/git/git.go index 02600af..6955320 100644 --- a/harness/internal/git/git.go +++ b/harness/internal/git/git.go @@ -73,12 +73,18 @@ func StripCredentials(repo *gogit.Repository) error { } func CheckoutBranch(repo *gogit.Repository, branch string) error { + localRef := plumbing.NewBranchReferenceName(branch) + + // Already on the requested branch — nothing to do. + if head, err := repo.Head(); err == nil && head.Name() == localRef { + return nil + } + wt, err := repo.Worktree() if err != nil { return fmt.Errorf("get worktree: %w", err) } - localRef := plumbing.NewBranchReferenceName(branch) remoteRef := plumbing.NewRemoteReferenceName("origin", branch) // If the remote tracking branch exists, create local branch from it. @@ -96,7 +102,6 @@ func CheckoutBranch(repo *gogit.Repository, branch string) error { Create: true, }) if err != nil { - // Branch might already exist locally. err = wt.Checkout(&gogit.CheckoutOptions{ Branch: localRef, }) diff --git a/harness/internal/git/git_test.go b/harness/internal/git/git_test.go index 184b58d..e45d7cd 100644 --- a/harness/internal/git/git_test.go +++ b/harness/internal/git/git_test.go @@ -216,73 +216,6 @@ func TestFullLifecycle(t *testing.T) { } } -func TestReadFromEnv(t *testing.T) { - t.Run("no env returns nil", func(t *testing.T) { - os.Unsetenv("GIT_REPO_URL") - os.Unsetenv("GIT_TOKEN") - os.Unsetenv("GIT_USERNAME") - os.Unsetenv("GIT_TARGET_BRANCH") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred != nil { - t.Error("expected nil credentials when GIT_REPO_URL is unset") - } - }) - - t.Run("repo url without token errors", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "") - os.Unsetenv("GIT_TOKEN") - - _, err := ReadFromEnv() - if err == nil { - t.Error("expected error when GIT_TOKEN is missing") - } - }) - - t.Run("full env", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "ghp_test123") - t.Setenv("GIT_USERNAME", "myuser") - t.Setenv("GIT_TARGET_BRANCH", "my-branch") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred.Username != "myuser" { - t.Errorf("Username = %q, want myuser", cred.Username) - } - if cred.Token != "ghp_test123" { - t.Errorf("Token = %q, want ghp_test123", cred.Token) - } - if cred.RepoURL != "https://github.com/org/repo.git" { - t.Errorf("RepoURL = %q", cred.RepoURL) - } - if cred.Branch != "my-branch" { - t.Errorf("Branch = %q, want my-branch", cred.Branch) - } - }) - - t.Run("default username", func(t *testing.T) { - t.Setenv("GIT_REPO_URL", "https://github.com/org/repo.git") - t.Setenv("GIT_TOKEN", "ghp_test123") - os.Unsetenv("GIT_USERNAME") - t.Setenv("GIT_TARGET_BRANCH", "br") - - cred, err := ReadFromEnv() - if err != nil { - t.Fatalf("unexpected error: %v", err) - } - if cred.Username != "x-access-token" { - t.Errorf("Username = %q, want x-access-token", cred.Username) - } - }) -} - func TestPushWithoutCredsToStrippedRemoteFails(t *testing.T) { remoteDir, _ := setupBareRemote(t) seedBareRepo(t, remoteDir) diff --git a/harness/internal/hub/client.go b/harness/internal/hub/client.go new file mode 100644 index 0000000..33b2c9b --- /dev/null +++ b/harness/internal/hub/client.go @@ -0,0 +1,73 @@ +package hub + +import ( + "os" + "strconv" + + "github.com/konveyor/tackle2-hub/shared/api" + "github.com/konveyor/tackle2-hub/shared/binding" + "github.com/konveyor/tackle2-hub/shared/binding/auth" +) + +// Client wraps the Hub RichClient for the subset of operations the harness needs. +type Client struct { + rich *binding.RichClient +} + +// NewClient creates a Hub API client with bearer token auth. +func NewClient(baseURL, token string) *Client { + rc := binding.New(baseURL) + rc.Client.Use(auth.NewBearer(token)) + return &Client{rich: rc} +} + +// FetchApp retrieves application metadata including the source repository. +func (c *Client) FetchApp(appID uint) (*api.Application, error) { + return c.rich.Application.Get(appID) +} + +// FetchGitCreds retrieves git credentials for an application's source repo. +// Tries direct identity (app-specific, role=source) first, then falls back +// to an indirect identity (global default, kind=source). +func (c *Client) FetchGitCreds(appID uint) (*api.Identity, error) { + selected := c.rich.Application.Select(appID) + identity, found, err := selected.Identity.Decrypted().Search(). + Direct("source"). + Indirect("source"). + Find() + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + return identity, nil +} + +// FetchAnalysis retrieves analysis insights for an application. +// Returns an empty slice (not an error) if no analysis exists. +func (c *Client) FetchAnalysis(appID uint) ([]api.Insight, error) { + selected := c.rich.Application.Select(appID) + insights, err := selected.Analysis.ListInsights() + if err != nil { + return nil, nil + } + return insights, nil +} + +// ParseAppID converts the APP_ID string to uint. +func ParseAppID(s string) (uint, error) { + n, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0, err + } + return uint(n), nil +} + +// ClearEnv removes Hub credentials from the process environment +// so child processes (goose) cannot access them. +func ClearEnv() { + os.Unsetenv("HUB_BASE_URL") + os.Unsetenv("HUB_TOKEN") + os.Unsetenv("APP_ID") +} From d5ef522c01cd7f4b8d36968f011c47fc9d30992d Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Thu, 23 Jul 2026 12:06:01 -0400 Subject: [PATCH 7/8] Consolidate agent images: base + language model Replace per-stage images (plan, execute, verify) with per-language images (java, go, csharp, nodejs). Skills come via SkillCards at runtime. Closes #40. Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- Makefile | 45 ++++++++++--------- hack/harness-test/playbook-resources.yaml | 6 +-- hack/harness-test/setup.sh | 5 +-- images/README.md | 18 ++++---- images/agent-base/Containerfile | 15 ++++++- images/agent-csharp/Containerfile | 17 +++++++ images/agent-execute-java/Containerfile | 4 -- images/agent-go/Containerfile | 17 +++++++ .../Containerfile | 5 +-- images/agent-nodejs/Containerfile | 15 +++++++ images/agent-plan/Containerfile | 25 ----------- images/agent-verify-java/Containerfile | 4 -- 12 files changed, 101 insertions(+), 75 deletions(-) create mode 100644 images/agent-csharp/Containerfile delete mode 100644 images/agent-execute-java/Containerfile create mode 100644 images/agent-go/Containerfile rename images/{agent-java-base => agent-java}/Containerfile (69%) create mode 100644 images/agent-nodejs/Containerfile delete mode 100644 images/agent-plan/Containerfile delete mode 100644 images/agent-verify-java/Containerfile diff --git a/Makefile b/Makefile index 4379577..0bbce6b 100644 --- a/Makefile +++ b/Makefile @@ -101,10 +101,10 @@ lint-config: golangci-lint ## Verify golangci-lint linter configuration CONTROLLER_AGENT_IMG ?= quay.io/konveyor/agentic-controller-agent:latest AGENT_BASE_IMG ?= quay.io/konveyor/agent-base:latest -AGENT_JAVA_BASE_IMG ?= quay.io/konveyor/agent-java-base:latest -AGENT_PLAN_IMG ?= quay.io/konveyor/agent-plan:latest -AGENT_EXECUTE_IMG ?= quay.io/konveyor/agent-execute-java:latest -AGENT_VERIFY_IMG ?= quay.io/konveyor/agent-verify-java:latest +AGENT_JAVA_IMG ?= quay.io/konveyor/agent-java:latest +AGENT_GO_IMG ?= quay.io/konveyor/agent-go:latest +AGENT_CSHARP_IMG ?= quay.io/konveyor/agent-csharp:latest +AGENT_NODEJS_IMG ?= quay.io/konveyor/agent-nodejs:latest .PHONY: build build: manifests generate fmt vet ## Build manager binary. @@ -119,34 +119,35 @@ controller-agent-push: controller-agent-build ## Build and push the controller's $(CONTAINER_TOOL) push $(CONTROLLER_AGENT_IMG) .PHONY: agent-base-build -agent-base-build: ## Build the base agent image (goose + git + harness binary). +agent-base-build: ## Build the base agent image (goose + git + Python + graphify + harness binary). $(CONTAINER_TOOL) build -t $(AGENT_BASE_IMG) -f images/agent-base/Containerfile . -.PHONY: agent-java-base-build -agent-java-base-build: agent-base-build ## Build the shared Java base image (JDK 21 + Maven). - $(CONTAINER_TOOL) build -t $(AGENT_JAVA_BASE_IMG) -f images/agent-java-base/Containerfile . +.PHONY: agent-java-build +agent-java-build: agent-base-build ## Build the Java agent image (JDK 21 + Maven). + $(CONTAINER_TOOL) build -t $(AGENT_JAVA_IMG) -f images/agent-java/Containerfile . -.PHONY: agent-plan-build -agent-plan-build: agent-base-build ## Build the plan stage agent image. - $(CONTAINER_TOOL) build -t $(AGENT_PLAN_IMG) -f images/agent-plan/Containerfile . +.PHONY: agent-go-build +agent-go-build: agent-base-build ## Build the Go agent image. + $(CONTAINER_TOOL) build -t $(AGENT_GO_IMG) -f images/agent-go/Containerfile . -.PHONY: agent-execute-java-build -agent-execute-java-build: agent-java-base-build ## Build the Java execute stage agent image. - $(CONTAINER_TOOL) build -t $(AGENT_EXECUTE_IMG) -f images/agent-execute-java/Containerfile . +.PHONY: agent-csharp-build +agent-csharp-build: agent-base-build ## Build the C# agent image (.NET SDK). + $(CONTAINER_TOOL) build -t $(AGENT_CSHARP_IMG) -f images/agent-csharp/Containerfile . -.PHONY: agent-verify-java-build -agent-verify-java-build: agent-java-base-build ## Build the Java verify stage agent image. - $(CONTAINER_TOOL) build -t $(AGENT_VERIFY_IMG) -f images/agent-verify-java/Containerfile . +.PHONY: agent-nodejs-build +agent-nodejs-build: agent-base-build ## Build the Node.js agent image. + $(CONTAINER_TOOL) build -t $(AGENT_NODEJS_IMG) -f images/agent-nodejs/Containerfile . .PHONY: agent-images-build -agent-images-build: agent-plan-build agent-execute-java-build agent-verify-java-build ## Build all stage agent images. +agent-images-build: agent-java-build agent-go-build agent-csharp-build agent-nodejs-build ## Build all agent images. .PHONY: agent-images-push -agent-images-push: agent-images-build ## Build and push all stage agent images. +agent-images-push: agent-images-build ## Build and push all agent images. $(CONTAINER_TOOL) push $(AGENT_BASE_IMG) - $(CONTAINER_TOOL) push $(AGENT_PLAN_IMG) - $(CONTAINER_TOOL) push $(AGENT_EXECUTE_IMG) - $(CONTAINER_TOOL) push $(AGENT_VERIFY_IMG) + $(CONTAINER_TOOL) push $(AGENT_JAVA_IMG) + $(CONTAINER_TOOL) push $(AGENT_GO_IMG) + $(CONTAINER_TOOL) push $(AGENT_CSHARP_IMG) + $(CONTAINER_TOOL) push $(AGENT_NODEJS_IMG) .PHONY: run run: manifests generate fmt vet ## Run a controller from your host. diff --git a/hack/harness-test/playbook-resources.yaml b/hack/harness-test/playbook-resources.yaml index 13d15bc..0859412 100644 --- a/hack/harness-test/playbook-resources.yaml +++ b/hack/harness-test/playbook-resources.yaml @@ -80,7 +80,7 @@ kind: Agent metadata: name: migration-plan-agent spec: - image: quay.io/konveyor/agent-plan:dev + image: quay.io/konveyor/agent-java:dev providers: - ref: gcp-vertex-ai skillCards: @@ -97,7 +97,7 @@ kind: Agent metadata: name: migration-execute-agent spec: - image: quay.io/konveyor/agent-execute-java:dev + image: quay.io/konveyor/agent-java:dev providers: - ref: gcp-vertex-ai skillCards: @@ -114,7 +114,7 @@ kind: Agent metadata: name: migration-verify-agent spec: - image: quay.io/konveyor/agent-verify-java:dev + image: quay.io/konveyor/agent-java:dev providers: - ref: gcp-vertex-ai skillCards: diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index 6c680dd..93462b3 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -63,9 +63,8 @@ echo "" echo "=== Loading images into Kind ===" IMAGES=( - "quay.io/konveyor/agent-plan" - "quay.io/konveyor/agent-execute-java" - "quay.io/konveyor/agent-verify-java" + "quay.io/konveyor/agent-base" + "quay.io/konveyor/agent-java" ) for IMG in "${IMAGES[@]}"; do diff --git a/images/README.md b/images/README.md index 07665eb..0915fe4 100644 --- a/images/README.md +++ b/images/README.md @@ -16,20 +16,20 @@ make controller-agent-build # build locally make controller-agent-push CONTAINER_TOOL=podman # push to quay ``` -## Stage agent images +## Agent images -Production agent image hierarchy for migration stages. Skills are -mounted at runtime via SkillCards, not baked into images. +Production agent image hierarchy. Skills are mounted at runtime via +SkillCards, not baked into images. ```text -agent-base UBI 10 + goose CLI + git + harness binary -├── agent-plan + Python 3, graphify -├── agent-java-base + JDK 21, Maven -│ ├── agent-execute-java (inherits java-base) -│ └── agent-verify-java (inherits java-base) +agent-base UBI 10 + goose CLI + git + Python 3 + graphify + harness binary +├── agent-java + JDK 21, Maven +├── agent-go + Go toolchain +├── agent-csharp + .NET SDK +└── agent-nodejs + Node.js, npm ``` ```bash -make agent-images-build # build all stage images +make agent-images-build # build all agent images make agent-images-push CONTAINER_TOOL=podman # push to quay ``` diff --git a/images/agent-base/Containerfile b/images/agent-base/Containerfile index 8813c24..0b09f42 100644 --- a/images/agent-base/Containerfile +++ b/images/agent-base/Containerfile @@ -1,5 +1,5 @@ -# agent-base: Foundation image with goose CLI, git, and the migration harness binary. -# All stage images extend this base. +# agent-base: Foundation image with goose CLI, git, Python, graphify, +# and the migration harness binary. All language images extend this. # # Build context must be the repository root: # podman build -t agent-base -f images/agent-base/Containerfile . @@ -23,8 +23,19 @@ RUN dnf install -y \ ca-certificates \ bzip2 \ tar \ + python3 \ + python3-pip \ + python3-devel \ + gcc \ + gcc-c++ \ + libffi-devel \ + pkg-config \ && dnf clean all +RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 + +ENV PYTHONUNBUFFERED=1 + # Install Goose (Block's LLM orchestrator) RUN ARCH=$(uname -m) \ && if [ "$ARCH" = "x86_64" ]; then GOOSE_ARCH="x86_64-unknown-linux-gnu"; \ diff --git a/images/agent-csharp/Containerfile b/images/agent-csharp/Containerfile new file mode 100644 index 0000000..e0f9fe5 --- /dev/null +++ b/images/agent-csharp/Containerfile @@ -0,0 +1,17 @@ +# agent-csharp: C# agent image. Adds .NET SDK on top of agent-base. +# +# Build context must be the repository root: +# podman build -t agent-csharp -f images/agent-csharp/Containerfile . + +FROM quay.io/konveyor/agent-base:latest AS base + +USER root + +RUN dnf install -y \ + dotnet-sdk-8.0 \ + && dnf clean all + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-execute-java/Containerfile b/images/agent-execute-java/Containerfile deleted file mode 100644 index 02bfd2f..0000000 --- a/images/agent-execute-java/Containerfile +++ /dev/null @@ -1,4 +0,0 @@ -# agent-execute-java: Java execute stage toolchain image. Skill content comes -# from SkillCards mounted at runtime. - -FROM quay.io/konveyor/agent-java-base:latest diff --git a/images/agent-go/Containerfile b/images/agent-go/Containerfile new file mode 100644 index 0000000..7a59a9a --- /dev/null +++ b/images/agent-go/Containerfile @@ -0,0 +1,17 @@ +# agent-go: Go agent image. Adds Go toolchain on top of agent-base. +# +# Build context must be the repository root: +# podman build -t agent-go -f images/agent-go/Containerfile . + +FROM quay.io/konveyor/agent-base:latest AS base + +USER root + +RUN dnf install -y \ + golang \ + && dnf clean all + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-java-base/Containerfile b/images/agent-java/Containerfile similarity index 69% rename from images/agent-java-base/Containerfile rename to images/agent-java/Containerfile index ab35fd5..3529dc9 100644 --- a/images/agent-java-base/Containerfile +++ b/images/agent-java/Containerfile @@ -1,8 +1,7 @@ -# agent-java-base: Shared base for Java migration stages (execute, verify). -# Adds JDK 21 and Maven on top of agent-base. +# agent-java: Java agent image. Adds JDK 21 and Maven on top of agent-base. # # Build context must be the repository root: -# podman build -t agent-java-base -f images/agent-java-base/Containerfile . +# podman build -t agent-java -f images/agent-java/Containerfile . FROM quay.io/konveyor/agent-base:latest AS base diff --git a/images/agent-nodejs/Containerfile b/images/agent-nodejs/Containerfile new file mode 100644 index 0000000..a97c95a --- /dev/null +++ b/images/agent-nodejs/Containerfile @@ -0,0 +1,15 @@ +# agent-nodejs: Node.js agent image. Adds Node.js and npm on top of agent-base. +# +# Build context must be the repository root: +# podman build -t agent-nodejs -f images/agent-nodejs/Containerfile . + +FROM quay.io/konveyor/agent-base:latest AS base + +USER root + +RUN dnf install -y nodejs npm && dnf clean all + +USER harness + +ENTRYPOINT ["migration-harness"] +CMD ["run"] diff --git a/images/agent-plan/Containerfile b/images/agent-plan/Containerfile deleted file mode 100644 index a04db73..0000000 --- a/images/agent-plan/Containerfile +++ /dev/null @@ -1,25 +0,0 @@ -# agent-plan: Plan stage toolchain image. Adds graphify for code graph -# generation. Skill content comes from SkillCards mounted at runtime. - -FROM quay.io/konveyor/agent-base:latest AS base - -USER root - -RUN dnf install -y \ - python3 \ - python3-pip \ - python3-devel \ - gcc \ - gcc-c++ \ - libffi-devel \ - pkg-config \ - && dnf clean all - -RUN python3 -m pip install --no-cache-dir graphifyy==0.7.17 - -ENV PYTHONUNBUFFERED=1 - -USER harness - -ENTRYPOINT ["migration-harness"] -CMD ["run"] diff --git a/images/agent-verify-java/Containerfile b/images/agent-verify-java/Containerfile deleted file mode 100644 index 81263bd..0000000 --- a/images/agent-verify-java/Containerfile +++ /dev/null @@ -1,4 +0,0 @@ -# agent-verify-java: Java verify stage toolchain image. Skill content comes -# from SkillCards mounted at runtime. - -FROM quay.io/konveyor/agent-java-base:latest From 0aaeefea4abf299a136da3f213b6a576d04438d1 Mon Sep 17 00:00:00 2001 From: Savitha Raghunathan <saveetha13@gmail.com> Date: Thu, 23 Jul 2026 14:21:43 -0400 Subject: [PATCH 8/8] Fix 6 bugs from CodeRabbit review - Path traversal in Clone guard: use filepath.Abs + filepath.Rel instead of strings.HasPrefix; check os.RemoveAll error - docker image exists is not a Docker CLI command: use image inspect - Empty staging commit: track whether anything was staged, return early if not - Missing .kts in watcher allowlist: Kotlin DSL Gradle files ignored - FetchAnalysis swallows errors: return nil, err instead of nil, nil - os.Exit(1) skips deferred cleanup: return error so defers run Assisted-By: Claude Code <noreply@anthropic.com> Signed-off-by: Savitha Raghunathan <saveetha13@gmail.com> --- hack/harness-test/setup.sh | 2 +- harness/cmd/migration-harness/main.go | 2 +- harness/internal/git/git.go | 22 ++++++++++++++++++++-- harness/internal/hub/client.go | 2 +- harness/internal/watcher/patterns.go | 2 +- 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/hack/harness-test/setup.sh b/hack/harness-test/setup.sh index 93462b3..60221b7 100755 --- a/hack/harness-test/setup.sh +++ b/hack/harness-test/setup.sh @@ -80,7 +80,7 @@ for IMG in "${IMAGES[@]}"; do done for SKILL in "${SKILL_DIRS[@]}"; do - if $CONTAINER_TOOL image exists "${SKILL_IMAGE}:${SKILL}" 2>/dev/null; then + if $CONTAINER_TOOL image inspect "${SKILL_IMAGE}:${SKILL}" >/dev/null 2>&1; then if [ "$CONTAINER_TOOL" = "podman" ]; then $CONTAINER_TOOL save "${SKILL_IMAGE}:${SKILL}" -o /tmp/skill-image.tar KIND_EXPERIMENTAL_PROVIDER=podman kind load image-archive /tmp/skill-image.tar --name "$KIND_CLUSTER" diff --git a/harness/cmd/migration-harness/main.go b/harness/cmd/migration-harness/main.go index 041123a..25e0278 100644 --- a/harness/cmd/migration-harness/main.go +++ b/harness/cmd/migration-harness/main.go @@ -187,7 +187,7 @@ func runStage(cmd *cobra.Command, args []string) error { // 13. Exit if stageFailed { logging.Err("stage failed") - os.Exit(1) + return fmt.Errorf("stage failed") } logging.Ok("stage succeeded") return nil diff --git a/harness/internal/git/git.go b/harness/internal/git/git.go index 6955320..d8cdb3e 100644 --- a/harness/internal/git/git.go +++ b/harness/internal/git/git.go @@ -6,6 +6,7 @@ import ( "fmt" "net/url" "os" + "path/filepath" "strings" "time" @@ -16,12 +17,24 @@ import ( "github.com/konveyor/migration-harness/internal/watcher" ) +func isChildOf(path, root string) bool { + rel, err := filepath.Rel(root, path) + return err == nil && rel != "." && !strings.HasPrefix(rel, "..") +} + func Clone(ctx context.Context, cred *Credentials, destDir string) (*gogit.Repository, error) { + destDir, err := filepath.Abs(destDir) + if err != nil { + return nil, fmt.Errorf("resolve destination: %w", err) + } + if _, err := os.Stat(destDir); err == nil { - if !strings.HasPrefix(destDir, "/workspace") && !strings.HasPrefix(destDir, os.TempDir()) { + if !isChildOf(destDir, "/workspace") && !isChildOf(destDir, os.TempDir()) { return nil, fmt.Errorf("refusing to remove %s: not under /workspace or temp", destDir) } - os.RemoveAll(destDir) + if err := os.RemoveAll(destDir); err != nil { + return nil, fmt.Errorf("remove %s: %w", destDir, err) + } } repo, err := gogit.PlainCloneContext(ctx, destDir, false, &gogit.CloneOptions{ @@ -126,6 +139,7 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { return plumbing.ZeroHash, nil } + staged := false for path, s := range status { if s.Worktree == gogit.Untracked && !watcher.ShouldStageNewFile(path) { continue @@ -133,6 +147,10 @@ func CommitAll(repo *gogit.Repository, message string) (plumbing.Hash, error) { if _, err := wt.Add(path); err != nil { return plumbing.ZeroHash, fmt.Errorf("add %s: %w", path, err) } + staged = true + } + if !staged { + return plumbing.ZeroHash, nil } hash, err := wt.Commit(message, &gogit.CommitOptions{ diff --git a/harness/internal/hub/client.go b/harness/internal/hub/client.go index 33b2c9b..931269f 100644 --- a/harness/internal/hub/client.go +++ b/harness/internal/hub/client.go @@ -50,7 +50,7 @@ func (c *Client) FetchAnalysis(appID uint) ([]api.Insight, error) { selected := c.rich.Application.Select(appID) insights, err := selected.Analysis.ListInsights() if err != nil { - return nil, nil + return nil, err } return insights, nil } diff --git a/harness/internal/watcher/patterns.go b/harness/internal/watcher/patterns.go index 3f7b754..dd8d776 100644 --- a/harness/internal/watcher/patterns.go +++ b/harness/internal/watcher/patterns.go @@ -8,7 +8,7 @@ import ( var sourceExts = map[string]bool{ ".java": true, ".xml": true, ".properties": true, ".md": true, ".json": true, ".yaml": true, ".yml": true, - ".gradle": true, ".kt": true, ".groovy": true, + ".gradle": true, ".kts": true, ".kt": true, ".groovy": true, } var excludeDirs = map[string]bool{