Merge pull request #1762 from entireio/fix/agent-help-trails-availability · Entire
Merge pull request #1762 from entireio/fix/agent-help-trails-availability
002d59d→main·
gtrrz-victor·yesterday·4 files·+255 added/-18 removed
fix(agent-help): refresh trails availability
Changes
4
cmd/entire/cli
Magent_help_cmd.go+60/-2
Magent_help_cmd_test.go+151/-16
settings
Msettings.go+8
Mtrail_context_cache.go+36
7 unmodified lines
8
9
10
11
12
13
14
54 unmodified lines
69
70
71
71
72
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
1 unmodified line
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
7 unmodified lines
"time"
"unicode"
"github.com/entireio/cli/cmd/entire/cli/logging"
"github.com/spf13/cobra"
flag "github.com/spf13/pflag"
)
54 unmodified lines
// agentHelpRepoContext resolves the origin remote ONCE and derives both the repo
// line (forge/owner/repo, or "" when it can't be determined — no origin /
// detached HEAD — so the renderer degrades gracefully) and whether trails are
// enabled for that scope. Previously the repo line and the trails check resolved
// origin independently, costing two git subprocesses per `entire agent-help` run.
// enabled for that scope. Unlike the prompt-path gate, agent-help is an explicit
// command and can afford to refresh an absent or stale enablement decision rather
// than incorrectly treating an unknown cache entry as "trails unavailable".
func agentHelpRepoContext(ctx context.Context) (repoLine string, trailsEnabled bool) {
return agentHelpRepoContextWithRefresh(ctx, refreshAgentHelpTrailsEnabledCacheIfStaleForScope)
}
// refreshAgentHelpTrailsEnabledCacheIfStaleForScope refreshes synchronously
// because agent-help is an explicit command whose output must reflect the
// current availability decision. SessionStart uses the detached
// refreshTrailsEnabledCacheIfStaleForScope path instead to avoid hook latency.
func refreshAgentHelpTrailsEnabledCacheIfStaleForScope(ctx context.Context, scope trailEnablementScope) error {
if cachedTrailsEnablementForScope(ctx, scope, time.Now()) != trailEnablementCacheUnknown {
return nil
}
if !scope.Supported {
return saveTrailsEnabledForScope(ctx, scope, false, time.Now())
}
client, err := NewAuthenticatedAPIClient(ctx, false)
if err != nil {
return err
}
_, err = refreshTrailsEnabledCacheForScope(ctx, client, scope)
return err
}
// agentHelpRepoContextWithRefresh keeps the refresh dependency explicit so the
// cache-miss behavior can be tested without authenticating against a real API.
func agentHelpRepoContextWithRefresh(
ctx context.Context,
refresh func(context.Context, trailEnablementScope) error,
) (repoLine string, trailsEnabled bool) {
scope, err := currentTrailEnablementScope(ctx)
if err != nil {
return "", false
}
if scope.Forge != "" && scope.Owner != "" && scope.Repo != "" {
repoLine = scope.RepoKey
}
now := time.Now()
if decision := cachedTrailsEnablementForScope(ctx, scope, now); decision != trailEnablementCacheUnknown {
return repoLine, decision == trailEnablementCacheEnabled
}
// ResolveDataAPIToken performs data-host discovery before it can reject a
// missing login. The scope already carries the locally resolved auth identity,
// so avoid making an unauthenticated first run wait on a network request that
// cannot produce an enabled decision.
if scope.AuthKey == "" {
return repoLine, false
}
if recentAgentHelpTrailsRefreshFailure(ctx, scope, now) {
return repoLine, false
}
refreshCtx, cancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout)
defer cancel()
if err := refresh(refreshCtx, scope); err != nil {
// A separate short backoff keeps an offline authenticated user from paying
// this timeout on every agent-help invocation. It must not alter the shared
// enablement decision, which SessionStart uses for context injection.
if cacheErr := saveAgentHelpTrailsRefreshFailure(ctx, scope, time.Now()); cacheErr != nil {
logging.Debug(ctx, "failed to save agent-help trails refresh backoff", "error", cacheErr)
}
return repoLine, false
}
return repoLine, cachedTrailsEnablementForScope(ctx, scope, time.Now()) == trailEnablementCacheEnabled
}
``
Mcmd/entire/cli/agent_help_cmd.go+60/-2
1 unmodified line
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 75 unmodified lines
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 99 234 235 236 102 237 238 239 240 25 unmodified lines
266 267 268 134 269 270 271 272 4 unmodified lines
277 278 279 145 280 281 282 283 13 unmodified lines
297 298 299 165 300 301 302 303 169 304 305 306 307 5 unmodified lines
313 314 315 181 316 317 318 319 43 unmodified lines
363 364 365 231 366 367 368 369 3 unmodified lines
373 374 375 241 376 377 378 379 10 unmodified lines
390 391 392 258 393 394 395 396 397 398 264 399 400 401 402 10 unmodified lines
413 414 415 281 416 417 418 419 1 unmodified line
421 422 423 289 424 425 426 427 1 unmodified line
429 430 431 297 432 433 434 435 10 unmodified lines
446 447 448 314 315 449 450 451 452 453
1 unmodified line
import ( "bytes" "context" "encoding/json" "errors" "io" "os/exec" "strings" "testing" "time"
"github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/spf13/cobra" )
const agentHelpTestRepo = "gh/acme/app"
// commandNames returns the Use-name of each command, for assertions. func commandNames(cmds []*cobra.Command) []string { names := make([]string, 0, len(cmds)) 75 unmodified lines
}
// agent-help is invoked explicitly, so an absent cache entry must trigger the
// repo-scoped trails availability check instead of being treated as disabled.
// Not parallel: changes the process working directory.
func TestAgentHelpRepoContext_RefreshesUnknownTrailsEnablement(t *testing.T) {
t.Setenv("ENTIRE_TOKEN", makeTestJWT(t, {"iss":"https://auth.entire.io","sub":"user-1","handle":"alice","aud":"https://entire.io"}))
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
testutil.IsolateGitConfigEnv(t)
t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir())
t.Setenv("XDG_CACHE_HOME", t.TempDir())
cmd := exec.CommandContext(t.Context(), "git", "remote", "add", "origin", "git@github.com:acme/app.git")
cmd.Dir = repoDir
cmd.Env = testutil.GitIsolatedEnv()
if err := cmd.Run(); err != nil {
t.Fatalf("git remote add: %v", err)
}
t.Chdir(repoDir)
refreshCalls := 0 repoLine, enabled := agentHelpRepoContextWithRefresh(t.Context(), func(ctx context.Context, scope trailEnablementScope) error { refreshCalls++ if scope.RepoKey != agentHelpTestRepo { t.Fatalf("refresh scope repo = %q, want %s", scope.RepoKey, agentHelpTestRepo) } return saveTrailsEnabledForScope(ctx, scope, true, time.Now()) })
if refreshCalls != 1 { t.Fatalf("refresh calls = %d, want 1", refreshCalls) } if repoLine != agentHelpTestRepo { t.Errorf("repo line = %q, want %s", repoLine, agentHelpTestRepo) } if !enabled { t.Fatal("trails should be enabled after the availability refresh succeeds") } }
// A failed availability refresh is cached only long enough to prevent repeated
// blocking calls during a network outage, then becomes retryable.
// Not parallel: changes the process working directory and auth environment.
func TestAgentHelpRepoContext_CachesRefreshFailureBriefly(t *testing.T) {
t.Setenv("ENTIRE_TOKEN", makeTestJWT(t, {"iss":"https://auth.entire.io","sub":"user-1","handle":"alice","aud":"https://entire.io"}))
repoDir := t.TempDir()
testutil.InitRepo(t, repoDir)
cmd := exec.CommandContext(t.Context(), "git", "remote", "add", "origin", "git@github.com:acme/app.git")
cmd.Dir = repoDir
cmd.Env = testutil.GitIsolatedEnv()
if err := cmd.Run(); err != nil {
t.Fatalf("git remote add: %v", err)
}
t.Chdir(repoDir)
refreshCalls := 0 _, enabled := agentHelpRepoContextWithRefresh(t.Context(), func(context.Context, trailEnablementScope) error { refreshCalls++ return errors.New("offline") }) if enabled { t.Fatal("trails should not be advertised after a failed availability refresh") } if refreshCalls != 1 { t.Fatalf("refresh calls after first invocation = %d, want 1", refreshCalls) }
// The failed attempt leaves a short-lived agent-help-only backoff, so another // invocation does not repeat the blocking refresh. _, enabled = agentHelpRepoContextWithRefresh(t.Context(), func(context.Context, trailEnablementScope) error { refreshCalls++ return errors.New("refresh should have been suppressed by the failure cache") }) if enabled { t.Fatal("trails should remain unadvertised during the refresh-failure backoff") } if refreshCalls != 1 { t.Fatalf("refresh calls after second invocation = %d, want 1", refreshCalls) }
scope, err := currentTrailEnablementScope(t.Context()) if err != nil { t.Fatalf("resolve trail scope: %v", err) } // The shared decision remains unknown, so SessionStart is not prevented from // doing its own authoritative refresh and context-injection decision. if got := cachedTrailsEnablementForScope(t.Context(), scope, time.Now()); got != trailEnablementCacheUnknown { t.Fatalf("shared trails cache after agent-help failure = %v, want unknown", got) } // The agent-help-only marker expires after the short backoff and permits a // later help invocation to retry. if recentAgentHelpTrailsRefreshFailure(t.Context(), scope, time.Now().Add(agentHelpTrailsRefreshFailureBackoff+time.Second)) { t.Fatal("agent-help refresh failure should expire after the backoff") } }
// Without a local auth identity, refreshing cannot produce a usable trails // decision. Skip it locally so agent-help does not block on API discovery before // auth eventually reports that the user is not logged in. // Not parallel: changes the process working directory and auth environment. func TestAgentHelpRepoContext_SkipsRefreshWithoutLocalIdentity(t *testing.T) { t.Setenv("ENTIRE_TOKEN", "") t.Setenv("ENTIRE_CONFIG_DIR", t.TempDir()) repoDir := t.TempDir() testutil.InitRepo(t, repoDir) cmd := exec.CommandContext(t.Context(), "git", "remote", "add", "origin", "git@github.com:acme/app.git") cmd.Dir = repoDir cmd.Env = testutil.GitIsolatedEnv() if err := cmd.Run(); err != nil { t.Fatalf("git remote add: %v", err) } t.Chdir(repoDir)
refreshCalls := 0 repoLine, enabled := agentHelpRepoContextWithRefresh(t.Context(), func(context.Context, trailEnablementScope) error { refreshCalls++ return nil })
if refreshCalls != 0 { t.Fatalf("refresh calls = %d, want 0 without a local auth identity", refreshCalls) } if repoLine != agentHelpTestRepo { t.Errorf("repo line = %q, want %s", repoLine, agentHelpTestRepo) } if enabled { t.Fatal("trails should not be advertised without a local auth identity") } }
// Drilling into a trail-gated command is blocked when trails are disabled. func TestRunAgentHelp_TrailDrillGatedOnTrailsEnabled(t *testing.T) { t.Parallel() root := NewRootCmd()
if _, err := runAgentHelp(root, []string{"trail"}, "gh/acme/app", false, true); err != nil { if _, err := runAgentHelp(root, []string{"trail"}, agentHelpTestRepo, false, true); err != nil { t.Errorf("trail drill should resolve when trails enabled: %v", err) } _, err := runAgentHelp(root, []string{"trail"}, "gh/acme/app", false, false) _, err := runAgentHelp(root, []string{"trail"}, agentHelpTestRepo, false, false) if err == nil { t.Fatalf("trail drill should be unavailable when trails disabled") } } }
// Other test cases here ... (omitted for brevity)