From 85328bd0c827c2c626a4d36422edbed2dd933ab5 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 3 Aug 2026 11:07:42 -0400 Subject: [PATCH 01/10] refactor(cli): collapse SWE catalog rows into one Go entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `af catalog` listed the SWE fleet twice — a root Python node and its Go counterpart — which forced a harness to pick between two rows that ship the same reasoners. Keep only the Go node (installed via the `//go` source selector) and give it the full fleet description. Tighten the pretty-output assertion to `swe-planner-go` (the old `swe-planner` substring matched either row) and add a guard test that pins the invariant: exactly one entry installs from Agent-Field/SWE-AF, its source ends in `//go`, and no entry is named exactly `swe-planner`, so a re-added root entry fails loudly instead of quietly reappearing. Co-Authored-By: Claude Fable 5 --- control-plane/internal/cli/catalog.go | 12 +++--------- control-plane/internal/cli/catalog_test.go | 21 ++++++++++++++++++++- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/control-plane/internal/cli/catalog.go b/control-plane/internal/cli/catalog.go index 36cc6c5f8..5926121d0 100644 --- a/control-plane/internal/cli/catalog.go +++ b/control-plane/internal/cli/catalog.go @@ -16,7 +16,8 @@ import ( // registry search lands. It is seeded from the desktop app's curated list // (desktop/src/shared/catalog.ts) — keep the two in sync when adding nodes. // `name` MUST equal the node's agentfield-package.yaml `name:` (the registry -// key after install), which is often not the repo name (SWE-AF → swe-planner). +// key after install), which is often not the repo name (SWE-AF//go → +// swe-planner-go). type nodeCatalogEntry struct { Name string `json:"name"` Description string `json:"description"` @@ -29,16 +30,9 @@ type nodeCatalogEntry struct { // desktop/src/shared/catalog.ts. Docs point at the public GitHub repo (the // `//` source selector stripped, since the docs live at the repo root). var nodeCatalog = []nodeCatalogEntry{ - { - Name: "swe-planner", - Description: "Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs", - Source: "https://github.com/Agent-Field/SWE-AF", - Docs: "https://github.com/Agent-Field/SWE-AF", - Language: "python", - }, { Name: "swe-planner-go", - Description: "Go port of the SWE fleet: same planning/execution reasoners, one static binary", + Description: "Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs — one static binary", Source: "https://github.com/Agent-Field/SWE-AF//go", Docs: "https://github.com/Agent-Field/SWE-AF", Language: "go", diff --git a/control-plane/internal/cli/catalog_test.go b/control-plane/internal/cli/catalog_test.go index 3baf112d3..99a53f9d1 100644 --- a/control-plane/internal/cli/catalog_test.go +++ b/control-plane/internal/cli/catalog_test.go @@ -3,6 +3,7 @@ package cli import ( "bytes" "encoding/json" + "strings" "testing" "github.com/stretchr/testify/require" @@ -28,7 +29,25 @@ func TestRunCatalogPrettyEndsWithInstallHint(t *testing.T) { require.NoError(t, runCatalog(&stdout, "pretty")) out := stdout.String() require.Contains(t, out, "af install ") - require.Contains(t, out, "swe-planner") + require.Contains(t, out, "swe-planner-go") +} + +// The SWE fleet ships as exactly one catalog row: the Go node installed from +// the `//go` source selector. A re-added root/Python `swe-planner` entry must +// fail here rather than silently reappear in `af catalog`. +func TestCatalogHasSingleGoSWEEntry(t *testing.T) { + var sweEntries []nodeCatalogEntry + for _, e := range nodeCatalog { + require.NotEqual(t, "swe-planner", e.Name, "SWE fleet must be catalogued only as swe-planner-go") + if strings.Contains(e.Source, "Agent-Field/SWE-AF") { + sweEntries = append(sweEntries, e) + } + } + + require.Len(t, sweEntries, 1, "exactly one catalog entry may install from Agent-Field/SWE-AF") + require.Equal(t, "swe-planner-go", sweEntries[0].Name) + require.True(t, strings.HasSuffix(sweEntries[0].Source, "//go"), + "SWE entry source must select the go subdirectory, got %q", sweEntries[0].Source) } func TestRunCatalogRejectsUnknownFormat(t *testing.T) { From 4767e7793fea78ef3ae269355573fee9f7cc96d9 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 3 Aug 2026 11:09:08 -0400 Subject: [PATCH 02/10] refactor(desktop): collapse SWE catalog rows into one Go entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the `af catalog` change: the Install view listed the SWE fleet twice with identical copy, so the two rows were indistinguishable to a user. Keep only the Go node sourced from `//go`, with the same description wording the CLI catalog now uses. Add a test pinning the invariant — `swe-planner-go` is present, its source ends in `//go`, exactly one entry installs from Agent-Field/SWE-AF, and no entry is named exactly `swe-planner`. Co-Authored-By: Claude Fable 5 --- desktop/src/main/agentfield.test.ts | 12 ++++++++++++ desktop/src/shared/catalog.ts | 11 ++--------- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/desktop/src/main/agentfield.test.ts b/desktop/src/main/agentfield.test.ts index a1248360e..f8c0340a9 100644 --- a/desktop/src/main/agentfield.test.ts +++ b/desktop/src/main/agentfield.test.ts @@ -562,6 +562,18 @@ describe('install catalog', () => { expect(catalogEntry(CATALOG[0].name)).toEqual(CATALOG[0]) expect(catalogEntry('definitely-not-real')).toBeUndefined() }) + + // The SWE fleet is offered as a single install: the Go node, sourced from + // the `//go` subdirectory. A re-added root/Python `swe-planner` entry must + // fail here rather than quietly reappear in the Install view. + it('offers the SWE fleet only as the go-sourced swe-planner-go entry', () => { + const goEntry = CATALOG.find((e) => e.name === 'swe-planner-go') + expect(goEntry).toBeDefined() + expect(goEntry?.source.endsWith('//go')).toBe(true) + + expect(CATALOG.find((e) => e.name === 'swe-planner')).toBeUndefined() + expect(CATALOG.filter((e) => e.source.includes('Agent-Field/SWE-AF'))).toHaveLength(1) + }) }) describe('installCommand', () => { diff --git a/desktop/src/shared/catalog.ts b/desktop/src/shared/catalog.ts index 8badbe75c..4eee80000 100644 --- a/desktop/src/shared/catalog.ts +++ b/desktop/src/shared/catalog.ts @@ -14,19 +14,12 @@ import type { CatalogEntry } from './types' // ports living beside their Python originals are installed). When adding an // entry, `name` MUST equal the manifest's `name:` (the registry key after // install — how the app detects installed state), which is often NOT the -// repo name (SWE-AF → swe-planner). +// repo name (SWE-AF//go → swe-planner-go). export const CATALOG: CatalogEntry[] = [ - { - name: 'swe-planner', - description: - 'Software factory — turn any issue into a production-ready pull request, end to end', - source: 'https://github.com/Agent-Field/SWE-AF', - language: 'python' - }, { name: 'swe-planner-go', description: - 'Software factory — turn any issue into a production-ready pull request, end to end', + 'Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs — one static binary', source: 'https://github.com/Agent-Field/SWE-AF//go', language: 'go' }, From c9a93d931bdadd6932ce10c7940c1f7249682d96 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 3 Aug 2026 11:14:02 -0400 Subject: [PATCH 03/10] docs: point SWE examples at the go-sourced swe-planner-go node MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog now offers the SWE fleet as a single Go node, so the docs that still told users to install the bare repo root and call `swe-planner` were advertising a node the catalog no longer lists. Update the README quickstart to `af install …/SWE-AF//go` plus `af run`/`af call swe-planner-go`, and switch the MCP example flow and the agentfield-use skill examples to the same node id. The `--path go` examples in installing-agent-nodes.md stay as they are — they document the subdirectory selector itself; only the surrounding framing is reworded so the Go node reads as the advertised install rather than a port of the root node. The skill edit is applied identically to the embedded copy under internal/skillkit/skill_data so the two stay byte-identical. Co-Authored-By: Claude Fable 5 --- README.md | 6 +++--- .../internal/skillkit/skill_data/agentfield-use/SKILL.md | 6 +++--- docs/installing-agent-nodes.md | 5 +++-- docs/mcp-integration.md | 6 +++--- skills/agentfield-use/SKILL.md | 6 +++--- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 475de91cd..99a7fa6c3 100644 --- a/README.md +++ b/README.md @@ -411,13 +411,13 @@ Two examples already run at this load. The [deep-research engine](https://agentf Each of these is a real, installable agent node. With a control plane running, drop any of them into your setup with a single command — `af install ` clones the repo, isolates its dependencies, and registers the node. You're prompted once for shared secrets like `OPENROUTER_API_KEY` (stored encrypted and reused across every node), then the node's reasoners are callable over REST or with `af call`: ```bash -af install https://github.com/Agent-Field/SWE-AF # autonomous engineering team → node: swe-planner +af install https://github.com/Agent-Field/SWE-AF//go # autonomous engineering team → node: swe-planner-go af install https://github.com/Agent-Field/sec-af # security auditor → node: sec-af af install https://github.com/Agent-Field/cloudsecurity-af # cloud / IaC security scanner → node: cloudsecurity af install https://github.com/Agent-Field/pr-af # agentic code review → node: pr-af -af run swe-planner # start a node (prompts once for required secrets) -af call swe-planner.build --in '{"goal": "Add JWT auth", "repo_url": "https://github.com/user/my-repo"}' +af run swe-planner-go # start a node (prompts once for required secrets) +af call swe-planner-go.build --in '{"goal": "Add JWT auth", "repo_url": "https://github.com/user/my-repo"}' ``` Full walkthrough — authoring, installing, and configuring nodes: [Installing agent nodes →](docs/installing-agent-nodes.md). diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index fd4d2c057..3d2b6794b 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -1,7 +1,7 @@ --- name: agentfield-use version: 0.4.0 -description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." +description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner-go, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." --- # Using AgentField agents @@ -135,7 +135,7 @@ Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. **Async — the default for real work.** Returns `202` immediately: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "add rate limiting to the API"}}' # -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} @@ -145,7 +145,7 @@ curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ `result` directly): ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner-go.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "..."}}' ``` diff --git a/docs/installing-agent-nodes.md b/docs/installing-agent-nodes.md index f15d4ed18..ce844eb3c 100644 --- a/docs/installing-agent-nodes.md +++ b/docs/installing-agent-nodes.md @@ -223,8 +223,9 @@ dependencies first (in dependency order) before the node itself. By default `af install ` looks for the `agentfield-package.yaml` at the root of the source (a git repo or a local directory). When a single repository ships -more than one installable node — for example a Python node at the root and a Go -port under `go/` — use `--path` to select the subdirectory to install: +more than one installable node — for example SWE-AF, whose advertised install is +the Go node under `go/`, alongside a Python node that also lives at the repo +root — use `--path` to select the subdirectory to install: ```bash # Install the node whose manifest lives at go/agentfield-package.yaml diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index 02fbfde3a..f838ef1dd 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -48,10 +48,10 @@ to them. ## Example flow -1. `discover_agents` → find `swe-planner` and its `build` reasoner. -2. `get_reasoner_schema` `{ node: "swe-planner", reasoner: "build" }` → learn the +1. `discover_agents` → find `swe-planner-go` and its `build` reasoner. +2. `get_reasoner_schema` `{ node: "swe-planner-go", reasoner: "build" }` → learn the input shape. -3. `execute_reasoner` `{ target: "swe-planner.build", input: { goal: "Add JWT auth" } }` +3. `execute_reasoner` `{ target: "swe-planner-go.build", input: { goal: "Add JWT auth" } }` → `{ run_id: "run_…", status: "accepted" }`. 4. `wait_run` `{ run_id: "run_…", timeout_seconds: 120 }` → block until the run finishes (or `timed_out: true`), then read `result`. diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index fd4d2c057..3d2b6794b 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -1,7 +1,7 @@ --- name: agentfield-use version: 0.4.0 -description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." +description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner-go, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." --- # Using AgentField agents @@ -135,7 +135,7 @@ Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. **Async — the default for real work.** Returns `202` immediately: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "add rate limiting to the API"}}' # -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} @@ -145,7 +145,7 @@ curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ `result` directly): ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner-go.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "..."}}' ``` From 67f0b2e338d4d7ae5e8b0f7a88b76b2a8c5eba42 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Mon, 3 Aug 2026 11:44:56 -0400 Subject: [PATCH 04/10] refactor(desktop): keep the app's own wording for the SWE entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog collapse rewrote this card's copy to match the CLI catalog's phrasing, which reads out of place next to the other entries in this file. Restore the original line — it describes the surviving Go node just as accurately. Co-Authored-By: Claude Fable 5 --- desktop/src/shared/catalog.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktop/src/shared/catalog.ts b/desktop/src/shared/catalog.ts index 4eee80000..c2c7c5550 100644 --- a/desktop/src/shared/catalog.ts +++ b/desktop/src/shared/catalog.ts @@ -19,7 +19,7 @@ export const CATALOG: CatalogEntry[] = [ { name: 'swe-planner-go', description: - 'Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs — one static binary', + 'Software factory — turn any issue into a production-ready pull request, end to end', source: 'https://github.com/Agent-Field/SWE-AF//go', language: 'go' }, From d26a02f23979c8bedc2cebd1d4deac2a9e783cde Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 10:08:14 -0400 Subject: [PATCH 05/10] fix(packages): record the //subdir selector in the installed source MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A subdirectory can reach the installer two ways: the `//subdir` selector on the URL, or the --path flag. The install API takes the second route — it splits the selector off the URL and passes it as an option — so info.URL arrives bare and the registry records the REPO ROOT as the source. The next update resolves that bare source and installs whatever manifest lives at the repo root, which is a different package than the one installed. For a repo shipping a Python root and a Go port side by side, updating the Go node silently replaces it with the Python one. Put the selector back when it came from the flag, so the recorded source round-trips through ParseGitURL. Co-Authored-By: Claude Opus 5 (1M context) --- control-plane/internal/packages/git.go | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/control-plane/internal/packages/git.go b/control-plane/internal/packages/git.go index 915715158..814437625 100644 --- a/control-plane/internal/packages/git.go +++ b/control-plane/internal/packages/git.go @@ -392,6 +392,24 @@ func (gi *GitInstaller) parsePackageMetadata(packagePath string) (*PackageMetada } // updateRegistryWithGit updates the installation registry with Git source info +// appendSubdirSelector rewrites "https://host/owner/repo[@ref]" into +// "https://host/owner/repo//subdir[@ref]" so a recorded source round-trips +// through ParseGitURL. It is needed whenever the subdirectory arrived by the +// --path flag (or the install API, which splits the selector off before +// calling): without it the registry records the REPO ROOT, and the next update +// installs whatever lives there instead of the package that is installed. +func appendSubdirSelector(url, subdir string) string { + subdir = strings.Trim(strings.TrimSpace(subdir), "/") + if subdir == "" { + return url + } + base, ref := url, "" + if at := strings.LastIndex(url, "@"); at > strings.LastIndex(url, "/") { + base, ref = url[:at], url[at:] + } + return base + "//" + subdir + ref +} + func (gi *GitInstaller) updateRegistryWithGit(metadata *PackageMetadata, info *GitPackageInfo, sourcePath, destPath string) error { registryPath := filepath.Join(gi.AgentFieldHome, "installed.yaml") @@ -422,6 +440,13 @@ func (gi *GitInstaller) updateRegistryWithGit(metadata *PackageMetadata, info *G // any @ref and //subdir the user gave. (Appending the ref again used to // produce doubled "…@main@main" entries.) sourcePathStr := info.URL + // …except when the subdirectory came from --path (or the install API, which + // splits `//subdir` off the URL before calling). Then info.URL is the bare + // repo and the selector has to be put back, or this records a source that + // resolves to the repo root and the next update installs a different package. + if strings.TrimSpace(info.Subdir) == "" && strings.TrimSpace(gi.Subdir) != "" { + sourcePathStr = appendSubdirSelector(sourcePathStr, gi.Subdir) + } // Add/update package entry with Git information registry.Installed[metadata.Name] = InstalledPackage{ From a18d8561cf8267f519ee86533e32b62bb2af67ca Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 10:08:29 -0400 Subject: [PATCH 06/10] feat(packages): let a manifest declare itself superseded by another package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node author who renames or replaces their own node has no way to carry existing users across: `af install ` keeps installing the old package forever, because the manifest at that source is the only thing the installer looks at. Add an optional `superseded_by:` key naming an installable source. Installing a superseded package installs the successor instead, and replaces the old one when it is already present. The redirect lives in the package's own manifest, so the control plane needs no knowledge of any particular node — any author gets this, and no catalog or table here has to name them. Ordering and safety: - The successor is installed FIRST; only then is the old package retired, so a failed install leaves the user's existing node exactly as it was. - The redirect is taken before the force check and before anything is copied, so it never half-installs the package it redirects away from. - Node-scoped secrets move to the successor before the old package is uninstalled, which would otherwise delete that scope outright. Values already set on the successor win. Global secrets are shared and untouched. - Retiring the old package never fails the install: the successor is already working, so a leftover is a cleanup chore, not a failure. - A chain is bounded at 3 hops so two manifests pointing at each other fail loudly instead of cloning forever. The user is warned before the swap, naming what will be replaced. Co-Authored-By: Claude Opus 5 (1M context) --- control-plane/internal/packages/git.go | 110 ++++++++++++++++++- control-plane/internal/packages/installer.go | 12 +- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/control-plane/internal/packages/git.go b/control-plane/internal/packages/git.go index 814437625..30b872c3a 100644 --- a/control-plane/internal/packages/git.go +++ b/control-plane/internal/packages/git.go @@ -38,8 +38,20 @@ type GitInstaller struct { // subdirectory becomes the package root that is copied and installed. It // composes with an @ref pin on the URL, which is parsed independently. Subdir string + + // redirects counts how many superseded_by hops led here, bounding a cycle + // (A superseded by B, B superseded by A) instead of cloning forever. + redirects int + // installedName records the package name this installer actually installed. + // A superseded_by redirect needs it to hand the old package's node-scoped + // secrets to the successor, whose name it cannot know in advance. + installedName string } +// maxSupersedeRedirects bounds a superseded_by chain. Three is generous for the +// real case (one hop) and still fails fast on a manifest cycle. +const maxSupersedeRedirects = 3 + // newSpinner creates a new spinner with the given message func (gi *GitInstaller) newSpinner(message string) *Spinner { return &Spinner{ @@ -202,7 +214,15 @@ func (gi *GitInstaller) InstallFromGit(gitURL string, force bool) error { return fmt.Errorf("failed to parse package metadata: %w", err) } - // 4. Use existing installer for the rest + // 4. A superseded package installs its successor instead. This runs before + // the force check and before anything is copied, so a redirect never + // half-installs the package it is redirecting away from. + if target := strings.TrimSpace(metadata.SupersededBy); target != "" { + return gi.followSupersededBy(metadata.Name, target, force) + } + gi.installedName = metadata.Name + + // 5. Use existing installer for the rest installer := &PackageInstaller{ AgentFieldHome: gi.AgentFieldHome, Verbose: gi.Verbose, @@ -392,6 +412,94 @@ func (gi *GitInstaller) parsePackageMetadata(packagePath string) (*PackageMetada } // updateRegistryWithGit updates the installation registry with Git source info +// followSupersededBy installs the successor a manifest points at, then retires +// the superseded package when it was already installed. Order matters: the +// successor is installed FIRST, so a failure leaves the user's existing node +// exactly as it was rather than with nothing. +func (gi *GitInstaller) followSupersededBy(fromName, target string, force bool) error { + if gi.redirects >= maxSupersedeRedirects { + return fmt.Errorf( + "superseded_by chain longer than %d hops (at %q → %q) — the manifests most likely point at each other", + maxSupersedeRedirects, fromName, target) + } + + installer := &PackageInstaller{AgentFieldHome: gi.AgentFieldHome} + replacing := installer.isPackageInstalled(fromName) + + fmt.Println() + fmt.Printf("⚠️ %s has been superseded by %s\n", fromName, target) + if replacing { + fmt.Printf("⚠️ %s is currently installed and WILL BE REPLACED: the successor is installed first,\n", fromName) + fmt.Printf(" then %s is stopped and removed. Its node-scoped secrets move to the successor.\n", fromName) + } + fmt.Println(ui.Muted(" installing the successor instead")) + fmt.Println() + + successor := &GitInstaller{ + AgentFieldHome: gi.AgentFieldHome, + Verbose: gi.Verbose, + redirects: gi.redirects + 1, + } + if err := successor.InstallFromGit(target, force); err != nil { + return fmt.Errorf("installing %s, the successor of %s: %w", target, fromName, err) + } + gi.installedName = successor.installedName + + if !replacing || successor.installedName == fromName { + return nil + } + gi.retireSuperseded(fromName, successor.installedName) + return nil +} + +// retireSuperseded removes the old package once its successor is in place. It +// never fails the install: the successor is already working, so a stubborn +// leftover is a cleanup chore, not a reason to report failure. +func (gi *GitInstaller) retireSuperseded(oldName, newName string) { + if store, err := NewSecretStore(gi.AgentFieldHome); err == nil { + migrateNodeScopedSecrets(store, oldName, newName) + } + uninstaller := &PackageUninstaller{AgentFieldHome: gi.AgentFieldHome} + if err := uninstaller.UninstallPackage(oldName); err != nil { + fmt.Printf("⚠️ Could not remove the superseded %s: %v\n", oldName, err) + fmt.Printf(" %s is installed and usable; remove the old one with: af uninstall %s\n", newName, oldName) + return + } + fmt.Printf("✓ Replaced %s with %s\n", oldName, newName) +} + +// migrateNodeScopedSecrets hands node-scoped secrets to the successor before +// the old package is uninstalled, which deletes that scope outright. Without +// this every `af secrets set KEY --node ` value is silently lost in the +// swap. Global secrets are shared and untouched. A value already set on the +// successor wins: the user set that one deliberately, and later. +func migrateNodeScopedSecrets(store *SecretStore, oldName, newName string) { + // Read the scope directly rather than via Get, which falls back to the + // global scope and would copy shared secrets into the node scope. + oldValues, err := store.load(oldName) + if err != nil || len(oldValues) == 0 { + return + } + newValues, err := store.load(newName) + if err != nil { + newValues = map[string]string{} + } + moved := 0 + for key, value := range oldValues { + if _, exists := newValues[key]; exists { + continue + } + if err := store.Set(newName, key, value); err != nil { + fmt.Printf("⚠️ Could not move secret %s to %s: %v\n", key, newName, err) + continue + } + moved++ + } + if moved > 0 { + fmt.Printf(" moved %d node-scoped secret(s) from %s to %s\n", moved, oldName, newName) + } +} + // appendSubdirSelector rewrites "https://host/owner/repo[@ref]" into // "https://host/owner/repo//subdir[@ref]" so a recorded source round-trips // through ParseGitURL. It is needed whenever the subdirectory arrived by the diff --git a/control-plane/internal/packages/installer.go b/control-plane/internal/packages/installer.go index 5dde0013a..f5d296f76 100644 --- a/control-plane/internal/packages/installer.go +++ b/control-plane/internal/packages/installer.go @@ -115,7 +115,17 @@ type PackageMetadata struct { // at parse time by detection (a go.mod at the package root => "go", otherwise // "python"), so existing Python manifests keep working with no new field. This // is an *additive* optional key: it does NOT bump config_version. - Language string `yaml:"language"` + Language string `yaml:"language"` + // SupersededBy retires this package in favour of another one, named by an + // installable source (any string `af install` accepts, including a `//subdir` + // selector and an @ref). Installing a superseded package installs the + // successor instead, and replaces the old one when it is already present. + // + // This is how a node author renames or replaces their own node without the + // control plane knowing anything about them: the redirect lives in the + // package's manifest, not in a table here. Absent means "not superseded", + // so this is an *additive* optional key: it does NOT bump config_version. + SupersededBy string `yaml:"superseded_by"` Main string `yaml:"main"` Entrypoint EntrypointConfig `yaml:"entrypoint"` AgentNode AgentNodeConfig `yaml:"agent_node"` From 7c43de96d99b08df29c58754ef333861c5391957 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 10:11:15 -0400 Subject: [PATCH 07/10] test(packages): cover the superseded_by redirect and the subdir source fix One test per behaviour, driven through the real InstallFromGit against the existing fake-git harness rather than against the internals: - a superseded package installs its successor, and its own name never reaches the registry - an already-installed superseded package is replaced: successor present, old entry and old package directory gone - node-scoped secrets follow the swap, a value already set on the successor wins, and global secrets are untouched - with nothing to replace it is a plain install, no error - two manifests pointing at each other fail with a bounded-chain error and install nothing - a recorded --path source round-trips through ParseGitURL back to the same repo AND subdir Co-Authored-By: Claude Opus 5 (1M context) --- .../internal/packages/git_supersede_test.go | 256 ++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 control-plane/internal/packages/git_supersede_test.go diff --git a/control-plane/internal/packages/git_supersede_test.go b/control-plane/internal/packages/git_supersede_test.go new file mode 100644 index 000000000..994ccb807 --- /dev/null +++ b/control-plane/internal/packages/git_supersede_test.go @@ -0,0 +1,256 @@ +package packages + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// These cover the shape a real repo uses to retire a node: the root manifest +// declares itself superseded by a package in a subdirectory of the same repo, +// so `af install ` lands on the successor. + +const supersededRoot = "name: dual-node\nversion: 1.0.0\n" + + "superseded_by: https://gitlab.com/acme/dual//go\n" + +func seedInstalled(t *testing.T, home, name string) string { + t.Helper() + pkgDir := filepath.Join(home, "packages", name) + if err := os.MkdirAll(pkgDir, 0o755); err != nil { + t.Fatal(err) + } + pu := &PackageUninstaller{AgentFieldHome: home} + registry, err := pu.loadRegistry() + if err != nil { + t.Fatal(err) + } + registry.Installed[name] = InstalledPackage{Name: name, Path: pkgDir, Status: "stopped"} + if err := pu.saveRegistry(registry); err != nil { + t.Fatal(err) + } + return pkgDir +} + +// Contract: installing a superseded package installs its successor instead, +// and the superseded name never reaches the registry. +func TestInstallFromGit_SupersededRedirectsToSuccessor(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeSubdirManifest(t, filepath.Join(repo, "go"), "dual-node-go") + setupFakeGit(t, "copy", repo, false) + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + if _, ok := registry.Installed["dual-node-go"]; !ok { + t.Fatalf("successor missing from registry, got %v", registry.Installed) + } + if _, ok := registry.Installed["dual-node"]; ok { + t.Fatal("the superseded package must not be installed") + } + if _, err := os.Stat(filepath.Join(home, "packages", "dual-node-go", "agentfield-package.yaml")); err != nil { + t.Fatalf("successor not on disk: %v", err) + } +} + +// Contract: when the superseded package is already installed it is replaced — +// the successor lands first, then the old package is stopped and removed. +func TestInstallFromGit_SupersededReplacesExistingInstall(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeSubdirManifest(t, filepath.Join(repo, "go"), "dual-node-go") + setupFakeGit(t, "copy", repo, false) + + oldDir := seedInstalled(t, home, "dual-node") + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + if _, ok := registry.Installed["dual-node-go"]; !ok { + t.Fatalf("successor missing, got %v", registry.Installed) + } + if _, ok := registry.Installed["dual-node"]; ok { + t.Fatal("superseded package should have been retired from the registry") + } + if _, err := os.Stat(oldDir); !os.IsNotExist(err) { + t.Fatalf("superseded package dir should be gone, stat err = %v", err) + } +} + +// Contract: node-scoped secrets follow the user across the swap, because +// uninstalling the old package deletes that scope outright. A value already +// set on the successor wins, and global secrets are untouched. +func TestInstallFromGit_SupersededMigratesNodeScopedSecrets(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeSubdirManifest(t, filepath.Join(repo, "go"), "dual-node-go") + setupFakeGit(t, "copy", repo, false) + + seedInstalled(t, home, "dual-node") + store, err := NewSecretStore(home) + if err != nil { + t.Fatal(err) + } + if err := store.Set("dual-node", "CARRIED", "from-old"); err != nil { + t.Fatal(err) + } + if err := store.Set("dual-node", "KEPT", "old-value"); err != nil { + t.Fatal(err) + } + if err := store.Set("dual-node-go", "KEPT", "new-value"); err != nil { + t.Fatal(err) + } + if err := store.Set("global", "SHARED", "shared-value"); err != nil { + t.Fatal(err) + } + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + + after, err := NewSecretStore(home) + if err != nil { + t.Fatal(err) + } + values, err := after.load("dual-node-go") + if err != nil { + t.Fatal(err) + } + if values["CARRIED"] != "from-old" { + t.Fatalf("secret did not follow the swap: %v", values) + } + if values["KEPT"] != "new-value" { + t.Fatalf("successor's own value must win, got %q", values["KEPT"]) + } + globals, err := after.load("global") + if err != nil { + t.Fatal(err) + } + if globals["SHARED"] != "shared-value" { + t.Fatal("global secrets must survive the swap") + } +} + +// Contract: with nothing to replace, the redirect is a plain install — no +// error, and no attempt to retire a package that was never there. +func TestInstallFromGit_SupersededWithoutPriorInstall(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeSubdirManifest(t, filepath.Join(repo, "go"), "dual-node-go") + setupFakeGit(t, "copy", repo, false) + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + if len(registry.Installed) != 1 { + t.Fatalf("expected exactly the successor installed, got %v", registry.Installed) + } +} + +// Contract: two manifests pointing at each other fail loudly instead of +// redirecting forever. +func TestInstallFromGit_SupersededCycleIsBounded(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + // The successor points straight back at the root: A → B → A → … + if err := os.MkdirAll(filepath.Join(repo, "go"), 0o755); err != nil { + t.Fatal(err) + } + manifest := "name: dual-node-go\nversion: 1.0.0\n" + + "entrypoint:\n start: python -m dual-node-go\n" + + "superseded_by: https://gitlab.com/acme/dual\n" + if err := os.WriteFile( + filepath.Join(repo, "go", "agentfield-package.yaml"), []byte(manifest), 0o644, + ); err != nil { + t.Fatal(err) + } + setupFakeGit(t, "copy", repo, false) + + err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false) + if err == nil || !strings.Contains(err.Error(), "superseded_by chain longer than") { + t.Fatalf("expected a bounded-chain error, got %v", err) + } + // Nothing was installed, so the registry was never even created. + if _, statErr := os.Stat(filepath.Join(home, "installed.yaml")); !os.IsNotExist(statErr) { + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + if len(registry.Installed) != 0 { + t.Fatalf("a cycle must install nothing, got %v", registry.Installed) + } + } +} + +// Contract: a source recorded for a --path install round-trips through +// ParseGitURL back to the same repo AND subdir, so the next update resolves +// the package that is actually installed rather than the repo root. +func TestAppendSubdirSelectorRoundTrips(t *testing.T) { + cases := []struct { + url, subdir, want, wantRef string + }{ + {"https://github.com/acme/repo", "go", "https://github.com/acme/repo//go", ""}, + {"https://github.com/acme/repo@main", "go", "https://github.com/acme/repo//go@main", "main"}, + {"https://github.com/acme/repo", "nested/dir", "https://github.com/acme/repo//nested/dir", ""}, + {"https://github.com/acme/repo", "", "https://github.com/acme/repo", ""}, + } + for _, c := range cases { + got := appendSubdirSelector(c.url, c.subdir) + if got != c.want { + t.Errorf("appendSubdirSelector(%q, %q) = %q, want %q", c.url, c.subdir, got, c.want) + continue + } + info, err := ParseGitURL(got) + if err != nil { + t.Errorf("ParseGitURL(%q): %v", got, err) + continue + } + wantSubdir := strings.Trim(c.subdir, "/") + if info.Subdir != wantSubdir || info.Ref != c.wantRef { + t.Errorf("round-trip of %q = subdir %q ref %q, want %q %q", + got, info.Subdir, info.Ref, wantSubdir, c.wantRef) + } + } +} + +// Contract: the registry records the subdirectory even when it arrived by the +// --path flag rather than the URL selector. Without this the stored source +// resolves to the repo root and the next update installs a different package. +func TestInstallFromGit_PathFlagRecordsSubdirInSource(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, "name: dual-node\nversion: 1.0.0\n") + writeSubdirManifest(t, filepath.Join(repo, "go"), "dual-node-go") + setupFakeGit(t, "copy", repo, false) + + gi := &GitInstaller{AgentFieldHome: home, Subdir: "go"} + if err := gi.InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + pkg, ok := registry.Installed["dual-node-go"] + if !ok { + t.Fatalf("expected dual-node-go installed, got %v", registry.Installed) + } + if pkg.SourcePath != "https://gitlab.com/acme/dual//go" { + t.Fatalf("source path = %q, want the //go selector recorded", pkg.SourcePath) + } + info, err := ParseGitURL(pkg.SourcePath) + if err != nil || info.Subdir != "go" { + t.Fatalf("recorded source must resolve back to the subdir: %v / %+v", err, info) + } +} From 95424a9cbe7c30625e15699797168b3b473bf304 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 11:36:11 -0400 Subject: [PATCH 08/10] feat(packages): let a successor replace a predecessor of the same name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A node that renames itself takes the name its predecessor held, and `superseded_by` could not express that: the redirect installed the successor without carrying the user's consent to replace, so the successor's own force check rejected it with "already installed (use --force to reinstall)". The redirect has already printed an explicit replacement warning by then, so it now carries that consent through. Same name means there is nothing to retire afterwards and node-scoped secrets are already in the right scope, both of which the existing short-circuit handles. That makes the failure mode worse, though, and this fixes it too: copyPackage clears the destination before the replacement is copied, and long before its dependencies build. A replace that dies in the dependency step — a missing toolchain is enough — used to leave the user with neither the package they had nor a working new one. The existing directory is now set aside first and put back on any failure before the registry is updated, which also covers a plain `af install --force` on any package. Also restores the doc comment on updateRegistryWithGit, which an earlier commit in this branch left attached to the wrong function. Co-Authored-By: Claude Opus 5 (1M context) --- control-plane/internal/packages/git.go | 91 ++++++++- .../internal/packages/git_supersede_test.go | 176 ++++++++++++++++++ 2 files changed, 263 insertions(+), 4 deletions(-) diff --git a/control-plane/internal/packages/git.go b/control-plane/internal/packages/git.go index 30b872c3a..cffe4576d 100644 --- a/control-plane/internal/packages/git.go +++ b/control-plane/internal/packages/git.go @@ -236,10 +236,21 @@ func (gi *GitInstaller) InstallFromGit(gitURL string, force bool) error { // Install using existing flow destPath := filepath.Join(gi.AgentFieldHome, "packages", metadata.Name) + // Reinstalling clears the destination before the replacement is copied, + // and long before its dependencies finish building — a missing toolchain + // is enough to fail there. Without this the user would be left with + // neither the package they had nor a working new one. Set the existing + // directory aside instead, and put it back if anything below fails. + backup, err := stashExistingPackage(destPath) + if err != nil { + return err + } + spinner = gi.newSpinner("Setting up environment") spinner.Start() if err := installer.copyPackage(packagePath, destPath); err != nil { spinner.Error("Failed to copy package") + backup.restore() return fmt.Errorf("failed to copy package: %w", err) } spinner.Success("Environment configured") @@ -248,14 +259,17 @@ func (gi *GitInstaller) InstallFromGit(gitURL string, force bool) error { spinner.Start() if err := installer.installDependencies(destPath, metadata); err != nil { spinner.Error("Failed to install dependencies") + backup.restore() return fmt.Errorf("failed to install dependencies: %w", err) } spinner.Success("Dependencies installed") // Update registry with Git source information if err := gi.updateRegistryWithGit(metadata, info, packagePath, destPath); err != nil { + backup.restore() return fmt.Errorf("failed to update registry: %w", err) } + backup.discard() fmt.Println() fmt.Println(installSummaryPanel(metadata.Name, metadata.Version, info.URL, info.Ref, destPath)) @@ -411,7 +425,6 @@ func (gi *GitInstaller) parsePackageMetadata(packagePath string) (*PackageMetada return installer.parsePackageMetadata(packagePath) } -// updateRegistryWithGit updates the installation registry with Git source info // followSupersededBy installs the successor a manifest points at, then retires // the superseded package when it was already installed. Order matters: the // successor is installed FIRST, so a failure leaves the user's existing node @@ -429,8 +442,9 @@ func (gi *GitInstaller) followSupersededBy(fromName, target string, force bool) fmt.Println() fmt.Printf("⚠️ %s has been superseded by %s\n", fromName, target) if replacing { - fmt.Printf("⚠️ %s is currently installed and WILL BE REPLACED: the successor is installed first,\n", fromName) - fmt.Printf(" then %s is stopped and removed. Its node-scoped secrets move to the successor.\n", fromName) + fmt.Printf("⚠️ %s is currently installed and WILL BE REPLACED. The successor is installed\n", fromName) + fmt.Println(" first and node-scoped secrets are carried over; if that fails, what you") + fmt.Println(" have now is left as it is.") } fmt.Println(ui.Muted(" installing the successor instead")) fmt.Println() @@ -440,7 +454,12 @@ func (gi *GitInstaller) followSupersededBy(fromName, target string, force bool) Verbose: gi.Verbose, redirects: gi.redirects + 1, } - if err := successor.InstallFromGit(target, force); err != nil { + // A successor may carry the same name as the package it retires — that is + // a node renaming itself in place, and it is the shape a rename takes when + // the old and new names are meant to converge. The warning above already + // said this replaces the current install, so carry that consent into the + // successor rather than failing the redirect with "already installed". + if err := successor.InstallFromGit(target, force || replacing); err != nil { return fmt.Errorf("installing %s, the successor of %s: %w", target, fromName, err) } gi.installedName = successor.installedName @@ -468,6 +487,69 @@ func (gi *GitInstaller) retireSuperseded(oldName, newName string) { fmt.Printf("✓ Replaced %s with %s\n", oldName, newName) } +// packageBackup holds an installed package directory that a reinstall is about +// to overwrite, so it can be put back if the reinstall fails partway. The zero +// value is a valid no-op, which is what a first-time install gets. +type packageBackup struct { + original string + saved string +} + +// stashExistingPackage moves an installed package directory aside so a failed +// reinstall can restore it. A missing directory is not an error — there is +// simply nothing to protect. +func stashExistingPackage(destPath string) (*packageBackup, error) { + if _, err := os.Stat(destPath); err != nil { + if os.IsNotExist(err) { + return &packageBackup{}, nil + } + return nil, fmt.Errorf("failed to inspect %s: %w", destPath, err) + } + // Dot-prefixed and alongside the original: same filesystem, so the move is + // a rename rather than a copy, and it cannot be mistaken for a package. + dir, name := filepath.Split(strings.TrimRight(destPath, string(os.PathSeparator))) + saved := filepath.Join(dir, "."+name+".previous") + // A leftover from an interrupted run would make the rename fail. + if err := os.RemoveAll(saved); err != nil { + return nil, fmt.Errorf("failed to clear a stale backup at %s: %w", saved, err) + } + if err := os.Rename(destPath, saved); err != nil { + return nil, fmt.Errorf("failed to set the existing package aside: %w", err) + } + return &packageBackup{original: destPath, saved: saved}, nil +} + +// restore puts the stashed package back, undoing a failed reinstall. It never +// returns an error: the install is already failing, and the caller's report of +// why is more useful than a cleanup problem layered on top. A backup that +// cannot be moved back is still on disk, so say where. +func (b *packageBackup) restore() { + if b == nil || b.saved == "" { + return + } + if err := os.RemoveAll(b.original); err != nil { + fmt.Printf("⚠️ Could not clear the failed install at %s: %v\n", b.original, err) + fmt.Printf(" your previous version is still on disk at %s\n", b.saved) + return + } + if err := os.Rename(b.saved, b.original); err != nil { + fmt.Printf("⚠️ Could not restore your previous version: %v\n", err) + fmt.Printf(" it is still on disk at %s\n", b.saved) + return + } + fmt.Printf(" restored the previously installed version at %s\n", b.original) + b.saved = "" +} + +// discard drops the stashed copy once the reinstall has succeeded. +func (b *packageBackup) discard() { + if b == nil || b.saved == "" { + return + } + os.RemoveAll(b.saved) + b.saved = "" +} + // migrateNodeScopedSecrets hands node-scoped secrets to the successor before // the old package is uninstalled, which deletes that scope outright. Without // this every `af secrets set KEY --node ` value is silently lost in the @@ -518,6 +600,7 @@ func appendSubdirSelector(url, subdir string) string { return base + "//" + subdir + ref } +// updateRegistryWithGit updates the installation registry with Git source info func (gi *GitInstaller) updateRegistryWithGit(metadata *PackageMetadata, info *GitPackageInfo, sourcePath, destPath string) error { registryPath := filepath.Join(gi.AgentFieldHome, "installed.yaml") diff --git a/control-plane/internal/packages/git_supersede_test.go b/control-plane/internal/packages/git_supersede_test.go index 994ccb807..07e56ccc8 100644 --- a/control-plane/internal/packages/git_supersede_test.go +++ b/control-plane/internal/packages/git_supersede_test.go @@ -195,6 +195,182 @@ func TestInstallFromGit_SupersededCycleIsBounded(t *testing.T) { } } +// writeMarkedSubdirPackage writes a subdirectory package that shares the root +// manifest's name — the shape a node takes when it renames itself in place — +// carrying a marker file so a test can tell whose files ended up installed. +func writeMarkedSubdirPackage(t *testing.T, dir, name, marker string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + manifest := "name: " + name + "\nversion: 2.0.0\nentrypoint:\n start: bin/" + name + "\n" + if err := os.WriteFile(filepath.Join(dir, "agentfield-package.yaml"), []byte(manifest), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, marker), []byte("successor\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +// Contract: a successor that carries the SAME name as the package it retires +// replaces it in place. The redirect already warned the user, so it must not +// fail with "already installed (use --force)" — the case a node hits when it +// renames itself to the name its predecessor held. +func TestInstallFromGit_SupersededSameNameReplacesInPlace(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeMarkedSubdirPackage(t, filepath.Join(repo, "go"), "dual-node", "successor.txt") + setupFakeGit(t, "copy", repo, false) + + oldDir := seedInstalled(t, home, "dual-node") + if err := os.WriteFile(filepath.Join(oldDir, "predecessor.txt"), []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("same-name supersede must not need --force: %v", err) + } + + registry := readRegistryFile(t, filepath.Join(home, "installed.yaml")) + pkg, ok := registry.Installed["dual-node"] + if !ok { + t.Fatalf("the shared name must still be installed, got %v", registry.Installed) + } + if pkg.Version != "2.0.0" { + t.Fatalf("registry still describes the predecessor: version %q", pkg.Version) + } + if _, err := os.Stat(filepath.Join(oldDir, "successor.txt")); err != nil { + t.Fatalf("successor's files are not installed: %v", err) + } + if _, err := os.Stat(filepath.Join(oldDir, "predecessor.txt")); !os.IsNotExist(err) { + t.Fatalf("predecessor's files must not survive the replace, stat err = %v", err) + } +} + +// Contract: node-scoped secrets survive a same-name replace. They never move — +// the scope name is unchanged — so the risk is the retire path deleting them. +func TestInstallFromGit_SupersededSameNameKeepsNodeScopedSecrets(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + writeTestPackage(t, repo, supersededRoot) + writeMarkedSubdirPackage(t, filepath.Join(repo, "go"), "dual-node", "successor.txt") + setupFakeGit(t, "copy", repo, false) + + seedInstalled(t, home, "dual-node") + store, err := NewSecretStore(home) + if err != nil { + t.Fatal(err) + } + if err := store.Set("dual-node", "KEPT", "node-value"); err != nil { + t.Fatal(err) + } + + if err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/dual", false); err != nil { + t.Fatalf("InstallFromGit: %v", err) + } + + after, err := NewSecretStore(home) + if err != nil { + t.Fatal(err) + } + values, err := after.load("dual-node") + if err != nil { + t.Fatal(err) + } + if values["KEPT"] != "node-value" { + t.Fatalf("node-scoped secret lost in an in-place replace: %v", values) + } +} + +// Contract: an install that fails after the destination has been cleared puts +// the previously installed package back. Without this a replace that dies in +// the dependency step — a missing toolchain is enough — leaves the user with +// neither their old node nor a working new one. +func TestInstallFromGit_FailedReinstallRestoresPreviousPackage(t *testing.T) { + home := t.TempDir() + repo := filepath.Join(t.TempDir(), "repo") + // language: typescript with no package.json fails in installDependencies, + // which runs after the destination has already been cleared and copied. + writeTestPackage(t, repo, "name: solo-node\nversion: 2.0.0\nlanguage: typescript\n") + setupFakeGit(t, "copy", repo, false) + + oldDir := seedInstalled(t, home, "solo-node") + if err := os.WriteFile(filepath.Join(oldDir, "predecessor.txt"), []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + + err := (&GitInstaller{AgentFieldHome: home}). + InstallFromGit("https://gitlab.com/acme/solo", true) + if err == nil { + t.Fatal("expected the install to fail in the dependency step") + } + + if _, statErr := os.Stat(filepath.Join(oldDir, "predecessor.txt")); statErr != nil { + t.Fatalf("previously installed package was not restored: %v", statErr) + } + entries, readErr := os.ReadDir(filepath.Join(home, "packages")) + if readErr != nil { + t.Fatal(readErr) + } + for _, e := range entries { + if strings.HasPrefix(e.Name(), ".") { + t.Fatalf("a backup was left behind in packages/: %s", e.Name()) + } + } +} + +// Contract: stashing is a no-op when there is nothing installed yet, and a +// discarded stash leaves no residue — the first-install path must not be +// burdened with cleanup that does not apply to it. +func TestStashExistingPackage(t *testing.T) { + home := t.TempDir() + missing := filepath.Join(home, "packages", "never-installed") + backup, err := stashExistingPackage(missing) + if err != nil { + t.Fatalf("stashing a missing package must succeed: %v", err) + } + backup.restore() // must not recreate anything + if _, err := os.Stat(missing); !os.IsNotExist(err) { + t.Fatalf("restoring a no-op stash created something, stat err = %v", err) + } + + present := filepath.Join(home, "packages", "installed") + if err := os.MkdirAll(present, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(present, "keep.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + backup, err = stashExistingPackage(present) + if err != nil { + t.Fatal(err) + } + if _, err := os.Stat(present); !os.IsNotExist(err) { + t.Fatalf("stashing must move the directory aside, stat err = %v", err) + } + backup.restore() + if _, err := os.Stat(filepath.Join(present, "keep.txt")); err != nil { + t.Fatalf("restore did not put the package back: %v", err) + } + + backup, err = stashExistingPackage(present) + if err != nil { + t.Fatal(err) + } + backup.discard() + entries, err := os.ReadDir(filepath.Join(home, "packages")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 0 { + t.Fatalf("discard left residue in packages/: %v", entries) + } +} + // Contract: a source recorded for a --path install round-trips through // ParseGitURL back to the same repo AND subdir, so the next update resolves // the package that is actually installed rather than the repo root. From 72b5b9bf6be2ae42773cfc871c891f6972ab2e74 Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 11:40:10 -0400 Subject: [PATCH 09/10] refactor: catalogue the SWE node as swe-planner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The catalog named it swe-planner-go, after the implementation. That was only ever a workaround for the two SWE manifests needing distinct registry keys, and the node now declares itself swe-planner — a name that survives the implementation changing under it, and the one its triggers already use. Catalog `name` must equal the manifest `name`, so this follows rather than leads. The install command in the README drops the `//go` selector too: the root manifest redirects, so the bare repo URL is the whole instruction. Both catalog tests keep their guard against a second SWE row, and now pin the surviving row's name rather than merely asserting the Python one is absent — the assertion that would have caught this rename going half-done. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 6 +++--- control-plane/internal/cli/catalog.go | 4 ++-- control-plane/internal/cli/catalog_test.go | 13 +++++++------ .../skill_data/agentfield-use/SKILL.md | 6 +++--- desktop/src/main/agentfield.test.ts | 19 +++++++++---------- desktop/src/shared/catalog.ts | 4 ++-- docs/mcp-integration.md | 6 +++--- skills/agentfield-use/SKILL.md | 6 +++--- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index 99a7fa6c3..5b82a7d6d 100644 --- a/README.md +++ b/README.md @@ -411,13 +411,13 @@ Two examples already run at this load. The [deep-research engine](https://agentf Each of these is a real, installable agent node. With a control plane running, drop any of them into your setup with a single command — `af install ` clones the repo, isolates its dependencies, and registers the node. You're prompted once for shared secrets like `OPENROUTER_API_KEY` (stored encrypted and reused across every node), then the node's reasoners are callable over REST or with `af call`: ```bash -af install https://github.com/Agent-Field/SWE-AF//go # autonomous engineering team → node: swe-planner-go +af install https://github.com/Agent-Field/SWE-AF # autonomous engineering team → node: swe-planner af install https://github.com/Agent-Field/sec-af # security auditor → node: sec-af af install https://github.com/Agent-Field/cloudsecurity-af # cloud / IaC security scanner → node: cloudsecurity af install https://github.com/Agent-Field/pr-af # agentic code review → node: pr-af -af run swe-planner-go # start a node (prompts once for required secrets) -af call swe-planner-go.build --in '{"goal": "Add JWT auth", "repo_url": "https://github.com/user/my-repo"}' +af run swe-planner # start a node (prompts once for required secrets) +af call swe-planner.build --in '{"goal": "Add JWT auth", "repo_url": "https://github.com/user/my-repo"}' ``` Full walkthrough — authoring, installing, and configuring nodes: [Installing agent nodes →](docs/installing-agent-nodes.md). diff --git a/control-plane/internal/cli/catalog.go b/control-plane/internal/cli/catalog.go index 5926121d0..a4a97dfca 100644 --- a/control-plane/internal/cli/catalog.go +++ b/control-plane/internal/cli/catalog.go @@ -17,7 +17,7 @@ import ( // (desktop/src/shared/catalog.ts) — keep the two in sync when adding nodes. // `name` MUST equal the node's agentfield-package.yaml `name:` (the registry // key after install), which is often not the repo name (SWE-AF//go → -// swe-planner-go). +// swe-planner). type nodeCatalogEntry struct { Name string `json:"name"` Description string `json:"description"` @@ -31,7 +31,7 @@ type nodeCatalogEntry struct { // `//` source selector stripped, since the docs live at the repo root). var nodeCatalog = []nodeCatalogEntry{ { - Name: "swe-planner-go", + Name: "swe-planner", Description: "Autonomous software-engineering fleet: plan, code, test, and ship production-grade PRs — one static binary", Source: "https://github.com/Agent-Field/SWE-AF//go", Docs: "https://github.com/Agent-Field/SWE-AF", diff --git a/control-plane/internal/cli/catalog_test.go b/control-plane/internal/cli/catalog_test.go index 99a53f9d1..9a4357ac4 100644 --- a/control-plane/internal/cli/catalog_test.go +++ b/control-plane/internal/cli/catalog_test.go @@ -29,23 +29,24 @@ func TestRunCatalogPrettyEndsWithInstallHint(t *testing.T) { require.NoError(t, runCatalog(&stdout, "pretty")) out := stdout.String() require.Contains(t, out, "af install ") - require.Contains(t, out, "swe-planner-go") + require.Contains(t, out, "swe-planner") } -// The SWE fleet ships as exactly one catalog row: the Go node installed from -// the `//go` source selector. A re-added root/Python `swe-planner` entry must -// fail here rather than silently reappear in `af catalog`. +// The SWE fleet ships as exactly one catalog row, named for the product rather +// than the implementation and installed from the `//go` source selector. A +// second entry — a re-added root/Python row, or the old implementation-suffixed +// name creeping back — must fail here rather than reappear in `af catalog`. func TestCatalogHasSingleGoSWEEntry(t *testing.T) { var sweEntries []nodeCatalogEntry for _, e := range nodeCatalog { - require.NotEqual(t, "swe-planner", e.Name, "SWE fleet must be catalogued only as swe-planner-go") if strings.Contains(e.Source, "Agent-Field/SWE-AF") { sweEntries = append(sweEntries, e) } } require.Len(t, sweEntries, 1, "exactly one catalog entry may install from Agent-Field/SWE-AF") - require.Equal(t, "swe-planner-go", sweEntries[0].Name) + require.Equal(t, "swe-planner", sweEntries[0].Name, + "the SWE entry is named for the product, not the implementation") require.True(t, strings.HasSuffix(sweEntries[0].Source, "//go"), "SWE entry source must select the go subdirectory, got %q", sweEntries[0].Source) } diff --git a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md index 3d2b6794b..fd4d2c057 100644 --- a/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md +++ b/control-plane/internal/skillkit/skill_data/agentfield-use/SKILL.md @@ -1,7 +1,7 @@ --- name: agentfield-use version: 0.4.0 -description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner-go, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." +description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." --- # Using AgentField agents @@ -135,7 +135,7 @@ Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. **Async — the default for real work.** Returns `202` immediately: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "add rate limiting to the API"}}' # -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} @@ -145,7 +145,7 @@ curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ `result` directly): ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner-go.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "..."}}' ``` diff --git a/desktop/src/main/agentfield.test.ts b/desktop/src/main/agentfield.test.ts index f8c0340a9..dace3b2b2 100644 --- a/desktop/src/main/agentfield.test.ts +++ b/desktop/src/main/agentfield.test.ts @@ -563,16 +563,15 @@ describe('install catalog', () => { expect(catalogEntry('definitely-not-real')).toBeUndefined() }) - // The SWE fleet is offered as a single install: the Go node, sourced from - // the `//go` subdirectory. A re-added root/Python `swe-planner` entry must - // fail here rather than quietly reappear in the Install view. - it('offers the SWE fleet only as the go-sourced swe-planner-go entry', () => { - const goEntry = CATALOG.find((e) => e.name === 'swe-planner-go') - expect(goEntry).toBeDefined() - expect(goEntry?.source.endsWith('//go')).toBe(true) - - expect(CATALOG.find((e) => e.name === 'swe-planner')).toBeUndefined() - expect(CATALOG.filter((e) => e.source.includes('Agent-Field/SWE-AF'))).toHaveLength(1) + // The SWE fleet is offered as a single install, named for the product and + // sourced from the `//go` subdirectory. A second SWE row — or the old + // implementation-suffixed name creeping back in — must fail here rather than + // quietly reappear in the Install view. + it('offers the SWE fleet as one product-named entry sourced from //go', () => { + const sweEntries = CATALOG.filter((e) => e.source.includes('Agent-Field/SWE-AF')) + expect(sweEntries).toHaveLength(1) + expect(sweEntries[0].name).toBe('swe-planner') + expect(sweEntries[0].source.endsWith('//go')).toBe(true) }) }) diff --git a/desktop/src/shared/catalog.ts b/desktop/src/shared/catalog.ts index c2c7c5550..72cf7862d 100644 --- a/desktop/src/shared/catalog.ts +++ b/desktop/src/shared/catalog.ts @@ -14,10 +14,10 @@ import type { CatalogEntry } from './types' // ports living beside their Python originals are installed). When adding an // entry, `name` MUST equal the manifest's `name:` (the registry key after // install — how the app detects installed state), which is often NOT the -// repo name (SWE-AF//go → swe-planner-go). +// repo name (SWE-AF//go → swe-planner). export const CATALOG: CatalogEntry[] = [ { - name: 'swe-planner-go', + name: 'swe-planner', description: 'Software factory — turn any issue into a production-ready pull request, end to end', source: 'https://github.com/Agent-Field/SWE-AF//go', diff --git a/docs/mcp-integration.md b/docs/mcp-integration.md index f838ef1dd..02fbfde3a 100644 --- a/docs/mcp-integration.md +++ b/docs/mcp-integration.md @@ -48,10 +48,10 @@ to them. ## Example flow -1. `discover_agents` → find `swe-planner-go` and its `build` reasoner. -2. `get_reasoner_schema` `{ node: "swe-planner-go", reasoner: "build" }` → learn the +1. `discover_agents` → find `swe-planner` and its `build` reasoner. +2. `get_reasoner_schema` `{ node: "swe-planner", reasoner: "build" }` → learn the input shape. -3. `execute_reasoner` `{ target: "swe-planner-go.build", input: { goal: "Add JWT auth" } }` +3. `execute_reasoner` `{ target: "swe-planner.build", input: { goal: "Add JWT auth" } }` → `{ run_id: "run_…", status: "accepted" }`. 4. `wait_run` `{ run_id: "run_…", timeout_seconds: 120 }` → block until the run finishes (or `timed_out: true`), then read `result`. diff --git a/skills/agentfield-use/SKILL.md b/skills/agentfield-use/SKILL.md index 3d2b6794b..fd4d2c057 100644 --- a/skills/agentfield-use/SKILL.md +++ b/skills/agentfield-use/SKILL.md @@ -1,7 +1,7 @@ --- name: agentfield-use version: 0.4.0 -description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner-go, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." +description: "Discover and call agents already running on a local AgentField control plane. Use when the user asks to use, call, query, run, or delegate work to an installed AgentField agent (swe-planner, pr-af, sec-af, …), to list what agents or reasoners are available, or to check on an execution. Not for building new agents — that is the agentfield skill." --- # Using AgentField agents @@ -135,7 +135,7 @@ Input kwargs are ALWAYS nested under `"input"` — never raw at the top level. **Async — the default for real work.** Returns `202` immediately: ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "add rate limiting to the API"}}' # -> {"execution_id":"...", "run_id":"...", "status":"queued", ...} @@ -145,7 +145,7 @@ curl -s -X POST http://localhost:8080/api/v1/execute/async/swe-planner-go.plan \ `result` directly): ```bash -curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner-go.plan \ +curl -s -X POST http://localhost:8080/api/v1/execute/swe-planner.plan \ -H 'Content-Type: application/json' \ -d '{"input": {"task": "..."}}' ``` From 80bc37c68ed2a29c8014a816e0fc903d94c4c90b Mon Sep 17 00:00:00 2001 From: Abir Abbas Date: Tue, 4 Aug 2026 11:40:21 -0400 Subject: [PATCH 10/10] docs: document superseded_by MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The key shipped undocumented, which defeats the point of making it generic — a node author cannot use a manifest field they cannot find. Documents what it accepts, and the ordering guarantees that make a redirect safe to run against an installed node: resolved before anything is copied, successor installed first, node-scoped secrets carried across, retiring never fails the install, chains bounded. Also corrects the claim just above it that a root node and a `--path` node from one repo always coexist. They coexist when their names differ, and replace each other when they do not — which is exactly what SWE-AF, the example named there, now does. Co-Authored-By: Claude Opus 5 (1M context) --- docs/installing-agent-nodes.md | 48 +++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 6 deletions(-) diff --git a/docs/installing-agent-nodes.md b/docs/installing-agent-nodes.md index ce844eb3c..68750356f 100644 --- a/docs/installing-agent-nodes.md +++ b/docs/installing-agent-nodes.md @@ -240,12 +240,48 @@ af install ./SWE-AF --path go The subdirectory must contain its own `agentfield-package.yaml`; that subtree becomes the package root that is copied to `~/.agentfield/packages/` and -installed (a Go node builds relative to it). Because registry entries are keyed by -the manifest `name`, the root node and a `--path` node from the same repo coexist -as separate installs. `--path` is a path **relative to the source root**: absolute -paths and paths that escape the root with `..` are rejected, and a missing manifest -is reported with the full expected path. A bare `af install ` (no `--path`) is -unchanged — the root manifest is always what you get by default. +installed (a Go node builds relative to it). Registry entries are keyed by the +manifest `name`, so a root node and a `--path` node from the same repo coexist as +separate installs when their names differ — and replace one another when they do +not. `--path` is a path **relative to the source root**: absolute paths and paths +that escape the root with `..` are rejected, and a missing manifest is reported +with the full expected path. A bare `af install ` (no `--path`) is unchanged +— the root manifest is always what you get by default, unless it redirects. + +## Retiring or renaming a node: `superseded_by` + +A manifest can declare that it has been replaced by another package. Installing +it then installs that other package instead: + +```yaml +name: my-node +superseded_by: https://github.com/me/my-repo//v2 +``` + +The value is any source `af install` accepts — including a `//subdir` selector +and an `@ref`. This lets you move, rename, or reimplement your node without +anyone having to learn a new install command, and without AgentField holding a +list of who redirects where: the redirect lives in your manifest. + +What happens on install: + +- The redirect is resolved **before** anything is copied, so a redirected + install never leaves the superseded package half-installed. +- If the superseded package is currently installed, the user is warned that it + will be replaced, and the successor is installed **first** — a failure leaves + what they had exactly as it was. +- Node-scoped secrets follow: when the successor takes a different name they are + copied across before the old package is uninstalled (which would delete that + scope); values already set on the successor win. When the successor takes the + *same* name — a node renaming itself in place — they are already in the right + scope and stay put. +- Retiring the old package never fails the install. If it cannot be removed you + are told how to remove it by hand. +- Chains are bounded at three hops, so two manifests pointing at each other fail + with a clear error instead of looping. + +The redirect applies to git installs. A local-path install ignores it, which is +how you install a superseded package deliberately. ## Previewing requirements before installing: `af show-requirements`