-
Notifications
You must be signed in to change notification settings - Fork 0
refactor(cli): migrate uninstall flows to harness-first agent discovery #43
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
24cd445
f517fff
e201d83
558f4c2
ea01025
4e14a0f
91b6be6
d74b5e3
b218933
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1598,30 +1598,35 @@ func runInstall(ctx context.Context, client forge.Client, printer *ui.Printer, o | |
|
|
||
| // runUninstall tears down the fullsend installation. | ||
| func runUninstall(ctx context.Context, client forge.Client, printer *ui.Printer, org, appSet string, browser appsetup.BrowserOpener, stdin io.Reader) error { | ||
| // Try to load agent slugs from existing config. If the .fullsend repo | ||
| // is already gone (e.g., previous partial uninstall), fall back to the | ||
| // default naming convention so we can still guide the user to delete | ||
| // the apps. Without this fallback, a partial uninstall leaves orphaned | ||
| // apps that block reinstallation (PEM keys are one-shot). | ||
| // Try to discover agent slugs. Prefer harness wrapper files, then | ||
| // fall back to config.yaml agents: block, then default naming. | ||
| // If the .fullsend repo is already gone (e.g., previous partial | ||
| // uninstall), fall back to the default naming convention so we can | ||
| // still guide the user to delete the apps. Without this fallback, | ||
| // a partial uninstall leaves orphaned apps that block reinstallation | ||
| // (PEM keys are one-shot). | ||
| var agentSlugs []string | ||
| var configMode string | ||
| var enrolledRepos []string | ||
| var parsedCfg *config.OrgConfig | ||
| cfgData, err := client.GetFileContent(ctx, org, forge.ConfigRepoName, "config.yaml") | ||
| if err == nil { | ||
| if parsedCfg, parseErr := config.ParseOrgConfig(cfgData); parseErr == nil { | ||
| for _, agent := range parsedCfg.Agents { | ||
| agentSlugs = append(agentSlugs, agent.Slug) | ||
| } | ||
| configMode = parsedCfg.Dispatch.Mode | ||
| enrolledRepos = parsedCfg.EnabledRepos() | ||
| if parsed, parseErr := config.ParseOrgConfig(cfgData); parseErr == nil { | ||
| parsedCfg = parsed | ||
| configMode = parsed.Dispatch.Mode | ||
| enrolledRepos = parsed.EnabledRepos() | ||
| } else { | ||
| printer.StepWarn(fmt.Sprintf("Could not parse existing config: %v; using defaults", parseErr)) | ||
| } | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] edge-case Behavioral change: old code appended empty slugs from config agents, new code correctly skips them. This improvement should be noted in the PR description. |
||
|
|
||
| agentSlugs = discoverAgentSlugs(ctx, client, org, forge.ConfigRepoName, "main", appSet, parsedCfg, printer) | ||
|
|
||
| if len(agentSlugs) == 0 { | ||
| // Config unavailable — assume default app naming convention and | ||
| // also include any legacy app-set prefixes so that apps created | ||
| // under an older version are not silently skipped. | ||
| // Neither harness files nor config agents found — assume default | ||
| // app naming convention and also include any legacy app-set | ||
| // prefixes so that apps created under an older version are not | ||
| // silently skipped. | ||
| for _, role := range config.DefaultAgentRoles() { | ||
| agentSlugs = append(agentSlugs, appsetup.AppSlug(appSet, role)) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
|
|
||
| "github.com/fullsend-ai/fullsend/internal/appsetup" | ||
| "github.com/fullsend-ai/fullsend/internal/config" | ||
| "github.com/fullsend-ai/fullsend/internal/forge" | ||
| "github.com/fullsend-ai/fullsend/internal/harness" | ||
| "github.com/fullsend-ai/fullsend/internal/ui" | ||
| ) | ||
|
|
||
| // discoverAgentSlugs discovers agent slugs using a three-tier fallback: | ||
| // | ||
| // 1. Harness wrapper files in the config repo (via DiscoverRemoteAgents) | ||
| // 2. config.yaml agents: block (legacy, emits deprecation warning) | ||
| // 3. Empty — caller is responsible for its own default-role fallback | ||
| // | ||
| // The ref parameter specifies the git ref for harness directory discovery. | ||
| // When an agent has a role but no slug, the slug is derived from appSet and | ||
| // the role using the standard naming convention. | ||
| func discoverAgentSlugs(ctx context.Context, client forge.Client, owner, configRepo, ref, appSet string, cfg *config.OrgConfig, printer *ui.Printer) []string { | ||
| agents, err := harness.DiscoverRemoteAgents(ctx, client, owner, configRepo, ref) | ||
| if err != nil { | ||
| printer.StepWarn(fmt.Sprintf("some harness files could not be read: %v", err)) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [critical] api-contract discoverAgentSlugs calls harness.DiscoverRemoteAgents(ctx, client, owner, configRepo, ref) but this function does not exist in the harness package. The only discovery function is harness.DiscoverAgents(dir string), which operates on a local filesystem directory. This PR will not compile. Suggested fix: Add harness.DiscoverRemoteAgents with the appropriate signature to the harness package, or change the call site to use an existing API. |
||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] error-handling Warning message 'some harness files could not be read' is misleading for wholesale failures (network error, repo not found) vs per-file parse errors. |
||
| if len(agents) > 0 { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [medium] test-inadequate No test covers the case where DiscoverRemoteAgents returns a non-nil error with zero valid agents. This is a distinct code path (err != nil but len(agents) == 0) that differs from the no-harness-directory case because the warning is still emitted. Suggested fix: Add a test where DiscoverRemoteAgents returns an error and no valid agents, verifying fallthrough to config.yaml with warning. |
||
| seen := make(map[string]bool, len(agents)) | ||
| var slugs []string | ||
| for _, a := range agents { | ||
| slug := a.Slug | ||
| if slug == "" && a.Role != "" { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [low] test-inadequate No test covers the case where a harness file produces an agent with both role and slug empty. |
||
| slug = appsetup.AppSlug(appSet, a.Role) | ||
| } | ||
| if slug == "" { | ||
| continue | ||
| } | ||
| if !seen[slug] { | ||
| seen[slug] = true | ||
| slugs = append(slugs, slug) | ||
| } | ||
| } | ||
| if len(slugs) > 0 { | ||
| return slugs | ||
| } | ||
| } | ||
|
|
||
| if cfg != nil && len(cfg.Agents) > 0 { | ||
| printer.StepWarn("agent identity read from config.yaml agents: block; migrate to harness files with role/slug fields") | ||
| var slugs []string | ||
| seen := make(map[string]bool, len(cfg.Agents)) | ||
| for _, a := range cfg.Agents { | ||
| slug := a.Slug | ||
| if slug == "" && a.Role != "" { | ||
| slug = appsetup.AppSlug(appSet, a.Role) | ||
| } | ||
| if slug != "" && !seen[slug] { | ||
| seen[slug] = true | ||
| slugs = append(slugs, slug) | ||
| } | ||
| } | ||
| if len(slugs) > 0 { | ||
| return slugs | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,185 @@ | ||
| package cli | ||
|
|
||
| import ( | ||
| "context" | ||
| "strings" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| "github.com/stretchr/testify/require" | ||
|
|
||
| "github.com/fullsend-ai/fullsend/internal/config" | ||
| "github.com/fullsend-ai/fullsend/internal/forge" | ||
| "github.com/fullsend-ai/fullsend/internal/ui" | ||
| ) | ||
|
|
||
| func TestDiscoverAgentSlugs_HarnessFirst(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
| client.DirContents = map[string][]forge.DirectoryEntry{ | ||
| "acme/.fullsend/harness@main": { | ||
| {Path: "harness/triage.yaml", Type: "file"}, | ||
| {Path: "harness/coder.yaml", Type: "file"}, | ||
| }, | ||
| } | ||
| client.FileContentsRef = map[string][]byte{ | ||
| "acme/.fullsend/harness/triage.yaml@main": []byte("role: triage\nslug: acme-triage\n"), | ||
| "acme/.fullsend/harness/coder.yaml@main": []byte("role: coder\nslug: acme-coder\n"), | ||
| } | ||
|
|
||
| cfg := &config.OrgConfig{ | ||
| Agents: []config.AgentEntry{ | ||
| {Role: "triage", Slug: "old-triage"}, | ||
| }, | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", cfg, printer) | ||
|
|
||
| require.Len(t, slugs, 2) | ||
| assert.Contains(t, slugs, "acme-triage") | ||
| assert.Contains(t, slugs, "acme-coder") | ||
| assert.NotContains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_FallsBackToAgentsBlock(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
|
|
||
| cfg := &config.OrgConfig{ | ||
| Agents: []config.AgentEntry{ | ||
| {Role: "triage", Slug: "acme-triage"}, | ||
| {Role: "coder", Slug: "acme-coder"}, | ||
| }, | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", cfg, printer) | ||
|
|
||
| require.Len(t, slugs, 2) | ||
| assert.Contains(t, slugs, "acme-triage") | ||
| assert.Contains(t, slugs, "acme-coder") | ||
| assert.Contains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_HarnessWithoutSlug_DerivesFromRole(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
| client.DirContents = map[string][]forge.DirectoryEntry{ | ||
| "acme/.fullsend/harness@main": { | ||
| {Path: "harness/triage.yaml", Type: "file"}, | ||
| }, | ||
| } | ||
| client.FileContentsRef = map[string][]byte{ | ||
| "acme/.fullsend/harness/triage.yaml@main": []byte("role: triage\n"), | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", nil, printer) | ||
|
|
||
| require.Len(t, slugs, 1) | ||
| assert.Equal(t, "fullsend-ai-triage", slugs[0]) | ||
| assert.NotContains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_ConfigAgentWithoutSlug_DerivesFromRole(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
|
|
||
| cfg := &config.OrgConfig{ | ||
| Agents: []config.AgentEntry{ | ||
| {Role: "triage"}, | ||
| }, | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", cfg, printer) | ||
|
|
||
| require.Len(t, slugs, 1) | ||
| assert.Equal(t, "fullsend-ai-triage", slugs[0]) | ||
| assert.Contains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_NeitherSource_ReturnsNil(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", nil, printer) | ||
|
|
||
| assert.Nil(t, slugs) | ||
| assert.NotContains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_DeduplicatesSlugs(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
| client.DirContents = map[string][]forge.DirectoryEntry{ | ||
| "acme/.fullsend/harness@main": { | ||
| {Path: "harness/coder.yaml", Type: "file"}, | ||
| {Path: "harness/fix.yaml", Type: "file"}, | ||
| }, | ||
| } | ||
| client.FileContentsRef = map[string][]byte{ | ||
| "acme/.fullsend/harness/coder.yaml@main": []byte("role: coder\nslug: acme-coder\n"), | ||
| "acme/.fullsend/harness/fix.yaml@main": []byte("role: fix\nslug: acme-coder\n"), | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", nil, printer) | ||
|
|
||
| require.Len(t, slugs, 1) | ||
| assert.Equal(t, "acme-coder", slugs[0]) | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_EmptyAgentsBlock_ReturnsNil(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
|
|
||
| cfg := &config.OrgConfig{ | ||
| Agents: []config.AgentEntry{}, | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", cfg, printer) | ||
|
|
||
| assert.Nil(t, slugs) | ||
| assert.NotContains(t, buf.String(), "agents: block") | ||
| } | ||
|
|
||
| func TestDiscoverAgentSlugs_PartialError_UsesValidAgents(t *testing.T) { | ||
| client := forge.NewFakeClient() | ||
| client.DirContents = map[string][]forge.DirectoryEntry{ | ||
| "acme/.fullsend/harness@main": { | ||
| {Path: "harness/triage.yaml", Type: "file"}, | ||
| {Path: "harness/broken.yaml", Type: "file"}, | ||
| }, | ||
| } | ||
| client.FileContentsRef = map[string][]byte{ | ||
| "acme/.fullsend/harness/triage.yaml@main": []byte("role: triage\nslug: acme-triage\n"), | ||
| "acme/.fullsend/harness/broken.yaml@main": []byte("invalid: [yaml"), | ||
| } | ||
|
|
||
| cfg := &config.OrgConfig{ | ||
| Agents: []config.AgentEntry{ | ||
| {Role: "triage", Slug: "old-triage"}, | ||
| }, | ||
| } | ||
|
|
||
| var buf strings.Builder | ||
| printer := ui.New(&buf) | ||
|
|
||
| slugs := discoverAgentSlugs(context.Background(), client, "acme", ".fullsend", "main", "fullsend-ai", cfg, printer) | ||
|
|
||
| require.Len(t, slugs, 1) | ||
| assert.Equal(t, "acme-triage", slugs[0]) | ||
| assert.Contains(t, buf.String(), "some harness files could not be read") | ||
| assert.NotContains(t, buf.String(), "agents: block") | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[low] test-inadequate
Both runUninstall and runGitHubUninstall pass hardcoded ref 'main' to discoverAgentSlugs. No test verifies behavior with non-main default branches.