diff --git a/cmd/entire/cli/api/trails.go b/cmd/entire/cli/api/trails.go index db865be2d6..97934c9f96 100644 --- a/cmd/entire/cli/api/trails.go +++ b/cmd/entire/cli/api/trails.go @@ -8,14 +8,8 @@ import ( "net/url" ) -// TrailsEnabled reports whether the trails feature is enabled for the repo on -// the API. It probes the trails list endpoint (limit=1): a 2xx response means -// trails are provisioned/enabled for the repo, while 404/403 (and any other -// non-2xx) mean they are not enabled or not accessible to this caller. -// -// Transport errors are returned to the caller (with enabled=false) so a -// "couldn't reach the API" outcome is distinguishable from a definitive -// "not enabled". +// TrailsEnabled probes trail availability: 2xx=true, 403/404/410=false, +// everything else ambiguous. func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) (bool, error) { resp, err := c.Get(ctx, fmt.Sprintf("/api/v1/trails/%s/%s/%s?limit=1", url.PathEscape(forge), url.PathEscape(owner), url.PathEscape(repo))) @@ -25,5 +19,13 @@ func (c *Client) TrailsEnabled(ctx context.Context, forge, owner, repo string) ( defer resp.Body.Close() // Drain (bounded) so net/http can reuse the connection; the body is unused. _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16)) //nolint:errcheck // best-effort drain - return resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices, nil + if resp.StatusCode >= http.StatusOK && resp.StatusCode < http.StatusMultipleChoices { + return true, nil + } + switch resp.StatusCode { + case http.StatusForbidden, http.StatusNotFound, http.StatusGone: + return false, nil + default: + return false, fmt.Errorf("probe trails enablement: unexpected status %s", resp.Status) + } } diff --git a/cmd/entire/cli/api/trails_test.go b/cmd/entire/cli/api/trails_test.go index a192977a1f..ac2ee154ef 100644 --- a/cmd/entire/cli/api/trails_test.go +++ b/cmd/entire/cli/api/trails_test.go @@ -48,7 +48,9 @@ func TestClient_TrailsEnabled(t *testing.T) { {"enabled empty (200)", http.StatusOK, `{"trails":[]}`, true, true}, {"not enabled (404)", http.StatusNotFound, `{"error":"not found"}`, false, true}, {"forbidden (403)", http.StatusForbidden, `{"error":"forbidden"}`, false, true}, - {"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, true}, + {"gone (410)", http.StatusGone, `{"error":"gone"}`, false, true}, + {"unauthorized (401)", http.StatusUnauthorized, `{"error":"unauthorized"}`, false, false}, + {"server error (500)", http.StatusInternalServerError, `{"error":"boom"}`, false, false}, } for _, tt := range tests { diff --git a/cmd/entire/cli/auth/contexts.go b/cmd/entire/cli/auth/contexts.go index 3baf39a727..b2302908d8 100644 --- a/cmd/entire/cli/auth/contexts.go +++ b/cmd/entire/cli/auth/contexts.go @@ -4,6 +4,7 @@ import ( "errors" "fmt" "net/url" + "os" "strings" "time" @@ -147,6 +148,38 @@ func sameIssuer(a, b string) bool { return strings.TrimRight(a, "/") == strings.TrimRight(b, "/") } +// LocalIdentityCacheKey returns a non-secret local auth identity key. +func LocalIdentityCacheKey() (string, error) { + if raw := strings.TrimSpace(os.Getenv(EnvTokenVar)); raw != "" { + claims, err := tokens.ParseClaims(raw) + if err != nil { + return "", fmt.Errorf("parse %s claims: %w", EnvTokenVar, err) + } + return strings.Join([]string{ + "env", + strings.TrimRight(claims.Issuer, "/"), + claims.Subject, + claims.Handle, + strings.Join(claims.Audience, ","), + }, "|"), nil + } + + c, ok, err := activeContext() + if err != nil { + return "", err + } + if !ok { + return "", nil + } + return strings.Join([]string{ + "context", + strings.TrimRight(c.CoreURL, "/"), + c.Name, + c.Handle, + c.KeychainService, + }, "|"), nil +} + // LoginTokenForContext returns the login JWT stored for c, read from the // OS keyring slot the context points at. The encoded expiry is stripped; // the server is the authority on validity and the device-flow login holds diff --git a/cmd/entire/cli/auth/contexts_test.go b/cmd/entire/cli/auth/contexts_test.go index dc5f560fde..378f8c43b8 100644 --- a/cmd/entire/cli/auth/contexts_test.go +++ b/cmd/entire/cli/auth/contexts_test.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strings" "testing" "time" @@ -27,6 +28,48 @@ func makeJWT(t *testing.T, payloadJSON string) string { return header + "." + payload + "." + enc.EncodeToString([]byte("sig")) } +func TestLocalIdentityCacheKey_ActiveContext(t *testing.T) { + cfgDir := t.TempDir() + t.Setenv("ENTIRE_CONFIG_DIR", cfgDir) + t.Setenv(EnvTokenVar, "") + + ctx := &contexts.Context{ + Name: "alice@core", + CoreURL: "https://core.example.com/", + Handle: "alice", + KeychainService: "entire-core:https://core.example.com", + } + if err := contexts.Save(cfgDir, &contexts.File{CurrentContext: ctx.Name, Contexts: []*contexts.Context{ctx}}); err != nil { + t.Fatalf("save contexts: %v", err) + } + + got, err := LocalIdentityCacheKey() + if err != nil { + t.Fatalf("LocalIdentityCacheKey: %v", err) + } + want := "context|https://core.example.com|alice@core|alice|entire-core:https://core.example.com" + if got != want { + t.Fatalf("cache key = %q, want %q", got, want) + } +} + +func TestLocalIdentityCacheKey_EnvToken(t *testing.T) { + token := makeJWT(t, `{"iss":"https://core.example.com/","sub":"svc-1","handle":"robot","aud":"https://api.example.com"}`) + t.Setenv(EnvTokenVar, token) + + got, err := LocalIdentityCacheKey() + if err != nil { + t.Fatalf("LocalIdentityCacheKey: %v", err) + } + want := "env|https://core.example.com|svc-1|robot|https://api.example.com" + if got != want { + t.Fatalf("cache key = %q, want %q", got, want) + } + if strings.Contains(got, token) { + t.Fatalf("cache key appears to contain raw JWT material: %q", got) + } +} + // RecordLoginContext must persist the refresh token before the access token, // so a failed access write never commits a fresh access JWT against a stale // refresh token left over from an earlier login. diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 3dd6b64438..692a25aa4d 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -146,6 +146,23 @@ func handleLifecycleSessionStart(ctx context.Context, ag agent.Agent, event *age slog.String("error", hintErr.Error())) } + // Resolve scope before the TurnStart prompt path. + refreshCtx, refreshCancel := context.WithTimeout(ctx, trailEnablementSessionStartRefreshTimeout) + if scope, scopeErr := currentTrailEnablementScope(refreshCtx); scopeErr != nil { + logging.Debug(logCtx, "trails enablement refresh skipped", + slog.String("error", scopeErr.Error())) + } else { + if hintErr := saveTrailEnablementScopeHint(ctx, event.SessionID, scope); hintErr != nil { + logging.Debug(logCtx, "failed to cache trails scope hint", + slog.String("error", hintErr.Error())) + } + if refreshErr := refreshTrailsEnabledCacheIfStaleForScope(refreshCtx, scope); refreshErr != nil { + logging.Debug(logCtx, "trails enablement refresh skipped", + slog.String("error", refreshErr.Error())) + } + } + refreshCancel() + // Build informational message — warn early if repo has no commits yet, // since checkpoints require at least one commit to work. message := sessionStartMessage(ag.Name(), false) @@ -384,12 +401,14 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even } logCtx := logging.WithAgent(logging.WithComponent(ctx, "lifecycle"), ag.Name()) - // Decide once per session, recorded on the session state itself (not a - // separate marker file). Winning the check-and-set means this turn owns the - // decision. trailsEnabledForRepo only reads clone-local cached enablement; - // the API refresh happens earlier on `entire enable`, outside the prompt path. - // Marking "decided" before checking the cache means a missing/stale false - // cache fails closed (no hint for this session) rather than retrying/spamming. + // Unknown cache leaves the session retryable. + scope, scopeOK, scopeErr := loadTrailEnablementScopeHint(ctx, event.SessionID) + if scopeErr != nil { + logging.Warn(logCtx, "failed to load trails scope hint", + slog.String("error", scopeErr.Error())) + return + } + decision := trailEnablementCacheUnknown mutated := false mutErr := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { if state.ContextInjectionDecided { @@ -401,6 +420,13 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even if state.Kind != "" { return strategy.ErrMutationSkip } + if !scopeOK { + return strategy.ErrMutationSkip + } + decision = cachedTrailsEnablementForScope(ctx, scope, time.Now()) + if decision == trailEnablementCacheUnknown { + return strategy.ErrMutationSkip + } state.ContextInjectionDecided = true mutated = true return nil @@ -414,12 +440,7 @@ func emitContextInjection(ctx context.Context, ag agent.Agent, event *agent.Even // state failed, mutErr was non-nil above and we returned without injecting, // leaving a later turn free to retry safely. won := mutErr == nil && mutated - if !won { - return // already decided for this session, skipped kind, or no session state yet - } - - // Only advertise trails when they're literally enabled for this repo on the API. - if !trailsEnabledForRepo(ctx) { + if !won || decision != trailEnablementCacheEnabled { return } diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 7b081a2556..09c52a9308 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -3,6 +3,7 @@ package cli import ( "context" "os" + "os/exec" "path/filepath" "strings" "testing" @@ -1140,6 +1141,78 @@ func TestHandleLifecycleTurnStart_WritesPromptContent(t *testing.T) { } } +type mockContextInjectorAgent struct { + mockLifecycleAgent +} + +var _ agent.ContextInjector = (*mockContextInjectorAgent)(nil) + +func (m *mockContextInjectorAgent) InjectionEvent() agent.EventType { return agent.TurnStart } + +func (m *mockContextInjectorAgent) RenderContextInjection(agent.ContextInjection) ([]byte, error) { + return nil, nil +} + +func addGitHubOriginForLifecycleTest(t *testing.T, repoDir string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/repo.git") + cmd.Dir = repoDir + cmd.Env = testutil.GitIsolatedEnv() + require.NoError(t, cmd.Run()) +} + +func TestHandleLifecycleTurnStart_ContextInjectionUnknownCacheDoesNotMarkDecided(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir(). + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + addGitHubOriginForLifecycleTest(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + + ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()} + sessionID := "test-trail-inject-unknown" + event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()} + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + state, err := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.False(t, state.ContextInjectionDecided, "unknown/missing cache should not permanently suppress later injection") +} + +func TestHandleLifecycleTurnStart_ContextInjectionFreshTrueMarksDecided(t *testing.T) { + // Cannot use t.Parallel() because we use t.Chdir(). + tmpDir := t.TempDir() + testutil.InitRepo(t, tmpDir) + testutil.WriteFile(t, tmpDir, "init.txt", "init") + testutil.GitAdd(t, tmpDir, "init.txt") + testutil.GitCommit(t, tmpDir, "init") + addGitHubOriginForLifecycleTest(t, tmpDir) + t.Chdir(tmpDir) + paths.ClearWorktreeRootCache() + session.ClearGitCommonDirCache() + require.NoError(t, saveTrailsEnabledForRepo(context.Background(), true)) + + ag := &mockContextInjectorAgent{mockLifecycleAgent: *newMockAgent()} + sessionID := "test-trail-inject-true" + scope, err := currentTrailEnablementScope(context.Background()) + require.NoError(t, err) + require.NoError(t, saveTrailEnablementScopeHint(context.Background(), sessionID, scope)) + event := &agent.Event{Type: agent.TurnStart, SessionID: sessionID, Prompt: "hello", Timestamp: time.Now()} + + require.NoError(t, handleLifecycleTurnStart(context.Background(), ag, event)) + + state, err := strategy.LoadSessionState(context.Background(), sessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.True(t, state.ContextInjectionDecided, "fresh true cache should make a final injection decision") +} + func TestHandleLifecycleTurnStart_RecordsGenericSkillSlashEvent(t *testing.T) { // Cannot use t.Parallel() because we use t.Chdir() tmpDir := t.TempDir() diff --git a/cmd/entire/cli/review/migration.go b/cmd/entire/cli/review/migration.go index ad2fe8a3c2..94900234d3 100644 --- a/cmd/entire/cli/review/migration.go +++ b/cmd/entire/cli/review/migration.go @@ -90,11 +90,10 @@ func maybePromptReviewSettingsMigration( return fmt.Errorf("review settings migration prompt: %w", err) } if !migrate { - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.ReviewMigrationDismissed = true - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.ReviewMigrationDismissed = true + return nil + }); err != nil { return fmt.Errorf("save migration dismissal: %w", err) } return nil @@ -154,59 +153,50 @@ func migrateProjectReviewSettings(ctx context.Context, project *projectReviewSet return false, nil } - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return false, fmt.Errorf("load review preferences for migration: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - preferencesChanged := false - if project.hasReview && !isJSONNull(project.review) { - var projectReview map[string]settings.ReviewConfig - if err := json.Unmarshal(project.review, &projectReview); err != nil { - return false, fmt.Errorf("parsing project review settings: %w", err) - } - if len(projectReview) > 0 { - merged, mergedOK, conflicts := mergeProjectReviewIntoPrefs(prefs.Review, projectReview) - if len(conflicts) > 0 { - return false, fmt.Errorf( - "review settings exist in both %s and clone-local preferences for agent(s) %v; "+ - "reconcile manually by removing the redundant keys from %s, then re-run `entire review`", - project.path, conflicts, project.path, - ) + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + if project.hasReview && !isJSONNull(project.review) { + var projectReview map[string]settings.ReviewConfig + if err := json.Unmarshal(project.review, &projectReview); err != nil { + return fmt.Errorf("parsing project review settings: %w", err) } - if mergedOK { - prefs.Review = merged - preferencesChanged = true + if len(projectReview) > 0 { + merged, mergedOK, conflicts := mergeProjectReviewIntoPrefs(prefs.Review, projectReview) + if len(conflicts) > 0 { + return fmt.Errorf( + "review settings exist in both %s and clone-local preferences for agent(s) %v; "+ + "reconcile manually by removing the redundant keys from %s, then re-run `entire review`", + project.path, conflicts, project.path, + ) + } + if mergedOK { + prefs.Review = merged + preferencesChanged = true + } } } - } - if project.hasFixAgent && !isJSONNull(project.fixAgent) { - var fixAgent string - if err := json.Unmarshal(project.fixAgent, &fixAgent); err != nil { - return false, fmt.Errorf("parsing project review_fix_agent: %w", err) - } - if fixAgent != "" { - if prefs.ReviewFixAgent != "" && prefs.ReviewFixAgent != fixAgent { - return false, fmt.Errorf( - "review_fix_agent differs between %s (%q) and clone-local preferences (%q); "+ - "reconcile manually by removing review_fix_agent from %s, then re-run `entire review`", - project.path, fixAgent, prefs.ReviewFixAgent, project.path, - ) + if project.hasFixAgent && !isJSONNull(project.fixAgent) { + var fixAgent string + if err := json.Unmarshal(project.fixAgent, &fixAgent); err != nil { + return fmt.Errorf("parsing project review_fix_agent: %w", err) } - if prefs.ReviewFixAgent == "" { - prefs.ReviewFixAgent = fixAgent - preferencesChanged = true + if fixAgent != "" { + if prefs.ReviewFixAgent != "" && prefs.ReviewFixAgent != fixAgent { + return fmt.Errorf( + "review_fix_agent differs between %s (%q) and clone-local preferences (%q); "+ + "reconcile manually by removing review_fix_agent from %s, then re-run `entire review`", + project.path, fixAgent, prefs.ReviewFixAgent, project.path, + ) + } + if prefs.ReviewFixAgent == "" { + prefs.ReviewFixAgent = fixAgent + preferencesChanged = true + } } } - } - - if preferencesChanged { - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { - return false, fmt.Errorf("save review preferences for migration: %w", err) - } + return nil + }); err != nil { + return false, fmt.Errorf("save review preferences for migration: %w", err) } delete(project.raw, "review") diff --git a/cmd/entire/cli/review/picker.go b/cmd/entire/cli/review/picker.go index caf226f4c3..234c4ec288 100644 --- a/cmd/entire/cli/review/picker.go +++ b/cmd/entire/cli/review/picker.go @@ -256,31 +256,21 @@ func MergePickerResults(existing map[string]settings.ReviewConfig, offered map[s } func SaveReviewFixAgent(ctx context.Context, agentName string) error { - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return fmt.Errorf("load review preferences before save: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.ReviewFixAgent = agentName - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.ReviewFixAgent = agentName + return nil + }); err != nil { return fmt.Errorf("save review preferences: %w", err) } return nil } func saveReviewConfigAndFixAgent(ctx context.Context, review map[string]settings.ReviewConfig, fixAgent string) error { - prefs, err := settings.LoadClonePreferences(ctx) - if err != nil { - return fmt.Errorf("load review preferences before save: %w", err) - } - if prefs == nil { - prefs = &settings.ClonePreferences{} - } - prefs.Review = review - prefs.ReviewFixAgent = fixAgent - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.Review = review + prefs.ReviewFixAgent = fixAgent + return nil + }); err != nil { return fmt.Errorf("save review preferences: %w", err) } return nil diff --git a/cmd/entire/cli/settings/settings.go b/cmd/entire/cli/settings/settings.go index 3d8eb754ac..a2672cdfb5 100644 --- a/cmd/entire/cli/settings/settings.go +++ b/cmd/entire/cli/settings/settings.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/internal/flock" "github.com/entireio/cli/cmd/entire/cli/jsonutil" "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/entireio/cli/cmd/entire/cli/paths" @@ -152,6 +153,12 @@ type ClonePreferences struct { // definitive false. This is clone-local and not committed so hook-time agent // context injection can avoid network/auth work on the prompt path. TrailsEnabled *bool `json:"trails_enabled,omitempty"` + + // Freshness and scope for TrailsEnabled. + TrailsEnabledCheckedAt *time.Time `json:"trails_enabled_checked_at,omitempty"` + TrailsEnabledRepoKey string `json:"trails_enabled_repo_key,omitempty"` + TrailsEnabledAPIBase string `json:"trails_enabled_api_base,omitempty"` + TrailsEnabledAuthKey string `json:"trails_enabled_auth_key,omitempty"` } // SummaryGenerationSettings configures provider selection for on-demand @@ -582,6 +589,15 @@ func SaveClonePreferences(ctx context.Context, prefs *ClonePreferences) error { return saveClonePreferencesToFile(prefs, path) } +// ModifyClonePreferences runs a read-modify-write under the preferences lock. +func ModifyClonePreferences(ctx context.Context, fn func(*ClonePreferences) error) error { + path, err := ClonePreferencesPath(ctx) + if err != nil { + return err + } + return modifyClonePreferencesFile(path, fn) +} + // LoadFromBytes parses settings from raw JSON bytes without merging local overrides. // Use this when you have settings content from a non-file source (e.g., git show). func LoadFromBytes(data []byte) (*EntireSettings, error) { @@ -686,6 +702,26 @@ func saveClonePreferencesToFile(prefs *ClonePreferences, filePath string) error return nil } +func modifyClonePreferencesFile(filePath string, fn func(*ClonePreferences) error) error { + if err := os.MkdirAll(filepath.Dir(filePath), 0o750); err != nil { + return fmt.Errorf("creating preferences directory: %w", err) + } + release, err := flock.Acquire(filePath + ".lock") + if err != nil { + return fmt.Errorf("lock preferences file: %w", err) + } + defer release() + + prefs, err := loadClonePreferencesFromFile(filePath) + if err != nil { + return err + } + if err := fn(prefs); err != nil { + return err + } + return saveClonePreferencesToFile(prefs, filePath) +} + func applyClonePreferences(settings *EntireSettings, prefs *ClonePreferences) { if prefs == nil { return diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index 623e4865fb..a56d27f669 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -962,7 +962,7 @@ func reportRepoEnabled(ctx context.Context, insecureHTTPAuth bool) { // Persist the negative locally even if auth is unavailable so a stale true // cache from a previous origin does not inject on the prompt path. No API // report is useful for non-forge remotes. - if err := saveTrailsEnabledForRepo(ctx, false); err != nil { + if err := saveTrailsEnabledForRemote(ctx, info.Forge, info.Owner, info.Repo, false); err != nil { logging.Debug(ctx, "failed to cache trails enablement", "error", err) } return @@ -993,7 +993,7 @@ func reportRepoEnabled(ctx context.Context, insecureHTTPAuth bool) { logging.Debug(ctx, "trails enablement probe failed", "error", err) return } - if err := saveTrailsEnabledForRepo(ctx, enabled); err != nil { + if err := saveTrailsEnabledForRemote(ctx, info.Forge, info.Owner, info.Repo, enabled); err != nil { logging.Debug(ctx, "failed to cache trails enablement", "error", err) } } diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index 18cbaa7a70..675ec5170b 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -116,7 +116,7 @@ If is omitted, shows the trail for the current branch. Otherwise, // runTrailShow shows one trail, defaulting to the current branch's trail. func runTrailShow(ctx context.Context, w, errW io.Writer, insecureHTTP bool, selector string) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err @@ -330,7 +330,7 @@ func runTrailListAll(ctx context.Context, w, errW io.Writer, opts trailListOptio if err != nil { return err } - return runAuthenticatedDataAPI(ctx, errW, opts.InsecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, opts.InsecureHTTP, func(ctx context.Context, client *api.Client) error { return runTrailListAllWithClient(ctx, w, client, opts, statusFilters) }) } @@ -886,12 +886,15 @@ func postTrailCreate(ctx context.Context, client *api.Client, forge, owner, repo createReq := newTrailCreateRequest(title, body, branch, base, statusStr) resp, err := client.Post(ctx, trailsBasePath(forge, owner, repoName), createReq) if err != nil { + noteTrailCommandEnablement(ctx, client, err) return api.TrailCreateResponse{}, fmt.Errorf("failed to create trail: %w", err) } defer resp.Body.Close() if err := checkTrailResponse(resp); err != nil { + noteTrailCommandEnablement(ctx, client, err) return api.TrailCreateResponse{}, err } + saveTrailsEnabledForRemoteBestEffort(ctx, forge, owner, repoName, true) var createResp api.TrailCreateResponse if err := api.DecodeJSON(resp, &createResp); err != nil { @@ -988,7 +991,7 @@ type trailUpdateInputs struct { } func runTrailUpdate(ctx context.Context, w, errW io.Writer, insecureHTTP bool, inputs trailUpdateInputs) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repoName, err := resolveTrailRemote(ctx) if err != nil { return err @@ -1196,7 +1199,7 @@ trail is looked up against that repository's origin remote.`, } func runTrailCheckout(ctx context.Context, w, errW io.Writer, insecureHTTP bool, selector string, force bool) error { - return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err @@ -1301,7 +1304,7 @@ func runTrailDelete(cmd *cobra.Command, number int, branch string, force bool) e ctx := cmd.Context() w := cmd.OutOrStdout() - return runAuthenticatedDataAPI(ctx, cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(ctx, cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { forge, owner, repo, err := resolveTrailRemote(ctx) if err != nil { return err diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 05d1016665..179a9529e0 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -21,6 +21,7 @@ import ( "charm.land/huh/v2" "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/settings" "github.com/entireio/cli/cmd/entire/cli/testutil" "github.com/entireio/cli/cmd/entire/cli/trail" "github.com/entireio/cli/internal/entireclient/clusterdiscovery" @@ -790,6 +791,12 @@ func TestResolveTrailRemote_RejectsUnsupportedForge(t *testing.T) { func TestTrailsEnabledForRepo_ReadsClonePreference(t *testing.T) { repoDir := t.TempDir() testutil.InitRepo(t, repoDir) + cmd := exec.CommandContext(context.Background(), "git", "remote", "add", "origin", "git@github.com:acme/repo.git") + cmd.Dir = repoDir + cmd.Env = testutil.GitIsolatedEnv() + if err := cmd.Run(); err != nil { + t.Fatalf("git remote add: %v", err) + } t.Chdir(repoDir) ctx := context.Background() @@ -808,6 +815,45 @@ func TestTrailsEnabledForRepo_ReadsClonePreference(t *testing.T) { if !trailsEnabledForRepo(ctx) { t.Fatal("expected trails enabled when cache is true") } + + prefs, err := settings.LoadClonePreferences(ctx) + if err != nil { + t.Fatalf("load prefs: %v", err) + } + if prefs.TrailsEnabledRepoKey != "gh/acme/repo" { + t.Fatalf("repo key = %q, want gh/acme/repo", prefs.TrailsEnabledRepoKey) + } + + currentAuthKey := prefs.TrailsEnabledAuthKey + prefs.TrailsEnabledAuthKey = currentAuthKey + "-other" + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("save auth-mismatched prefs: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled for mismatched auth cache scope") + } + prefs.TrailsEnabledAuthKey = currentAuthKey + fresh := time.Now() + prefs.TrailsEnabledCheckedAt = &fresh + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("restore auth-matched prefs: %v", err) + } + + stale := time.Now().Add(-trailEnablementCacheTTL - time.Minute) + prefs.TrailsEnabledCheckedAt = &stale + if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + t.Fatalf("save stale prefs: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled when cache is stale") + } + + if err := saveTrailsEnabledForRemote(ctx, "gh", "other", "repo", true); err != nil { + t.Fatalf("save mismatched cache: %v", err) + } + if trailsEnabledForRepo(ctx) { + t.Fatal("expected trails disabled for mismatched cache scope") + } } func TestTrailWatchDescription(t *testing.T) { diff --git a/cmd/entire/cli/trail_context_cache.go b/cmd/entire/cli/trail_context_cache.go index 68476112d3..11a0d67764 100644 --- a/cmd/entire/cli/trail_context_cache.go +++ b/cmd/entire/cli/trail_context_cache.go @@ -2,33 +2,284 @@ package cli import ( "context" + "encoding/json" + "errors" "fmt" + "io" + "os" + "path/filepath" + "strings" + "time" + "github.com/entireio/cli/cmd/entire/cli/api" + "github.com/entireio/cli/cmd/entire/cli/auth" + "github.com/entireio/cli/cmd/entire/cli/gitremote" + "github.com/entireio/cli/cmd/entire/cli/jsonutil" + "github.com/entireio/cli/cmd/entire/cli/logging" + "github.com/entireio/cli/cmd/entire/cli/session" "github.com/entireio/cli/cmd/entire/cli/settings" + "github.com/entireio/cli/cmd/entire/cli/validation" ) -// trailsEnabledForRepo reports the locally cached server-side trails -// enablement for this repo. It intentionally performs no git subprocesses, auth -// work, or network I/O: this runs on the agent turn-start path. +const ( + trailEnablementCacheTTL = time.Hour + trailEnablementSessionStartRefreshTimeout = time.Second + trailEnablementRefreshTimeout = 3 * time.Second +) + +type trailEnablementCacheStatus int + +const ( + trailEnablementCacheUnknown trailEnablementCacheStatus = iota + trailEnablementCacheEnabled + trailEnablementCacheDisabled +) + +type trailEnablementScope struct { + Forge string `json:"forge"` + Owner string `json:"owner"` + Repo string `json:"repo"` + RepoKey string `json:"repo_key"` + APIBase string `json:"api_base"` + AuthKey string `json:"auth_key"` + Supported bool `json:"supported"` +} + +// trailsEnabledForRepo reports cached enablement for the current repo. func trailsEnabledForRepo(ctx context.Context) bool { + return cachedTrailsEnablementForRepo(ctx, time.Now()) == trailEnablementCacheEnabled +} + +func cachedTrailsEnablementForRepo(ctx context.Context, now time.Time) trailEnablementCacheStatus { + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return trailEnablementCacheUnknown + } + return cachedTrailsEnablementForScope(ctx, scope, now) +} + +func cachedTrailsEnablementForScope(ctx context.Context, scope trailEnablementScope, now time.Time) trailEnablementCacheStatus { prefs, err := settings.LoadClonePreferences(ctx) - if err != nil || prefs.TrailsEnabled == nil { - return false + if err != nil || prefs.TrailsEnabled == nil || prefs.TrailsEnabledCheckedAt == nil { + return trailEnablementCacheUnknown + } + if !trailEnablementCacheMatchesScope(prefs, scope) || trailEnablementCacheExpired(*prefs.TrailsEnabledCheckedAt, now) { + return trailEnablementCacheUnknown + } + if *prefs.TrailsEnabled { + return trailEnablementCacheEnabled + } + return trailEnablementCacheDisabled +} + +func trailEnablementCacheMatchesScope(prefs *settings.ClonePreferences, scope trailEnablementScope) bool { + return prefs.TrailsEnabledRepoKey == scope.RepoKey && + prefs.TrailsEnabledAPIBase == scope.APIBase && + prefs.TrailsEnabledAuthKey == scope.AuthKey +} + +func trailEnablementCacheExpired(checkedAt time.Time, now time.Time) bool { + if checkedAt.IsZero() { + return true + } + if now.Before(checkedAt) { + return true + } + return now.Sub(checkedAt) > trailEnablementCacheTTL +} + +func currentTrailEnablementScope(ctx context.Context) (trailEnablementScope, error) { + rawURL, err := gitremote.GetRemoteURL(ctx, "origin") + if err != nil { + return trailEnablementScope{}, fmt.Errorf("get origin remote: %w", err) + } + if strings.TrimSpace(rawURL) == "" { + return trailEnablementScope{}, errors.New("get origin remote: empty URL") + } + info, err := gitremote.ParseURL(rawURL) + if err != nil { + return trailEnablementScope{}, fmt.Errorf("parse origin remote: %w", err) + } + authKey, err := auth.LocalIdentityCacheKey() + if err != nil { + return trailEnablementScope{}, fmt.Errorf("resolve auth cache key: %w", err) } - return *prefs.TrailsEnabled + return trailEnablementScope{ + Forge: info.Forge, + Owner: info.Owner, + Repo: info.Repo, + RepoKey: trailEnablementRepoKey(info.Forge, info.Owner, info.Repo), + APIBase: api.BaseURL(), + AuthKey: authKey, + Supported: info.Forge != "", + }, nil +} + +func trailEnablementRepoKey(forge, owner, repo string) string { + return strings.Join([]string{forge, owner, repo}, "/") +} + +func saveTrailEnablementScopeHint(ctx context.Context, sessionID string, scope trailEnablementScope) error { + path, err := trailEnablementScopeHintPath(ctx, sessionID) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return fmt.Errorf("create session state dir: %w", err) + } + data, err := jsonutil.MarshalIndentWithNewline(scope, "", " ") + if err != nil { + return fmt.Errorf("marshal trail scope hint: %w", err) + } + if err := jsonutil.WriteFileAtomic(path, data, 0o600); err != nil { + return fmt.Errorf("write trail scope hint: %w", err) + } + return nil +} + +func loadTrailEnablementScopeHint(ctx context.Context, sessionID string) (trailEnablementScope, bool, error) { + path, err := trailEnablementScopeHintPath(ctx, sessionID) + if err != nil { + return trailEnablementScope{}, false, err + } + data, err := os.ReadFile(path) //nolint:gosec // path is derived from validated session ID + if err != nil { + if os.IsNotExist(err) { + return trailEnablementScope{}, false, nil + } + return trailEnablementScope{}, false, fmt.Errorf("read trail scope hint: %w", err) + } + var scope trailEnablementScope + if err := json.Unmarshal(data, &scope); err != nil { + return trailEnablementScope{}, false, fmt.Errorf("parse trail scope hint: %w", err) + } + return scope, true, nil +} + +func trailEnablementScopeHintPath(ctx context.Context, sessionID string) (string, error) { + if err := validation.ValidateSessionID(sessionID); err != nil { + return "", fmt.Errorf("invalid session ID: %w", err) + } + commonDir, err := session.GetGitCommonDir(ctx) + if err != nil { + return "", fmt.Errorf("resolve git common dir: %w", err) + } + return filepath.Join(commonDir, session.SessionStateDirName, sessionID+".trail-scope.json"), nil } -// saveTrailsEnabledForRepo persists the server-side trails enablement cache in -// clone-local preferences (.git/entire/preferences.json). The value is not -// committed and is shared by linked worktrees of the same clone. func saveTrailsEnabledForRepo(ctx context.Context, enabled bool) error { - prefs, err := settings.LoadClonePreferences(ctx) + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return err + } + return saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()) +} + +func saveTrailsEnabledForRemote(ctx context.Context, forge, owner, repo string, enabled bool) error { + authKey, err := auth.LocalIdentityCacheKey() if err != nil { - return fmt.Errorf("load clone preferences: %w", err) + return fmt.Errorf("resolve auth cache key: %w", err) } - prefs.TrailsEnabled = &enabled - if err := settings.SaveClonePreferences(ctx, prefs); err != nil { + scope := trailEnablementScope{ + Forge: forge, + Owner: owner, + Repo: repo, + RepoKey: trailEnablementRepoKey(forge, owner, repo), + APIBase: api.BaseURL(), + AuthKey: authKey, + Supported: forge != "", + } + return saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()) +} + +func saveTrailsEnabledForScope(ctx context.Context, scope trailEnablementScope, enabled bool, checkedAt time.Time) error { + enabledCopy := enabled + checkedAtUTC := checkedAt.UTC() + if err := settings.ModifyClonePreferences(ctx, func(prefs *settings.ClonePreferences) error { + prefs.TrailsEnabled = &enabledCopy + prefs.TrailsEnabledCheckedAt = &checkedAtUTC + prefs.TrailsEnabledRepoKey = scope.RepoKey + prefs.TrailsEnabledAPIBase = scope.APIBase + prefs.TrailsEnabledAuthKey = scope.AuthKey + return nil + }); err != nil { return fmt.Errorf("save clone preferences: %w", err) } return nil } + +func refreshTrailsEnabledCacheIfStaleForScope(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 +} + +func refreshTrailsEnabledCache(ctx context.Context, client *api.Client) (bool, error) { + scope, err := currentTrailEnablementScope(ctx) + if err != nil { + return false, err + } + return refreshTrailsEnabledCacheForScope(ctx, client, scope) +} + +func refreshTrailsEnabledCacheForScope(ctx context.Context, client *api.Client, scope trailEnablementScope) (bool, error) { + if !scope.Supported { + if err := saveTrailsEnabledForScope(ctx, scope, false, time.Now()); err != nil { + return false, err + } + return false, nil + } + enabled, err := client.TrailsEnabled(ctx, scope.Forge, scope.Owner, scope.Repo) + if err != nil { + return false, fmt.Errorf("check trails enablement: %w", err) + } + if err := saveTrailsEnabledForScope(ctx, scope, enabled, time.Now()); err != nil { + return false, err + } + return enabled, nil +} + +func saveTrailsEnabledForRepoBestEffort(ctx context.Context, enabled bool) { + if err := saveTrailsEnabledForRepo(ctx, enabled); err != nil { + logging.Debug(ctx, "failed to cache trails enablement", "error", err) + } +} + +func saveTrailsEnabledForRemoteBestEffort(ctx context.Context, forge, owner, repo string, enabled bool) { + if err := saveTrailsEnabledForRemote(ctx, forge, owner, repo, enabled); err != nil { + logging.Debug(ctx, "failed to cache trails enablement", "error", err) + } +} + +func refreshTrailsEnabledCacheBestEffort(ctx context.Context, client *api.Client) { + refreshCtx, cancel := context.WithTimeout(ctx, trailEnablementRefreshTimeout) + defer cancel() + if _, err := refreshTrailsEnabledCache(refreshCtx, client); err != nil { + logging.Debug(ctx, "trails enablement refresh skipped", "error", err) + } +} + +func noteTrailCommandEnablement(ctx context.Context, client *api.Client, commandErr error) { + if commandErr == nil { + saveTrailsEnabledForRepoBestEffort(ctx, true) + return + } + refreshTrailsEnabledCacheBestEffort(ctx, client) +} + +func runAuthenticatedTrailAPI(ctx context.Context, errW io.Writer, insecureHTTP bool, fn func(context.Context, *api.Client) error) error { + return runAuthenticatedDataAPI(ctx, errW, insecureHTTP, func(ctx context.Context, client *api.Client) error { + err := fn(ctx, client) + noteTrailCommandEnablement(ctx, client, err) + return err + }) +} diff --git a/cmd/entire/cli/trail_review_cmd.go b/cmd/entire/cli/trail_review_cmd.go index e855e9156a..45a800a56e 100644 --- a/cmd/entire/cli/trail_review_cmd.go +++ b/cmd/entire/cli/trail_review_cmd.go @@ -466,7 +466,7 @@ func runTrailReviewSetStatus(cmd *cobra.Command, selector string, commentID, sta func authenticatedTrailReviewTarget(cmd *cobra.Command, selector string) (*api.Client, trailReviewTarget, error) { var target trailReviewTarget var resolvedClient *api.Client - err := runAuthenticatedDataAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + err := runAuthenticatedTrailAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { var err error resolvedClient = client target, err = resolveTrailReviewTarget(ctx, client, selector) diff --git a/cmd/entire/cli/trail_watch_cmd.go b/cmd/entire/cli/trail_watch_cmd.go index 8fa7539124..5330fe1468 100644 --- a/cmd/entire/cli/trail_watch_cmd.go +++ b/cmd/entire/cli/trail_watch_cmd.go @@ -84,11 +84,12 @@ Events emitted by the server: } func runTrailWatch(cmd *cobra.Command, number int, jsonOutput, showPings, once bool) error { - return runAuthenticatedDataAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { + return runAuthenticatedTrailAPI(cmd.Context(), cmd.ErrOrStderr(), trailInsecureHTTP(cmd), func(ctx context.Context, client *api.Client) error { trailID, description, err := resolveTrailWatchTarget(ctx, client, number) if err != nil { return err } + saveTrailsEnabledForRepoBestEffort(ctx, true) return runTrailWatchResolved(cmd, client, trailID, description, jsonOutput, showPings, once) }) } diff --git a/docs/architecture/agent-guide.md b/docs/architecture/agent-guide.md index fc7c033a33..75179be66f 100644 --- a/docs/architecture/agent-guide.md +++ b/docs/architecture/agent-guide.md @@ -51,7 +51,7 @@ Every agent must implement all 19 methods on the `Agent` interface: | `TokenCalculator` | `CalculateTokenUsage` | Agent's transcript contains token usage data | | `SubagentAwareExtractor` | `ExtractAllModifiedFiles`, `CalculateTotalTokenUsage` | Agent spawns subagents (like Claude Code's Task tool) | | `HookResponseWriter` | `WriteHookResponse` | Agent can display messages from hook responses (e.g., session start banner). Claude Code uses JSON `systemMessage` on stdout; Factory AI Droid uses plain text on stdout. | -| `ContextInjector` | `InjectionEvent`, `RenderContextInjection` | Agent can inject text into the **model's** context window (distinct from `HookResponseWriter`, which targets the *user*). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (`emitContextInjection`) emits it once per normal session via `session.State.ContextInjectionDecided`, skipping review/investigate sessions, and only when clone-local preferences say trails are enabled for the repo. The API check happens earlier on `entire enable` (`reportRepoEnabled` refreshes `ClonePreferences.TrailsEnabled` using `api.Client.TrailsEnabled`); the prompt path performs no git/auth/network work. Claude Code / Codex / Gemini inject at `TurnStart` using `hookSpecificOutput.additionalContext` (UserPromptSubmit / BeforeAgent); Pi and OpenCode emit a `{"inject_context":...}` envelope that their embedded extension applies (Pi via a `before_agent_start` message, OpenCode via `experimental.chat.system.transform`). | +| `ContextInjector` | `InjectionEvent`, `RenderContextInjection` | Agent can inject text into the **model's** context window (distinct from `HookResponseWriter`, which targets the *user*). The agent declares which lifecycle event it injects at and renders a native stdout payload. The dispatcher (`emitContextInjection`) emits it once per normal session via `session.State.ContextInjectionDecided`, skipping review/investigate sessions, and only when fresh clone-local preferences say trails are enabled for the current repo/API/auth target. The API check happens before the prompt path (`entire enable`, successful `entire trail ...` commands, and stale/missing cache refresh on SessionStart all refresh `ClonePreferences.TrailsEnabled` using `api.Client.TrailsEnabled`); TurnStart performs no auth/network work and leaves unknown/stale caches undecided so a later refresh can still inject. Claude Code / Codex / Gemini inject at `TurnStart` using `hookSpecificOutput.additionalContext` (UserPromptSubmit / BeforeAgent); Pi and OpenCode emit a `{"inject_context":...}` envelope that their embedded extension applies (Pi via a `before_agent_start` message, OpenCode via `experimental.chat.system.transform`). | | `FileWatcher` | `GetWatchPaths`, `OnFileChange` | Agent doesn't support hooks; uses file-based detection instead | ## Step-by-Step Implementation Guide