diff --git a/.github/workflows/codeql-actions.yml b/.github/workflows/codeql-actions.yml deleted file mode 100644 index 63189fcec2..0000000000 --- a/.github/workflows/codeql-actions.yml +++ /dev/null @@ -1,39 +0,0 @@ -name: CodeQL (GitHub Actions) -on: - workflow_dispatch: - pull_request: - paths: - - '.github/workflows/**' - - '.github/actions/**' - -permissions: {} - -jobs: - analyze: - name: Analyze workflows - runs-on: ubuntu-latest - - permissions: - actions: read - contents: read - security-events: write - - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ github.event.pull_request.head.sha }} - - - name: Initialize CodeQL - uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - with: - languages: actions - queries: security-extended,security-and-quality - - - name: Perform CodeQL analysis - uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 - with: - category: /language:actions - # Fork PRs receive a read-only GITHUB_TOKEN, so SARIF upload to the - # code-scanning API would fail. Analyze still runs and surfaces - # findings in the job log; same-repo PRs upload as normal. - upload: ${{ github.event.pull_request.head.repo.full_name == github.repository && 'always' || 'never' }} diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..a139504070 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,85 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + branches: [main] + schedule: + # Weekly, so a new query release is picked up even on a quiet week. + - cron: '34 14 * * 0' + +# Deny by default; the analyze job declares exactly what it needs. +permissions: {} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + timeout-minutes: 30 + permissions: + # Upload the SARIF result to the code-scanning API. + security-events: write + # Fetch the CodeQL query packs. + packages: read + # Read workflow metadata (required for private repositories). + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + # The query suite is set per language, not once for the workflow, + # because the two languages have very different cost profiles. + # + # actions: measured at 49s with the extended+quality suites versus + # 50s with the default suite, so the extra 10 queries are free. They + # are also the ones this repo wants: `unpinned-tag`, the + # `*-injection/medium` and `untrusted-checkout/medium` variants (the + # default suite ships only the high/critical precision ones), and + # `if-expression-always-true`. + - language: actions + build-mode: none + queries: security-extended,security-and-quality + # go: autobuild compiles the module, which dominates this leg, so the + # query suite is close to free here too. Measured on this repo: + # 36 queries (default) 192s, 37 (extended) 150s, 59 + # (extended+quality) 140s — the spread is runner noise, not suites. + # + # security-and-quality is enabled because its 23 Go additions are + # bug-finding queries, not style: the InconsistentCode family + # (MissingErrorCheck, WrappedErrorAlwaysNil, LengthComparisonOffByOne, + # UnhandledCloseWritableHandle) and the RedundantCode family + # (ImpossibleInterfaceNilCheck, DuplicateSwitchCase, ShiftOutOfRange, + # UnreachableStatement). Run against a local database they add two + # high-precision findings that golangci-lint does not report, so this + # is incremental coverage rather than overlap. + - language: go + build-mode: autobuild + queries: security-extended,security-and-quality + + steps: + - name: Checkout repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + queries: ${{ matrix.queries }} + + - name: Perform CodeQL analysis + uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4 + with: + category: /language:${{ matrix.language }} + # A fork PR gets a read-only GITHUB_TOKEN, so `security-events: write` + # is not granted and the upload fails the job. Analyze still runs and + # surfaces findings in the log. Note the condition tests the EVENT + # first: the predecessor workflow ran only on pull_request, so it could + # compare head.repo directly, but that expression is null on push and + # schedule — copied here verbatim it would resolve to 'never' and + # silently stop main from ever being uploaded, which is the one thing + # this workflow exists to do. + upload: ${{ (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && 'always' || 'never' }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0d3177aee8..ba2fef1341 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -31,8 +31,26 @@ jobs: # So this golangci-lint uses the same config as `mise run lint:go`, but using special sauce to # create inline feedback on GitHub's UI. On local dev, the same issues should be surfaced by # mise-tasks/lint/go + # + # The version is read from mise.toml for the same reason setup-go reads + # go.mod above: the two pins must name the same version, and the hardcoded + # copy that used to live here is what drifted on the Go 1.27 bump. A 2.11 + # binary refuses to lint a 1.27 module outright ("the Go language version + # (go1.26) used to build golangci-lint is lower than the targeted Go + # version"), so this step failed after `mise run lint` had already passed + # with the correct version. + - name: Resolve golangci-lint version + id: golangci + run: | + version=$(sed -n "s/^golangci-lint = '\(.*\)'$/\1/p" mise.toml) + if [ -z "$version" ]; then + echo "could not read the golangci-lint version from mise.toml" >&2 + exit 1 + fi + echo "version=v$version" >> "$GITHUB_OUTPUT" + - name: Run golangci-lint uses: golangci/golangci-lint-action@ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a # v9 with: - version: 'v2.11.3' + version: ${{ steps.golangci.outputs.version }} debug: 'clean' diff --git a/.golangci.yaml b/.golangci.yaml index 662d3bcba6..96dea2473f 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -74,6 +74,31 @@ linters: - whitespace - wrapcheck settings: + goconst: + # golangci-lint 2.13's goconst upgrade began scanning composite literals, + # which turned ~2.3k previously-unreported strings into findings. The two + # knobs below are NOT equivalent in what they give up: + # + # ignore-map-keys restores the prior scope exactly: a map-literal key + # was never reported before 2.13, so nothing is lost. + # + # ignore-tests is broader than the 2.13 change and DOES give up + # coverage this repo had. goconst used to lint test logic -- the proof + # is the three //nolint:goconst directives in _test.go files that the + # Go 1.27 bump deleted as unused; they were suppressing real + # comparison/assignment findings under 2.11. Those shapes are no longer + # checked in tests. + # + # It is accepted anyway because no narrower exclusion exists. goconst + # settings are global (there is no per-path variant), the remainder is + # 2016 findings across 62 packages so a path-scoped rule would have to + # name nearly every one, and the field that looks like the right tool -- + # exclude-types: [CompositeLit] -- REPLACES the default exclusion set + # rather than adding to it, so it re-enables Call findings and takes the + # count up to 6255. Revisit if goconst gains per-path settings or fixes + # exclude-types. + ignore-tests: true + ignore-map-keys: true gosec: excludes: - G204 # subprocess with variables is expected for git/opencode CLI wrappers diff --git a/CLAUDE.md b/CLAUDE.md index 104f753c86..8a390033a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,8 +80,17 @@ the commands are always runnable in every build. - `org`: control-plane organization management — `create`, `list`, `get`, `delete` - `project`: control-plane project management — `create`, `list`, `get`, `delete` - `repo`: control-plane repository lifecycle — `create`, `list`, `get`, `delete`, - `clone`, plus the `mirror` and `visibility` subtrees. Git content operations - (log, diff, …) are intentionally out of scope. The `mirror` subtree is + `clone`, plus the `mirror`, `visibility` and `protection` subtrees. Git + content operations (log, diff, …) are intentionally out of scope. + `protection` (`list`, `add [--server-side-merge-only]`, `remove`) edits a + native repo's branch-protection rules through core's + `/repos/{repoId}/branch-protection` resource: `add` and `remove` are one + PATCH each (`addRules` upserts by ref), never a read-modify-write of the + list. `add` sends `serverSideMergeOnly` only when the flag was given: the + server keeps an existing rule's level when it is absent, so re-adding a + branch without the flag never lowers it and `--server-side-merge-only=false` + is the explicit way down. A short branch name expands to `refs/heads/`, + `HEAD` and `refs/...` pass through. The `mirror` subtree is server-side (`create`, `list`, `get`, `remove`, `collaborators`) with one exception: `mirror use` repoints the *current clone's* git remote at a mirror (local git config only — it creates nothing server-side). Interactively it @@ -234,7 +243,7 @@ named `_group.go` and `_.go` respectively. ## Tech Stack -- Language: Go 1.26.x +- Language: Go 1.27.x (`go.mod` pins the 1.27.1 minimum) - Build tool: mise, go modules - Linting: golangci-lint diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 965da96636..aec7e5b6c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -93,7 +93,7 @@ Please answer these questions in your bug report: ### Prerequisites -- **Go 1.26.x** - Check with `go version` +- **Go 1.27.1+** - Check with `go version`. `go.mod` pins the minimum, so an older toolchain fails unless it can download the pinned one (`GOTOOLCHAIN=local` cannot). - **mise** - Task runner and version manager. Install with `curl https://mise.run | sh` ### Clone and Install diff --git a/README.md b/README.md index 5d07e53d7a..12e1bb6d74 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,7 @@ With Entire, you can: - Git - macOS, Linux or Windows - [Supported agent](#agent-hook-configuration) installed and authenticated -- Go 1.26+ only if you install with `go install` (the packaged installs bundle their own runtime) +- Go 1.27.1+ only if you install with `go install` (the packaged installs bundle their own runtime) ## Quick Start diff --git a/WINDOWS.md b/WINDOWS.md index 5c3e18de13..68697fdba5 100644 --- a/WINDOWS.md +++ b/WINDOWS.md @@ -4,7 +4,7 @@ - **Windows 10 1809+** (required for ConPTY support in E2E tests) - **Git for Windows** — provides `git.exe` and bundled bash for git hooks -- **Go 1.26+** — for building from source +- **Go 1.27.1+** — for building from source ## Building diff --git a/cmd/entire/cli/activity_cmd.go b/cmd/entire/cli/activity_cmd.go index bd9861dcea..af07acc828 100644 --- a/cmd/entire/cli/activity_cmd.go +++ b/cmd/entire/cli/activity_cmd.go @@ -27,23 +27,40 @@ const ( sessionsOverviewLimit = 50 ) +// Canonical agent IDs as /me/activity reports them: the keys of the display +// map, the values knownAgents normalizes onto, and the render order all draw +// from this one set. +const ( + activityAgentClaude = "claude" + activityAgentGemini = "gemini" + activityAgentAmp = "amp" + activityAgentCodex = "codex" + activityAgentOpencode = "opencode" + activityAgentCopilot = "copilot" + activityAgentPi = "pi" + activityAgentCursor = "cursor" + activityAgentDroid = "droid" + activityAgentKiro = "kiro" + activityAgentUnknown = "unknown" +) + // knownAgents maps normalized agent strings from the API to display IDs. // Used for the commit list, where per-checkpoint agent strings are free-form. // The /me/activity endpoint returns already-normalized canonical IDs. var knownAgents = map[string]string{ - "claude": "claude", - "claudecode": "claude", - "gemini": "gemini", - "geminicli": "gemini", - "amp": "amp", - "codex": "codex", - "opencode": "opencode", - "copilot": "copilot", - "copilotcli": "copilot", - "pi": "pi", - "cursor": "cursor", - "droid": "droid", - "kiro": "kiro", + "claude": activityAgentClaude, + "claudecode": activityAgentClaude, + "gemini": activityAgentGemini, + "geminicli": activityAgentGemini, + "amp": activityAgentAmp, + "codex": activityAgentCodex, + "opencode": activityAgentOpencode, + "copilot": activityAgentCopilot, + "copilotcli": activityAgentCopilot, + "pi": activityAgentPi, + "cursor": activityAgentCursor, + "droid": activityAgentDroid, + "kiro": activityAgentKiro, } func newActivityCmd() *cobra.Command { diff --git a/cmd/entire/cli/activity_render.go b/cmd/entire/cli/activity_render.go index 46493d7618..6a29d7649c 100644 --- a/cmd/entire/cli/activity_render.go +++ b/cmd/entire/cli/activity_render.go @@ -37,7 +37,7 @@ type activityStyles struct { // used by other commands. Activity benefits from wide output for bar charts. func getFullTerminalWidth(w io.Writer) int { if f, ok := w.(*os.File); ok { - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return width } } @@ -45,7 +45,7 @@ func getFullTerminalWidth(w io.Writer) int { if f == nil { continue } - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return width } } @@ -108,22 +108,22 @@ type agentDisplay struct { // recognizable; lipgloss resolves them to the best representation for the // terminal's color profile. The non-brand "unknown" fallback uses muted gray. var agentDisplayMap = map[string]agentDisplay{ - "claude": {Label: "Claude Code", Color: "#fb923c", Char: '▓'}, // orange-400 - "gemini": {Label: "Gemini", Color: "#60a5fa", Char: '▓'}, // blue-400 - "amp": {Label: "Amp", Color: "#f87171", Char: '▓'}, // red-400 - "codex": {Label: "Codex", Color: "#818cf8", Char: '▓'}, // indigo-400 - "opencode": {Label: "OpenCode", Color: "#22d3ee", Char: '▓'}, // cyan-400 - "copilot": {Label: "Copilot", Color: "#a78bfa", Char: '▓'}, // violet-400 - "pi": {Label: "Pi", Color: "#fbbf24", Char: '▓'}, // amber-400 - "cursor": {Label: "Cursor", Color: "#38bdf8", Char: '▓'}, // sky-400 - "droid": {Label: "Droid", Color: "#f472b6", Char: '▓'}, // pink-400 - "kiro": {Label: "Kiro", Color: "#c084fc", Char: '▓'}, // purple-400 - "unknown": {Label: "Unknown", Color: palette.Muted, Char: '░'}, + activityAgentClaude: {Label: "Claude Code", Color: "#fb923c", Char: '▓'}, // orange-400 + activityAgentGemini: {Label: "Gemini", Color: "#60a5fa", Char: '▓'}, // blue-400 + activityAgentAmp: {Label: "Amp", Color: "#f87171", Char: '▓'}, // red-400 + activityAgentCodex: {Label: "Codex", Color: "#818cf8", Char: '▓'}, // indigo-400 + activityAgentOpencode: {Label: "OpenCode", Color: "#22d3ee", Char: '▓'}, // cyan-400 + activityAgentCopilot: {Label: "Copilot", Color: "#a78bfa", Char: '▓'}, // violet-400 + activityAgentPi: {Label: "Pi", Color: "#fbbf24", Char: '▓'}, // amber-400 + activityAgentCursor: {Label: "Cursor", Color: "#38bdf8", Char: '▓'}, // sky-400 + activityAgentDroid: {Label: "Droid", Color: "#f472b6", Char: '▓'}, // pink-400 + activityAgentKiro: {Label: "Kiro", Color: "#c084fc", Char: '▓'}, // purple-400 + activityAgentUnknown: {Label: "Unknown", Color: palette.Muted, Char: '░'}, } var agentOrder = []string{ - "claude", "codex", "gemini", "amp", "opencode", - "copilot", "pi", "cursor", "droid", "kiro", "unknown", + activityAgentClaude, activityAgentCodex, activityAgentGemini, activityAgentAmp, activityAgentOpencode, + activityAgentCopilot, activityAgentPi, activityAgentCursor, activityAgentDroid, activityAgentKiro, activityAgentUnknown, } // renderActivityHeader renders the stat cards, contribution heatmap, and repo @@ -556,7 +556,7 @@ func renderSessionListN(w io.Writer, sty activityStyles, days []sessionDay, maxD for _, day := range days[:maxDays] { displayDate := formatCommitDate(day.Date) - sessionWord := "sessions" + sessionWord := nounSessions if len(day.Sessions) == 1 { sessionWord = strings.TrimSuffix(sessionWord, "s") } diff --git a/cmd/entire/cli/agent/agent.go b/cmd/entire/cli/agent/agent.go index 20f1566cf1..61e1badfb7 100644 --- a/cmd/entire/cli/agent/agent.go +++ b/cmd/entire/cli/agent/agent.go @@ -304,6 +304,44 @@ type TokenCalculator interface { CalculateTokenUsage(transcriptData []byte, fromOffset int) (*TokenUsage, error) } +// SubagentReference is the authoritative record of one spawned agent supplied +// by the session ledger. Transcript paths are hints only: implementations must +// verify that a path's native metadata identifies this exact AgentID. +type SubagentReference struct { + // ObservedTurnIDs are child turn identities recorded by native hooks. + ObservedTurnIDs []string + AgentID string + DeclaredTranscriptPath string + ResolvedTranscriptPath string +} + +// SubagentAnalysis is the exact evidence available for one supplied subagent. +// TokenUsage is nil when its cumulative native usage cannot be read exactly. +type SubagentAnalysis struct { + AgentID string + ResolvedPath string + ModifiedFiles []string + TokenUsage *TokenUsage + TerminalTurnIDs []string +} + +// InventoryExtraction contains parent evidence plus analysis of the supplied +// authoritative child inventory. TokenUsage records parent usage and, when +// complete, its exact cumulative child aggregate in SubagentTokens. +type InventoryExtraction struct { + TokenUsage *TokenUsage + Children []SubagentAnalysis +} + +// InventoryAwareExtractor analyzes only an already-authoritative inventory of +// children. It is intentionally built-in only: external agents have no +// equivalent protocol capability yet. +type InventoryAwareExtractor interface { + Agent + + ExtractWithSubagentInventory(ctx context.Context, parent []byte, fromOffset int, refs []SubagentReference) (InventoryExtraction, error) +} + // ModelExtractor extracts the LLM model identifier from a transcript for agents // that do not report the model through lifecycle hooks. Pi, for example, records // the model on every assistant message (message.model) but its hook events carry diff --git a/cmd/entire/cli/agent/agent_test.go b/cmd/entire/cli/agent/agent_test.go index b9507a5293..73de9cecc4 100644 --- a/cmd/entire/cli/agent/agent_test.go +++ b/cmd/entire/cli/agent/agent_test.go @@ -135,7 +135,6 @@ func TestHookTypeConstants(t *testing.T) { } } -//nolint:govet // testing struct field assignment func TestHookInputStructure(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/capabilities.go b/cmd/entire/cli/agent/capabilities.go index bebb33c9e7..ef5f5a56a0 100644 --- a/cmd/entire/cli/agent/capabilities.go +++ b/cmd/entire/cli/agent/capabilities.go @@ -18,7 +18,8 @@ type CapabilityDeclarer interface { // // Not every optional interface appears here: built-in-only capabilities that // have no external-protocol equivalent (SessionBaseDirProvider, ModelExtractor, -// SkillEventExtractor, TranscriptSanitizer, TranscriptFetcher) are intentionally +// SkillEventExtractor, TranscriptSanitizer, TranscriptFetcher, +// InventoryAwareExtractor) are intentionally // excluded — their As* helpers resolve by type assertion alone (see // builtinCapability), with no DeclaredCaps gate. type DeclaredCaps struct { @@ -148,6 +149,14 @@ func AsTokenCalculator(ag Agent) (TokenCalculator, bool) { return declaredCapability[TokenCalculator](ag, func(c DeclaredCaps) bool { return c.TokenCalculator }) } +// AsInventoryAwareExtractor returns the agent as InventoryAwareExtractor when +// it implements the built-in-only inventory protocol. External agents cannot +// declare this capability because its authoritative child ledger is internal to +// Entire rather than the external-agent protocol. +func AsInventoryAwareExtractor(ag Agent) (InventoryAwareExtractor, bool) { + return builtinCapability[InventoryAwareExtractor](ag) +} + // AsTextGenerator returns the agent as TextGenerator if it both // implements the interface and (for CapabilityDeclarer agents) has declared the capability. func AsTextGenerator(ag Agent) (TextGenerator, bool) { diff --git a/cmd/entire/cli/agent/capabilities_test.go b/cmd/entire/cli/agent/capabilities_test.go index e56db9b87a..fe26b26171 100644 --- a/cmd/entire/cli/agent/capabilities_test.go +++ b/cmd/entire/cli/agent/capabilities_test.go @@ -78,6 +78,11 @@ func (m *mockFullAgent) PrepareTranscript(context.Context, string) error { retur // TokenCalculator func (m *mockFullAgent) CalculateTokenUsage([]byte, int) (*TokenUsage, error) { return nil, nil } //nolint:nilnil // test mock +// InventoryAwareExtractor is built-in only and deliberately has no DeclaredCaps bit. +func (m *mockFullAgent) ExtractWithSubagentInventory(context.Context, []byte, int, []SubagentReference) (InventoryExtraction, error) { + return InventoryExtraction{}, nil +} + // ModelExtractor func (m *mockFullAgent) ExtractModel([]byte) (string, error) { return "mock-model", nil } @@ -257,6 +262,26 @@ func TestAsTokenCalculator(t *testing.T) { }) } +func TestAsInventoryAwareExtractor(t *testing.T) { + t.Parallel() + + t.Run("not implemented", func(t *testing.T) { + t.Parallel() + _, ok := AsInventoryAwareExtractor(&mockBaseAgent{}) + if ok { + t.Error("expected false") + } + }) + + t.Run("implemented without declared capability", func(t *testing.T) { + t.Parallel() + extractor, ok := AsInventoryAwareExtractor(&mockFullAgent{}) + if !ok || extractor == nil { + t.Error("expected built-in-only type assertion to succeed") + } + }) +} + func TestAsModelExtractor(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/agent/claudecode/generate.go b/cmd/entire/cli/agent/claudecode/generate.go index 2e7d51bf77..65f1e859e0 100644 --- a/cmd/entire/cli/agent/claudecode/generate.go +++ b/cmd/entire/cli/agent/claudecode/generate.go @@ -14,6 +14,13 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) +// flagOutputFormat selects the CLI's response encoding; modelHaiku is the +// default model for Entire's own generation calls (fast and cheap). +const ( + flagOutputFormat = "--output-format" + modelHaiku = "haiku" +) + // buildGenerateArgs assembles the claude CLI argv for a --print text-generation // call. // @@ -41,7 +48,7 @@ import ( // without any injection (settingsPath == ""). func buildGenerateArgs(model, settingsPath string) []string { args := []string{ - "--print", "--output-format", "json", + "--print", flagOutputFormat, "json", "--model", model, "--setting-sources", "", } @@ -59,7 +66,7 @@ func buildGenerateArgs(model, settingsPath string) []string { func buildStreamingGenerateArgs(model, settingsPath string) []string { args := []string{ "--print", - "--output-format", "stream-json", + flagOutputFormat, "stream-json", "--include-partial-messages", "--verbose", "--model", model, @@ -151,7 +158,7 @@ func readUserAPIKeyHelper() string { func (c *ClaudeCodeAgent) GenerateText(ctx context.Context, prompt string, model string) (string, error) { claudePath := "claude" if model == "" { - model = "haiku" + model = modelHaiku } commandRunner := c.CommandRunner diff --git a/cmd/entire/cli/agent/claudecode/generate_streaming.go b/cmd/entire/cli/agent/claudecode/generate_streaming.go index 5e2878f082..91e6c4dbd4 100644 --- a/cmd/entire/cli/agent/claudecode/generate_streaming.go +++ b/cmd/entire/cli/agent/claudecode/generate_streaming.go @@ -29,7 +29,7 @@ func (c *ClaudeCodeAgent) GenerateTextStreaming( progress agent.ProgressFn, ) (string, error) { if model == "" { - model = "haiku" + model = modelHaiku } commandRunner := c.CommandRunner diff --git a/cmd/entire/cli/agent/claudecode/models.go b/cmd/entire/cli/agent/claudecode/models.go index 666900254d..71de832b3d 100644 --- a/cmd/entire/cli/agent/claudecode/models.go +++ b/cmd/entire/cli/agent/claudecode/models.go @@ -15,6 +15,6 @@ func (c *ClaudeCodeAgent) ListModels(_ context.Context) ([]agent.ModelInfo, erro return []agent.ModelInfo{ {ID: "opus", Note: "alias — latest Claude Opus"}, {ID: "sonnet", Note: "alias — latest Claude Sonnet"}, - {ID: "haiku", Note: "alias — latest Claude Haiku (fast)"}, + {ID: modelHaiku, Note: "alias — latest Claude Haiku (fast)"}, }, nil } diff --git a/cmd/entire/cli/agent/claudecode/reviewer.go b/cmd/entire/cli/agent/claudecode/reviewer.go index 90708ba943..bc80457fd4 100644 --- a/cmd/entire/cli/agent/claudecode/reviewer.go +++ b/cmd/entire/cli/agent/claudecode/reviewer.go @@ -37,7 +37,7 @@ func NewReviewer() *reviewtypes.ReviewerTemplate { // Exposed at package level for test inspection of argv and env. func buildReviewCmd(ctx context.Context, cfg reviewtypes.RunConfig) *exec.Cmd { prompt := review.ComposeReviewPrompt(cfg) - args := []string{"-p", prompt, "--output-format", "stream-json", "--verbose"} + args := []string{"-p", prompt, flagOutputFormat, "stream-json", "--verbose"} args = review.AppendModelFlag(args, cfg.Model) cmd := exec.CommandContext(ctx, "claude", args...) cmd.Env = review.AppendReviewEnv(os.Environ(), "claude-code", cfg, prompt) diff --git a/cmd/entire/cli/agent/codex/AGENT.md b/cmd/entire/cli/agent/codex/AGENT.md index 7c2115f870..1941403b5f 100644 --- a/cmd/entire/cli/agent/codex/AGENT.md +++ b/cmd/entire/cli/agent/codex/AGENT.md @@ -311,7 +311,9 @@ The `systemMessage` field can be used to display messages to the user via the ag - The `transcript_path` field in hook payloads provides the exact path - Format: JSONL (line-delimited JSON) - Session ID extraction: `session_id` field from hook payload (UUID format) -- Transcript may be null in `--ephemeral` mode +- Transcript may be null in `--ephemeral` mode; root ownership cannot be + verified, so Entire preserves the turn lifecycle event while logging that + transcript-derived evidence may be unavailable. **Note:** Codex's primary storage is SQLite (`~/.codex/state`), but the JSONL rollout file is the file-based transcript we can read. The `transcript_path` in hook payloads points to this file. @@ -341,10 +343,21 @@ The `systemMessage` field can be used to display messages to the user via the ag - **SessionEnd must be trusted before it fires:** Codex silently skips hooks with no `trusted_hash` entry in the user's `config.toml`. Existing users have trusted the four older events but not `session_end`, so the hook does nothing until they approve it via `/hooks` inside Codex. `HookTrustGaps` and `InspectHookConfig(...).Missing` both cover `session_end`, so `entire doctor` and the SessionStart banner say so — without that it would fail silently. The e2e suite pre-trusts hooks by generating the same hashes itself (`e2e/agents/codex_trust.go`), so **an event added to `managedHooks` must also be added to `codexHookEventLabels`, the `codexHookEvents` struct, and the `codexEventGroups` switch there** — all three, or it is installed but inert for every e2e run; `TestCodexHookTrustState_CoversEveryInstalledEvent` fails when they drift. - **A pre-SessionEnd install still counts as installed:** `AreHooksInstalled` gates on the core events only, so adding an event does not retroactively drop Codex out of `entire status` and the agent pickers for everyone who enabled it earlier. The stale install is reported as drift through `InspectHookConfig(...).Missing` instead, with `entire enable` as the fix. - **`reason` carries no information:** always `"other"`, so a session ended by `/clear` is indistinguishable from one ended by quitting. -- **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. The integration handles this gracefully. +- **Transcript may be null:** In `--ephemeral` mode, `transcript_path` is null. + Entire emits a categorized debug diagnostic and preserves TurnStart / TurnEnd state + mutation; only a rollout positively identified as a child is skipped. +- **Unreadable, malformed, or future rollout metadata is treated as unknown:** + the turn hook emits a diagnostic categorized as `unreadable_transcript`, + `malformed_session_metadata`, or `unclassified_source`, then preserves the + lifecycle event so a root session is not silently left active. - **No hooks fire under `-s read-only`:** verified against 0.147.0 — a `codex exec -s read-only` run produces no hook invocations at all, so no session is tracked. `-s workspace-write` fires the full set. - **Subagent identity fields are inverted from their names:** `SubagentStart` / `SubagentStop` (schemas at `codex-rs/hooks/schema/generated/subagent-{start,stop}.command.input.schema.json`) send `session_id` = the identity shared by the root thread *and every descendant*, i.e. the user's session, which maps straight to Entire's SessionID; `agent_id` = the subagent thread's own id. Codex sends no `tool_use_id`, so `agent_id` doubles as Entire's ToolUseID — it is the only value correlating a start with its stop, and Entire keys pre-task state and the task metadata directory on it. Getting this backwards attributes subagent work to a session Entire has never seen. -- **`SubagentStop` carries two transcripts:** `transcript_path` is the *parent* rollout, `agent_transcript_path` the subagent's own. Entire forwards the latter as `Event.SubagentTranscriptPath`, so it never guesses a layout for Codex. +- **`SubagentStop` is provisional, not authoritative completion.** It carries two transcripts: `transcript_path` is the *parent* rollout and `agent_transcript_path` the child rollout. Entire retains the child identity and declared path, then accepts a rollout only after its first `session_meta.id` exactly matches `agent_id`, it is a regular file, and the same verified bytes are analyzed. A hook-supplied filename is never trusted by itself. +- **Completion is reconciled from the child rollout.** Only explicit matching `task_complete.turn_id` records (or the narrowly correlated legacy boundary) finalize a pending child turn. The root hook reads the authoritative inventory, batches exact-ID rollout lookup across active and archived trees, and fails closed on an ambiguous, unreadable, malformed, or non-regular candidate. +- **Fork history is not child usage.** A rollout with `forked_from_id` but no exact ordinal boundary may contain parent counters and an unfinished parent turn. Only hook-observed child turns contribute files; independently balanced turns remain reconcilable. Its cumulative token total is unavailable rather than guessed. +- **Rollout reads are confined.** Declared and cached paths must stay under configured active or archived rollout roots. Metadata reads are bounded and reject special files. Fallback scan failures remain conservative and emit a debug diagnostic; a large archive may exceed the fixed scan budget. +- **Session end persists ENDED first.** Child reconciliation and condensation then share the remaining hook deadline. Force-closing an unresolved turn preserves evidence already captured for prior turns. +- **Child accounting is exact-only.** Each child retains independently readable file and terminal evidence, but the aggregate is present only when every inventory child resolves and has a valid final cumulative token snapshot. A missing or malformed child makes aggregate coverage incomplete and leaves its aggregate nil; no timestamps, filenames, text length, or tool/API-call counts are estimated. - **Only thread-spawned subagents fire these hooks:** internal/synthetic ones expose no user-configured lifecycle hooks, so they are invisible to Entire. - **Hook response protocol differs from Claude Code:** Codex uses `systemMessage` (same field name) but also supports `hookSpecificOutput` with `additionalContext` for injecting context into the model. For Entire's purposes, `systemMessage` is sufficient. @@ -353,7 +366,7 @@ The `systemMessage` field can be used to display messages to the user via the ag - ~~Hooks require feature flag~~ — `CodexHooks` became `Stage::Stable, default_enabled: true` on 2026-04-23 (openai/codex#19012) and the config key was aliased from `codex_hooks` to `hooks` on 2026-05-01 (openai/codex#20522). No flag is needed. - ~~No SessionEnd hook~~ — added in 0.146; Entire consumes it. - ~~PreToolUse is shell-only~~ — now dispatched generically from the tool registry (`codex-rs/core/src/tools/registry.rs`), covering shell, `apply_patch`, MCP tools and unified_exec. -- ~~No subagent hooks~~ — `SubagentStart` / `SubagentStop` exist, carrying `agent_id`, `agent_type` and `agent_transcript_path`, and Entire now consumes both: they are Codex's PreTask/PostTask equivalents and drive task checkpoints. See the identity and transcript gotchas above. +- `SubagentStart` / `SubagentStop` retain child inventory. Start records the child before best-effort generic capture; stop only records a pending observation and never captures the whole parent worktree or marks completion. See the identity and transcript reconciliation rules above. ## Captured Payloads diff --git a/cmd/entire/cli/agent/codex/codex.go b/cmd/entire/cli/agent/codex/codex.go index a6fa3c4d06..66dbdf7049 100644 --- a/cmd/entire/cli/agent/codex/codex.go +++ b/cmd/entire/cli/agent/codex/codex.go @@ -2,9 +2,12 @@ package codex import ( + "bytes" "context" "errors" "fmt" + "io" + "io/fs" "os" "os/exec" "path/filepath" @@ -26,6 +29,500 @@ func init() { //nolint:revive // CodexAgent is clearer than Agent in this context type CodexAgent struct { CommandRunner agent.TextCommandRunner + // RolloutRoots overrides the active and archived rollout roots for callers + // that already know them (notably tests). Nil uses Codex's normal home. + RolloutRoots []string + // loadRollout and walkDir are package-private deterministic test seams. + // Production uses verified same-descriptor reads plus the bounded, + // incremental directory walker. + loadRollout func(string) (loadedRollout, error) + walkDir func(string, fs.WalkDirFunc) error + // scanLimits and observeRolloutRead are deterministic test seams for the + // fallback rollout budget. Production uses defaultRolloutScanLimits and no + // observer. + scanLimits *rolloutScanLimits + observeRolloutRead func(string, int) +} + +type loadedRollout struct { + Path string + Data []byte +} + +const ( + rolloutScanTimeout = 500 * time.Millisecond + rolloutCandidateLimit = 20_000 + rolloutMetadataByteLimit = int64(64 << 10) + rolloutBodyByteLimit = int64(128 << 20) + rolloutAggregateLimit = int64(256 << 20) + rolloutReadDirBatch = 128 + rolloutBodyReadChunk = 32 << 10 + rolloutMetadataChunk = 1 << 10 +) + +type rolloutScanLimits struct { + timeout time.Duration + candidateLimit int + metadataByteLimit int64 + bodyByteLimit int64 + aggregateByteLimit int64 + readDirBatch int + now func() time.Time +} + +var defaultRolloutScanLimits = rolloutScanLimits{ //nolint:gochecknoglobals // immutable production defaults + timeout: rolloutScanTimeout, + candidateLimit: rolloutCandidateLimit, + metadataByteLimit: rolloutMetadataByteLimit, + bodyByteLimit: rolloutBodyByteLimit, + aggregateByteLimit: rolloutAggregateLimit, + readDirBatch: rolloutReadDirBatch, + now: time.Now, +} + +var errRolloutScanBudget = errors.New("codex rollout scan budget exceeded") + +type rolloutScanBudget struct { + ctx context.Context + limits rolloutScanLimits + deadline time.Time + candidates int + aggregateBytes int64 +} + +func newRolloutScanBudget(ctx context.Context, limits rolloutScanLimits) *rolloutScanBudget { + if limits.now == nil { + limits.now = time.Now + } + if limits.readDirBatch <= 0 { + limits.readDirBatch = rolloutReadDirBatch + } + return &rolloutScanBudget{ + ctx: ctx, + limits: limits, + deadline: limits.now().Add(limits.timeout), + } +} + +func (b *rolloutScanBudget) check() error { + if err := b.ctx.Err(); err != nil { + return fmt.Errorf("rollout scan canceled: %w: %w", err, errRolloutScanBudget) + } + if b.limits.timeout > 0 && !b.limits.now().Before(b.deadline) { + return fmt.Errorf("rollout scan deadline reached: %w", errRolloutScanBudget) + } + return nil +} + +func (b *rolloutScanBudget) observeCandidate() error { + if err := b.check(); err != nil { + return err + } + b.candidates++ + if b.limits.candidateLimit >= 0 && b.candidates > b.limits.candidateLimit { + return fmt.Errorf("rollout candidate limit %d exceeded: %w", b.limits.candidateLimit, errRolloutScanBudget) + } + return nil +} + +func (b *rolloutScanBudget) observeBytes(count int64) error { + if count < 0 || count > b.limits.aggregateByteLimit || b.aggregateBytes > b.limits.aggregateByteLimit-count { + return fmt.Errorf("aggregate rollout byte limit %d exceeded: %w", b.limits.aggregateByteLimit, errRolloutScanBudget) + } + b.aggregateBytes += count + return nil +} + +func readRegularRolloutContext(ctx context.Context, roots []string, path string, byteLimit int64, observe func(string, int)) (loadedRollout, error) { + file, opened, err := openScopedRollout(roots, path) + if err != nil { + return loadedRollout{}, err + } + defer file.Close() + if opened.Size() > byteLimit { + return loadedRollout{}, fmt.Errorf("rollout size %d exceeds limit %d", opened.Size(), byteLimit) + } + data, err := readRolloutBody(file, rolloutReadOptions{ + path: path, byteLimit: byteLimit, check: ctx.Err, observe: observe, + limitErr: fmt.Errorf("rollout exceeds byte limit %d", byteLimit), + }) + if err != nil { + return loadedRollout{}, fmt.Errorf("read rollout: %w", err) + } + return loadedRollout{Path: path, Data: data}, nil +} + +// openScopedRollout accepts files only inside configured rollout roots. Root +// operations retain containment across directory/symlink replacement races. +func openScopedRollout(roots []string, path string) (*os.File, fs.FileInfo, error) { + for _, base := range roots { + rel, err := filepath.Rel(base, path) + if err != nil || !filepath.IsLocal(rel) { + continue + } + root, err := os.OpenRoot(base) + if err != nil { + return nil, nil, fmt.Errorf("open rollout root: %w", err) + } + file, info, err := openRolloutFile(root, rel) + _ = root.Close() + return file, info, err + } + return nil, nil, errors.New("rollout is outside configured roots") +} + +func openRolloutFile(root *os.Root, name string) (*os.File, fs.FileInfo, error) { + before, err := root.Lstat(name) + if err != nil { + return nil, nil, fmt.Errorf("lstat rollout: %w", err) + } + if !before.Mode().IsRegular() { + return nil, nil, errors.New("rollout is not a regular file") + } + file, err := root.OpenFile(name, os.O_RDONLY|rolloutNonblock, 0) + if err != nil { + return nil, nil, fmt.Errorf("open rollout: %w", err) + } + opened, err := file.Stat() + if err != nil || !opened.Mode().IsRegular() || !os.SameFile(before, opened) { + _ = file.Close() + return nil, nil, errors.New("rollout changed or is not a regular file") + } + return file, opened, nil +} + +type rolloutReadOptions struct { + path string + byteLimit int64 + check func() error + account func(int64) error + observe func(string, int) + limitErr error +} + +func readRolloutBody(file *os.File, opts rolloutReadOptions) ([]byte, error) { + data := make([]byte, 0) + buffer := make([]byte, rolloutBodyReadChunk) + for { + if err := opts.check(); err != nil { + return nil, err + } + n, err := file.Read(buffer) + if n > 0 { + if opts.account != nil { + if accountErr := opts.account(int64(n)); accountErr != nil { + return nil, accountErr + } + } + if opts.observe != nil { + opts.observe(opts.path, n) + } + if int64(len(data)+n) > opts.byteLimit { + return nil, opts.limitErr + } + data = append(data, buffer[:n]...) + } + if errors.Is(err, io.EOF) { + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read rollout body: %w", err) + } + } +} + +func (c *CodexAgent) loadCandidateRollout(ctx context.Context, path string) (loadedRollout, error) { + if c.loadRollout != nil { + return c.loadRollout(path) + } + return readRegularRolloutContext(ctx, c.rolloutRoots(), path, rolloutBodyByteLimit, c.observeRolloutRead) +} + +func (c *CodexAgent) loadVerifiedRollout(ctx context.Context, path, agentID string) (loadedRollout, bool) { + loaded, err := c.loadCandidateRollout(ctx, path) + if err != nil { + return loadedRollout{}, false + } + if loaded.Path == "" { + loaded.Path = path + } + if loaded.Path != path { + return loadedRollout{}, false + } + id, err := sessionMetaID(loaded.Data) + if err != nil || id != agentID { + return loadedRollout{}, false + } + return loaded, true +} + +func (c *CodexAgent) rolloutRoots() []string { + if c.RolloutRoots != nil { + return c.RolloutRoots + } + sessionDir, err := c.GetSessionDir("") + if err != nil { + return nil + } + codexHome, err := resolveCodexHome() + if err != nil { + return []string{sessionDir} + } + return []string{sessionDir, filepath.Join(codexHome, "archived_sessions")} +} + +func (c *CodexAgent) loadDirectRollout(ctx context.Context, ref agent.SubagentReference) (loadedRollout, bool) { + for _, path := range []string{ref.DeclaredTranscriptPath, ref.ResolvedTranscriptPath} { + if path == "" { + continue + } + if loaded, ok := c.loadVerifiedRollout(ctx, path, ref.AgentID); ok { + return loaded, true + } + } + return loadedRollout{}, false +} + +func (c *CodexAgent) walkRollouts(ctx context.Context, root string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { + if c.walkDir != nil { + return c.walkDir(root, func(path string, entry fs.DirEntry, entryErr error) error { + if entryErr != nil { + if path == root && errors.Is(entryErr, fs.ErrNotExist) { + return nil + } + return entryErr + } + if err := budget.check(); err != nil { + return err + } + return visit(path, entry) + }) + } + if err := walkRolloutsIncremental(ctx, root, budget, visit); err != nil { + return fmt.Errorf("walk Codex rollouts: %w", err) + } + return nil +} + +func walkRolloutsIncremental(ctx context.Context, root string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { + if err := budget.check(); err != nil { + return err + } + scoped, err := os.OpenRoot(root) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("open rollout root: %w", err) + } + defer scoped.Close() + return walkRolloutDirectory(ctx, scoped, root, ".", budget, visit) +} + +func walkRolloutDirectory(ctx context.Context, root *os.Root, base, dirPath string, budget *rolloutScanBudget, visit func(string, fs.DirEntry) error) error { + dir, err := root.Open(dirPath) + if err != nil { + return fmt.Errorf("open rollout directory: %w", err) + } + defer dir.Close() + + for { + if err := budget.check(); err != nil { + return err + } + entries, readErr := dir.ReadDir(budget.limits.readDirBatch) + for _, entry := range entries { + if err := budget.check(); err != nil { + return err + } + path := filepath.Join(dirPath, entry.Name()) + if entry.IsDir() { + if err := walkRolloutDirectory(ctx, root, base, path, budget, visit); err != nil { + return err + } + continue + } + if err := visit(filepath.Join(base, path), entry); err != nil { + return err + } + } + if errors.Is(readErr, io.EOF) { + return nil + } + if readErr != nil { + return fmt.Errorf("read rollout directory: %w", readErr) + } + if err := ctx.Err(); err != nil { + return fmt.Errorf("walk rollout directory canceled: %w", err) + } + } +} + +func (c *CodexAgent) inspectFallbackCandidate( + path string, + agentIDs map[string]struct{}, + budget *rolloutScanBudget, +) (string, loadedRollout, error) { + if c.loadRollout != nil { + loaded, loadErr := c.loadRollout(path) + if loadErr != nil { + return "", loadedRollout{}, loadErr + } + if loaded.Path == "" { + loaded.Path = path + } + if loaded.Path != path { + return "", loadedRollout{}, errors.New("rollout loader returned a different path") + } + id, metaErr := sessionMetaID(loaded.Data) + if metaErr != nil { + return "", loadedRollout{}, metaErr + } + if _, wanted := agentIDs[id]; !wanted { + return id, loadedRollout{}, nil + } + if int64(len(loaded.Data)) > budget.limits.bodyByteLimit { + return "", loadedRollout{}, errRolloutScanBudget + } + if err := budget.observeBytes(int64(len(loaded.Data))); err != nil { + return "", loadedRollout{}, err + } + return id, loaded, nil + } + + file, opened, err := openScopedRollout(c.rolloutRoots(), path) + if err != nil { + return "", loadedRollout{}, err + } + defer file.Close() + + metadata, err := c.readFallbackMetadata(file, path, budget) + if err != nil { + return "", loadedRollout{}, err + } + id, err := sessionMetaID(metadata) + if err != nil { + return "", loadedRollout{}, err + } + if _, wanted := agentIDs[id]; !wanted { + return id, loadedRollout{}, nil + } + if opened.Size() > budget.limits.bodyByteLimit { + return "", loadedRollout{}, fmt.Errorf("rollout body size %d exceeds limit %d: %w", opened.Size(), budget.limits.bodyByteLimit, errRolloutScanBudget) + } + if opened.Size() > budget.limits.aggregateByteLimit-budget.aggregateBytes { + return "", loadedRollout{}, fmt.Errorf("aggregate rollout size exceeds limit %d: %w", budget.limits.aggregateByteLimit, errRolloutScanBudget) + } + if _, err := file.Seek(0, io.SeekStart); err != nil { + return "", loadedRollout{}, fmt.Errorf("seek rollout candidate: %w", err) + } + data, err := readRolloutBody(file, rolloutReadOptions{ + path: path, byteLimit: budget.limits.bodyByteLimit, check: budget.check, + account: budget.observeBytes, observe: c.observeRolloutRead, limitErr: errRolloutScanBudget, + }) + if err != nil { + return "", loadedRollout{}, err + } + return id, loadedRollout{Path: path, Data: data}, nil +} + +func (c *CodexAgent) readFallbackMetadata(file *os.File, path string, budget *rolloutScanBudget) ([]byte, error) { + data := make([]byte, 0, min(rolloutMetadataChunk, int(budget.limits.metadataByteLimit))) + buffer := make([]byte, rolloutMetadataChunk) + for { + if err := budget.check(); err != nil { + return nil, err + } + remaining := budget.limits.metadataByteLimit + 1 - int64(len(data)) + if remaining <= 0 { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + readSize := len(buffer) + if int64(readSize) > remaining { + readSize = int(remaining) + } + n, err := file.Read(buffer[:readSize]) + if n > 0 { + if budgetErr := budget.observeBytes(int64(n)); budgetErr != nil { + return nil, budgetErr + } + if c.observeRolloutRead != nil { + c.observeRolloutRead(path, n) + } + chunk := buffer[:n] + if newline := bytes.IndexByte(chunk, '\n'); newline >= 0 { + data = append(data, chunk[:newline+1]...) + if int64(len(data)) > budget.limits.metadataByteLimit { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + return data, nil + } + data = append(data, chunk...) + if int64(len(data)) > budget.limits.metadataByteLimit { + return nil, fmt.Errorf("rollout metadata exceeds limit %d: %w", budget.limits.metadataByteLimit, errRolloutScanBudget) + } + } + if errors.Is(err, io.EOF) { + if len(data) == 0 { + return nil, errors.New("rollout metadata is empty") + } + return data, nil + } + if err != nil { + return nil, fmt.Errorf("read rollout metadata: %w", err) + } + } +} + +// scanFallbackRollouts scans every configured root once. Any traversal or +// regular-candidate metadata failure discards all results: partial results +// cannot prove a child ID is unique. +func (c *CodexAgent) scanFallbackRollouts(ctx context.Context, agentIDs map[string]struct{}) (map[string]loadedRollout, error) { + if len(agentIDs) == 0 { + return map[string]loadedRollout{}, nil + } + limits := defaultRolloutScanLimits + if c.scanLimits != nil { + limits = *c.scanLimits + } + budget := newRolloutScanBudget(ctx, limits) + matches := make(map[string][]loadedRollout) + seenPaths := make(map[string]struct{}) + for _, root := range c.rolloutRoots() { + if root == "" { + continue + } + walkErr := c.walkRollouts(ctx, root, budget, func(path string, entry fs.DirEntry) error { + if entry.IsDir() || filepath.Ext(path) != ".jsonl" { + return nil + } + if err := budget.observeCandidate(); err != nil { + return err + } + id, loaded, err := c.inspectFallbackCandidate(path, agentIDs, budget) + if err != nil { + return fmt.Errorf("inspect rollout candidate: %w", err) + } + if loaded.Path == "" { + return nil + } + if _, duplicate := seenPaths[path]; !duplicate { + seenPaths[path] = struct{}{} + matches[id] = append(matches[id], loaded) + } + return nil + }) + if walkErr != nil { + return nil, walkErr + } + } + resolved := make(map[string]loadedRollout) + for id, candidates := range matches { + if len(candidates) == 1 { + resolved[id] = candidates[0] + } + } + return resolved, nil } // NewCodexAgent creates a new Codex agent instance. diff --git a/cmd/entire/cli/agent/codex/lifecycle.go b/cmd/entire/cli/agent/codex/lifecycle.go index 348c43f5e0..981948568c 100644 --- a/cmd/entire/cli/agent/codex/lifecycle.go +++ b/cmd/entire/cli/agent/codex/lifecycle.go @@ -5,10 +5,12 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "os" "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" ) // Compile-time interface assertions. @@ -110,16 +112,16 @@ func (c *CodexAgent) SessionEndBudget() time.Duration { return sessionEndBudget // ParseHookEvent translates a Codex hook into a normalized lifecycle Event. // Returns nil if the hook has no lifecycle significance. -func (c *CodexAgent) ParseHookEvent(_ context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) ParseHookEvent(ctx context.Context, hookName string, stdin io.Reader) (*agent.Event, error) { switch hookName { case HookNameSessionStart: return c.parseSessionInfoEvent(stdin, agent.SessionStart) case HookNameSessionEnd: return c.parseSessionInfoEvent(stdin, agent.SessionEnd) case HookNameUserPromptSubmit: - return c.parseTurnStart(stdin) + return c.parseTurnStart(ctx, stdin) case HookNameStop: - return c.parseTurnEnd(stdin) + return c.parseTurnEnd(ctx, stdin) case HookNamePreToolUse: // PreToolUse has no lifecycle significance — pass through return nil, nil //nolint:nilnil // nil event = no lifecycle action @@ -151,6 +153,8 @@ func (c *CodexAgent) parseSubagentStart(stdin io.Reader) (*agent.Event, error) { SessionID: raw.SessionID, SessionRef: derefString(raw.TranscriptPath), ToolUseID: raw.AgentID, + TurnID: raw.TurnID, + SubagentID: raw.AgentID, SubagentType: raw.AgentType, Model: raw.Model, Timestamp: time.Now(), @@ -166,15 +170,17 @@ func (c *CodexAgent) parseSubagentStop(stdin io.Reader) (*agent.Event, error) { return nil, err } return &agent.Event{ - Type: agent.SubagentEnd, - SessionID: raw.SessionID, - SessionRef: derefString(raw.TranscriptPath), - ToolUseID: raw.AgentID, - SubagentID: raw.AgentID, - SubagentType: raw.AgentType, - SubagentTranscriptPath: derefString(raw.AgentTranscriptPath), - Model: raw.Model, - Timestamp: time.Now(), + Type: agent.SubagentEnd, + SessionID: raw.SessionID, + SessionRef: derefString(raw.TranscriptPath), + ToolUseID: raw.AgentID, + TurnID: raw.TurnID, + SubagentID: raw.AgentID, + ProvisionalSubagentStop: true, + SubagentType: raw.AgentType, + SubagentTranscriptPath: derefString(raw.AgentTranscriptPath), + Model: raw.Model, + Timestamp: time.Now(), }, nil } @@ -197,11 +203,14 @@ func (c *CodexAgent) parseSessionInfoEvent(stdin io.Reader, eventType agent.Even }, nil } -func (c *CodexAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) parseTurnStart(ctx context.Context, stdin io.Reader) (*agent.Event, error) { raw, err := agent.ReadAndParseHookInput[userPromptSubmitRaw](stdin) if err != nil { return nil, err } + if !c.isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { + return nil, nil //nolint:nilnil // only confirmed child rollouts are skipped + } return &agent.Event{ Type: agent.TurnStart, SessionID: raw.SessionID, @@ -268,11 +277,14 @@ func isApplyPatchTool(name string) bool { } } -func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { +func (c *CodexAgent) parseTurnEnd(ctx context.Context, stdin io.Reader) (*agent.Event, error) { raw, err := agent.ReadAndParseHookInput[stopRaw](stdin) if err != nil { return nil, err } + if !c.isRootTurnRollout(ctx, derefString(raw.TranscriptPath)) { + return nil, nil //nolint:nilnil // only confirmed child rollouts are skipped + } return &agent.Event{ Type: agent.TurnEnd, SessionID: raw.SessionID, @@ -281,3 +293,21 @@ func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) { Timestamp: time.Now(), }, nil } + +func (c *CodexAgent) isRootTurnRollout(ctx context.Context, path string) bool { + classification := classifyRolloutDetailed(path, c.rolloutRoots()) + switch classification.Classification { + case rolloutRoot: + return true + case rolloutChild: + logging.Debug(ctx, "codex: skipped root lifecycle mutation for child rollout", slog.String("path", path)) + return false + case rolloutUnknown: + logging.Debug(ctx, "codex: preserved root lifecycle event because rollout ownership is unverified", + slog.String("category", string(classification.Issue)), + slog.String("detail", classification.Detail), + slog.String("path", path)) + return true + } + return false +} diff --git a/cmd/entire/cli/agent/codex/lifecycle_test.go b/cmd/entire/cli/agent/codex/lifecycle_test.go index ed51ebfe51..6bf3275ac7 100644 --- a/cmd/entire/cli/agent/codex/lifecycle_test.go +++ b/cmd/entire/cli/agent/codex/lifecycle_test.go @@ -2,16 +2,27 @@ package codex import ( "context" + "log/slog" + "os" + "path/filepath" "strings" "testing" "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/logging" "github.com/stretchr/testify/require" ) const testRolloutPath = "/Users/test/.codex/rollouts/01/01/rollout-20260324-550e8400.jsonl" +func writeRootRollout(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"user"}}`+"\n"), 0o600)) + return path +} + // SessionStart and SessionEnd share one parser, so they are covered together. // SessionEnd (Codex 0.146+) is what finally lets a quit Codex session be // finalized; its payload is thinner than every other Codex hook — no model, no @@ -122,10 +133,11 @@ func TestCodexAgent_SessionEndBudgetFitsConfiguredTimeout(t *testing.T) { func TestParseHookEvent_UserPromptSubmit(t *testing.T) { t.Parallel() ag := &CodexAgent{} + rolloutPath := writeRootRollout(t) input := `{ "session_id": "test-uuid", "turn_id": "turn-123", - "transcript_path": "/tmp/rollout.jsonl", + "transcript_path": "` + rolloutPath + `", "cwd": "/tmp/testrepo", "hook_event_name": "UserPromptSubmit", "model": "gpt-4.1", @@ -138,7 +150,7 @@ func TestParseHookEvent_UserPromptSubmit(t *testing.T) { require.NotNil(t, event) require.Equal(t, agent.TurnStart, event.Type) require.Equal(t, "test-uuid", event.SessionID) - require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef) + require.Equal(t, rolloutPath, event.SessionRef) require.Equal(t, "Create a hello.txt file", event.Prompt) require.Equal(t, "gpt-4.1", event.Model) } @@ -146,10 +158,11 @@ func TestParseHookEvent_UserPromptSubmit(t *testing.T) { func TestParseHookEvent_Stop(t *testing.T) { t.Parallel() ag := &CodexAgent{} + rolloutPath := writeRootRollout(t) input := `{ "session_id": "test-uuid", "turn_id": "turn-123", - "transcript_path": "/tmp/rollout.jsonl", + "transcript_path": "` + rolloutPath + `", "cwd": "/tmp/testrepo", "hook_event_name": "Stop", "model": "gpt-4.1", @@ -163,10 +176,111 @@ func TestParseHookEvent_Stop(t *testing.T) { require.NotNil(t, event) require.Equal(t, agent.TurnEnd, event.Type) require.Equal(t, "test-uuid", event.SessionID) - require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef) + require.Equal(t, rolloutPath, event.SessionRef) require.Equal(t, "gpt-4.1", event.Model) } +func TestParseHookEvent_TurnHooksIgnoreChildRollout(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"thread_source":"subagent"}}`+"\n"), 0o600)) + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":"` + path + `","model":"gpt-5","prompt":"do work","stop_hook_active":true}` + + for _, hookName := range []string{HookNameUserPromptSubmit, HookNameStop} { + event, err := (&CodexAgent{RolloutRoots: []string{filepath.Dir(path)}}).ParseHookEvent(context.Background(), hookName, strings.NewReader(input)) + require.NoError(t, err) + require.Nil(t, event) + } +} + +func TestParseHookEvent_UnknownTurnRolloutPreservesRootLifecycle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + path func(*testing.T) string + category rolloutClassificationIssue + detail string + }{ + { + name: "null transcript path", + path: func(*testing.T) string { return "" }, + category: rolloutIssueNullPath, + }, + { + name: "unreadable transcript", + path: func(t *testing.T) string { + return filepath.Join(t.TempDir(), "missing.jsonl") + }, + category: rolloutIssueUnreadable, + detail: "open", + }, + { + name: "malformed metadata", + path: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":`), 0o600)) + return path + }, + category: rolloutIssueMalformedMetadata, + detail: "first_record_json", + }, + { + name: "future source", + path: func(t *testing.T) string { + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(`{"type":"session_meta","payload":{"source":"future-source"}}`+"\n"), 0o600)) + return path + }, + category: rolloutIssueUnclassifiedSource, + detail: "future-source", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + rolloutPath := tt.path(t) + pathJSON := "null" + if rolloutPath != "" { + pathJSON = `"` + rolloutPath + `"` + } + logDir := t.TempDir() + logger, err := logging.New(logging.Config{ + Root: func() (*os.Root, error) { return os.OpenRoot(logDir) }, + Dir: "logs", Level: slog.LevelDebug, + }) + require.NoError(t, err) + ctx := logging.WithLogger(context.Background(), logger) + input := `{"session_id":"root-session-1","turn_id":"turn-1","transcript_path":` + pathJSON + `,"model":"gpt-5","prompt":"do work"}` + + for _, hook := range []struct { + name string + want agent.EventType + }{ + {HookNameUserPromptSubmit, agent.TurnStart}, + {HookNameStop, agent.TurnEnd}, + } { + event, err := (&CodexAgent{RolloutRoots: []string{filepath.Dir(rolloutPath)}}).ParseHookEvent(ctx, hook.name, strings.NewReader(input)) + require.NoError(t, err) + require.NotNil(t, event) + require.Equal(t, hook.want, event.Type) + } + require.NoError(t, logger.Close()) + + logData, err := os.ReadFile(filepath.Join(logDir, "logs", "entire.log")) + require.NoError(t, err) + logText := string(logData) + require.Contains(t, logText, "codex: preserved root lifecycle event because rollout ownership is unverified") + require.Contains(t, logText, string(tt.category)) + require.Contains(t, logText, tt.detail) + if rolloutPath != "" { + require.Contains(t, logText, rolloutPath) + } + }) + } +} + func TestParseHookEvent_PreToolUse_ReturnsNil(t *testing.T) { t.Parallel() ag := &CodexAgent{} @@ -348,11 +462,14 @@ func TestParseHookEvent_SubagentStart(t *testing.T) { require.NotNil(t, ev) require.Equal(t, agent.SubagentStart, ev.Type) require.Equal(t, "root-session-1", ev.SessionID, "the shared root session id, not the child thread") + require.Equal(t, "turn-3", ev.TurnID) + require.Equal(t, testCodexAgentID, ev.SubagentID) // Codex sends no tool_use_id; agent_id is the only value correlating start with // stop, and Entire keys pre-task state on ToolUseID. require.Equal(t, testCodexAgentID, ev.ToolUseID) require.Equal(t, "reviewer", ev.SubagentType) require.Equal(t, "/rollouts/root-session-1.jsonl", ev.SessionRef, "the parent rollout") + require.False(t, ev.Final) } // TestParseHookEvent_SubagentStop covers the two transcripts SubagentStop carries: @@ -380,11 +497,14 @@ func TestParseHookEvent_SubagentStop(t *testing.T) { require.NotNil(t, ev) require.Equal(t, agent.SubagentEnd, ev.Type) require.Equal(t, "root-session-1", ev.SessionID, "the shared root session id") + require.Equal(t, "turn-3", ev.TurnID) require.Equal(t, testCodexAgentID, ev.SubagentID) require.Equal(t, testCodexAgentID, ev.ToolUseID, "agent_id doubles as the correlation key") require.Equal(t, "/rollouts/root-session-1.jsonl", ev.SessionRef, "the PARENT rollout") require.Equal(t, "/rollouts/"+testCodexAgentID+".jsonl", ev.SubagentTranscriptPath, "the subagent's own rollout") + require.True(t, ev.ProvisionalSubagentStop) + require.False(t, ev.Final) } // TestParseHookEvent_SubagentStop_NullTranscripts covers the nullable fields: Codex diff --git a/cmd/entire/cli/agent/codex/rollout_open_unix.go b/cmd/entire/cli/agent/codex/rollout_open_unix.go new file mode 100644 index 0000000000..5ca38be17a --- /dev/null +++ b/cmd/entire/cli/agent/codex/rollout_open_unix.go @@ -0,0 +1,8 @@ +//go:build !windows + +package codex + +import "syscall" + +// A regular-file replacement by a FIFO must not block before fstat can reject it. +const rolloutNonblock = syscall.O_NONBLOCK diff --git a/cmd/entire/cli/agent/codex/rollout_open_windows.go b/cmd/entire/cli/agent/codex/rollout_open_windows.go new file mode 100644 index 0000000000..a5fdc4d426 --- /dev/null +++ b/cmd/entire/cli/agent/codex/rollout_open_windows.go @@ -0,0 +1,3 @@ +package codex + +const rolloutNonblock = 0 diff --git a/cmd/entire/cli/agent/codex/rollout_read_test.go b/cmd/entire/cli/agent/codex/rollout_read_test.go new file mode 100644 index 0000000000..c13482efa5 --- /dev/null +++ b/cmd/entire/cli/agent/codex/rollout_read_test.go @@ -0,0 +1,33 @@ +package codex + +import ( + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/stretchr/testify/require" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRolloutReadRejectsOutsideRootAndSymlinkedParent(t *testing.T) { + t.Parallel() + root, outside := t.TempDir(), t.TempDir() + path := writeRollout(t, outside, "child.jsonl", "child", nil) + ag := &CodexAgent{RolloutRoots: []string{root}} + _, ok := ag.loadDirectRollout(t.Context(), agent.SubagentReference{AgentID: "child", DeclaredTranscriptPath: path}) + require.False(t, ok) + require.NoError(t, os.Symlink(outside, filepath.Join(root, "escape"))) + _, ok = ag.loadDirectRollout(t.Context(), agent.SubagentReference{AgentID: "child", DeclaredTranscriptPath: filepath.Join(root, "escape", "child.jsonl")}) + require.False(t, ok) +} + +func TestRolloutClassificationBoundsFirstRecord(t *testing.T) { + t.Parallel() + root := t.TempDir() + path := filepath.Join(root, "huge.jsonl") + data := `{"type":"session_meta","payload":{"thread_source":"user","padding":"` + strings.Repeat("x", int(rolloutMetadataByteLimit)) + `"}}` + require.NoError(t, os.WriteFile(path, []byte(data), 0o600)) + result := classifyRolloutDetailed(path, []string{root}) + require.Equal(t, rolloutUnknown, result.Classification) + require.Equal(t, rolloutIssueUnreadable, result.Issue) +} diff --git a/cmd/entire/cli/agent/codex/rollout_read_unix_test.go b/cmd/entire/cli/agent/codex/rollout_read_unix_test.go new file mode 100644 index 0000000000..834a5e7979 --- /dev/null +++ b/cmd/entire/cli/agent/codex/rollout_read_unix_test.go @@ -0,0 +1,18 @@ +//go:build !windows + +package codex + +import ( + "github.com/stretchr/testify/require" + "path/filepath" + "syscall" + "testing" +) + +func TestRolloutClassificationRejectsFIFO(t *testing.T) { + t.Parallel() + root := t.TempDir() + path := filepath.Join(root, "pipe.jsonl") + require.NoError(t, syscall.Mkfifo(path, 0o600)) + require.Equal(t, rolloutUnknown, classifyRolloutDetailed(path, []string{root}).Classification) +} diff --git a/cmd/entire/cli/agent/codex/subagent_test.go b/cmd/entire/cli/agent/codex/subagent_test.go new file mode 100644 index 0000000000..40631a2f9d --- /dev/null +++ b/cmd/entire/cli/agent/codex/subagent_test.go @@ -0,0 +1,683 @@ +package codex + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/stretchr/testify/require" +) + +func TestResolveRollout_DefaultCodexHomeIncludesArchivedSessions(t *testing.T) { + // This test changes CODEX_HOME, so it must not run in parallel. + codexHome := t.TempDir() + t.Setenv("CODEX_HOME", codexHome) + t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", "") + + active := filepath.Join(codexHome, "sessions") + archived := filepath.Join(codexHome, "archived_sessions") + activePath := writeRollout(t, active, "2026/08/31/rollout-active.jsonl", "active", nil) + archivedPath := writeRollout(t, archived, "2026/08/30/rollout-archived.jsonl", "archived", nil) + ag := &CodexAgent{} + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "active"}, {AgentID: "archived"}}) + require.NoError(t, err) + require.Equal(t, activePath, result.Children[0].ResolvedPath) + require.Equal(t, archivedPath, result.Children[1].ResolvedPath) +} + +func TestResolveRollout_MismatchedKnownPathsFallBackOnlyToExactID(t *testing.T) { + t.Parallel() + + root := t.TempDir() + active := filepath.Join(root, "sessions") + ag := &CodexAgent{RolloutRoots: []string{active}} + mismatch := writeRollout(t, root, "declared.jsonl", "wrong", nil) + exact := writeRollout(t, active, "2026/08/31/rollout-child.jsonl", "child", nil) + + for _, ref := range []agent.SubagentReference{ + {AgentID: "child", DeclaredTranscriptPath: mismatch}, + {AgentID: "child", ResolvedTranscriptPath: mismatch}, + } { + child, _ := extractChild(t, ag, ref) + require.Equal(t, exact, child.ResolvedPath) + } +} + +func TestResolveRollout_RejectsInferredAndAmbiguousCandidates(t *testing.T) { + t.Parallel() + + root := t.TempDir() + active := filepath.Join(root, "sessions") + ag := &CodexAgent{RolloutRoots: []string{active}} + writeRollout(t, active, "2026/08/31/rollout-child.jsonl", "childish", nil) + child, usage := extractChild(t, ag, agent.SubagentReference{AgentID: "child"}) + require.Empty(t, child.ResolvedPath) + require.False(t, *usage.SubagentTokensComplete) + + writeRollout(t, active, "2026/08/30/rollout-child-one.jsonl", "child", nil) + writeRollout(t, active, "2026/08/31/rollout-child-two.jsonl", "child", nil) + child, _ = extractChild(t, ag, agent.SubagentReference{AgentID: "child"}) + require.Empty(t, child.ResolvedPath) +} + +func TestResolveRollout_RejectsSymlinkHint(t *testing.T) { + t.Parallel() + + root := t.TempDir() + target := writeRollout(t, root, "target.jsonl", "child", nil) + link := filepath.Join(root, "child-link.jsonl") + require.NoError(t, os.Symlink(target, link)) + + ag := &CodexAgent{RolloutRoots: []string{}} + child, _ := extractChild(t, ag, agent.SubagentReference{ + AgentID: "child", + DeclaredTranscriptPath: link, + }) + require.Empty(t, child.ResolvedPath) +} + +func TestTerminalTurnIDs_OnlyAcceptsUnambiguousBoundaries(t *testing.T) { + t.Parallel() + + valid := []json.RawMessage{ + taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), + taskEvent("task_started", stringPointer("two")), taskEvent("task_complete", nil), + } + require.Equal(t, []string{"one", "two"}, analyzeRollout(rolloutData(t, "child", valid)).TerminalTurnIDs) + withUnknownEvent := append(append([]json.RawMessage(nil), valid...), json.RawMessage(`{"type":"event_msg","payload":{"type":"future_event","turn_id":7}}`)) + require.Equal(t, []string{"one", "two"}, analyzeRollout(rolloutData(t, "child", withUnknownEvent)).TerminalTurnIDs) + + tests := []struct { + name string + events []json.RawMessage + }{ + {"completion without start", []json.RawMessage{taskEvent("task_complete", stringPointer("one"))}}, + {"start without id", []json.RawMessage{taskEvent("task_started", nil)}}, + {"unclosed start", []json.RawMessage{taskEvent("task_started", stringPointer("one"))}}, + {"overlapping starts", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_started", stringPointer("two"))}}, + {"mismatched completion", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("two"))}}, + {"duplicate turn", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one")), taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", stringPointer("one"))}}, + {"duplicate completion", []json.RawMessage{taskEvent("task_started", stringPointer("one")), taskEvent("task_complete", nil), taskEvent("task_complete", nil)}}, + {"invalid id type", []json.RawMessage{json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":7}}`)}}, + {"malformed tail", append(valid, json.RawMessage(`{"type":"event_msg","payload":{"type":"task_started","turn_id":`))}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + require.Empty(t, analyzeRollout(rolloutData(t, "child", tt.events)).TerminalTurnIDs) + }) + } +} + +func TestAnalyzeRollout_PaginatedSubagentIgnoresInheritedParentHistory(t *testing.T) { + t.Parallel() + + lines := []map[string]any{ + { + "ordinal": 0, + "type": "session_meta", + "payload": map[string]any{ + "id": "child", + "thread_source": "subagent", + "subagent_history_start_ordinal": 10, + }, + }, + {"ordinal": 2, "type": "event_msg", "payload": map[string]any{"type": "task_started", "turn_id": "parent-turn"}}, + {"ordinal": 3, "type": "event_msg", "payload": map[string]any{"type": "item_completed", "item": map[string]any{"type": "FileChange", "status": "completed", "changes": map[string]any{"/repo/parent.txt": map[string]any{"type": "update"}}}}}, + {"ordinal": 4, "type": "event_msg", "payload": map[string]any{"type": "token_count", "info": map[string]any{"total_token_usage": map[string]any{"input_tokens": 99, "cached_input_tokens": 50, "output_tokens": 9}}}}, + {"ordinal": 11, "type": "event_msg", "payload": map[string]any{"type": "task_started", "turn_id": "child-turn"}}, + {"ordinal": 12, "type": "event_msg", "payload": map[string]any{"type": "item_completed", "item": map[string]any{"type": "FileChange", "status": "completed", "changes": map[string]any{"/repo/child.txt": map[string]any{"type": "update"}}}}}, + {"ordinal": 13, "type": "event_msg", "payload": map[string]any{"type": "token_count", "info": map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}}}, + {"ordinal": 14, "type": "event_msg", "payload": map[string]any{"type": "task_complete", "turn_id": "child-turn"}}, + } + encoded := make([][]byte, 0, len(lines)) + for _, line := range lines { + data, err := json.Marshal(line) + require.NoError(t, err) + encoded = append(encoded, data) + } + + result := analyzeRollout(append([]byte(joinLines(encoded)), '\n')) + require.Equal(t, []string{"child-turn"}, result.TerminalTurnIDs) + require.Equal(t, []string{"/repo/child.txt"}, result.ModifiedFiles) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 2, OutputTokens: 1, APICallCount: 1}, + result.ExactTokenUsage, "the inherited snapshot below the start ordinal is out of scope for the call count too") +} + +func TestExactTokenUsage_UsesOnlyLastRecognizableSnapshot(t *testing.T) { + t.Parallel() + + valid := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 15, "cached_input_tokens": 12, "output_tokens": 3, + "reasoning_output_tokens": 2, "total_tokens": 18, + }}) + usage := analyzeRollout(rolloutData(t, "child", []json.RawMessage{valid})).ExactTokenUsage + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 12, OutputTokens: 3, APICallCount: 1}, usage) + + malformedLast := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 10, "cached_input_tokens": 11, "output_tokens": 3, + }}) + require.Nil(t, analyzeRollout(rolloutData(t, "child", []json.RawMessage{valid, malformedLast})).ExactTokenUsage) + + missingRequired := tokenCountEvent(map[string]any{"total_token_usage": map[string]any{ + "input_tokens": 10, "output_tokens": 3, + }}) + require.Nil(t, analyzeRollout(rolloutData(t, "child", []json.RawMessage{missingRequired})).ExactTokenUsage) +} + +func TestExactTokenUsage_RejectsEveryUnavailableOrInconsistentSnapshot(t *testing.T) { + t.Parallel() + + valid := func(values map[string]any) []byte { + return rolloutData(t, "child", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": values})}) + } + require.Nil(t, analyzeRollout(rolloutData(t, "child", nil)).ExactTokenUsage) + + for _, values := range []map[string]any{ + {"cached_input_tokens": 0, "output_tokens": 1}, + {"input_tokens": 1, "output_tokens": 1}, + {"input_tokens": 1, "cached_input_tokens": 0}, + {"input_tokens": -1, "cached_input_tokens": 0, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": -1, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 2, "output_tokens": 0}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "total_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "total_tokens": 1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": -1}, + {"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1, "reasoning_output_tokens": 2}, + } { + require.Nil(t, analyzeRollout(valid(values)).ExactTokenUsage) + } + + zeros := analyzeRollout(valid(map[string]any{"input_tokens": 0, "cached_input_tokens": 0, "output_tokens": 0})).ExactTokenUsage + require.Equal(t, &agent.TokenUsage{APICallCount: 1}, zeros) + + multiple := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 9, "cached_input_tokens": 1, "output_tokens": 2}}), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), + }) + usage := analyzeRollout(multiple).ExactTokenUsage + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 1, OutputTokens: 2, APICallCount: 2}, usage, + "each usage-bearing snapshot is one model turn, as it is for the parent") + + withoutUsage := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"rate_limits": map[string]any{"primary_used_percent": 12}}), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), + }) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 1, OutputTokens: 2, APICallCount: 1}, + analyzeRollout(withoutUsage).ExactTokenUsage, "a token_count carrying no usage snapshot is not a model turn") + + malformedFinal := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 4, "cached_input_tokens": 1, "output_tokens": 2}}), + tokenCountEvent(map[string]any{"total_token_usage": "not-an-object"}), + }) + require.Nil(t, analyzeRollout(malformedFinal).ExactTokenUsage, "must not fall back to the earlier valid snapshot") +} + +func TestSubagentInventory_CollectsExactEvidenceAndDoesNotPartialAggregate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + childOne := writeRollout(t, root, "rollout-one.jsonl", "one", []json.RawMessage{ + patchEvent("child.txt"), + taskEvent("task_started", stringPointer("child-turn")), + taskEvent("task_complete", stringPointer("child-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}), + }) + childTwo := writeRollout(t, root, "rollout-two.jsonl", "two", []json.RawMessage{ + patchEvent("two.txt"), + taskEvent("task_started", stringPointer("two-turn")), + taskEvent("task_complete", stringPointer("two-turn")), + }) + parent := rolloutData(t, "parent", []json.RawMessage{patchEvent("parent.txt")}) + + result, err := ag.ExtractWithSubagentInventory(t.Context(), parent, 0, []agent.SubagentReference{ + {AgentID: "one", DeclaredTranscriptPath: childOne}, + {AgentID: "two", ResolvedTranscriptPath: childTwo}, + }) + require.NoError(t, err) + require.Len(t, result.Children, 2) + require.Equal(t, childOne, result.Children[0].ResolvedPath) + require.Equal(t, []string{"child.txt"}, result.Children[0].ModifiedFiles) + require.Equal(t, []string{"child-turn"}, result.Children[0].TerminalTurnIDs) + require.NotNil(t, result.Children[0].TokenUsage) + require.Equal(t, []string{"two.txt"}, result.Children[1].ModifiedFiles) + require.Equal(t, []string{"two-turn"}, result.Children[1].TerminalTurnIDs) + require.Nil(t, result.Children[1].TokenUsage) + require.NotNil(t, result.TokenUsage) + require.Nil(t, result.TokenUsage.SubagentTokens) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.False(t, *result.TokenUsage.SubagentTokensComplete) +} + +func TestSubagentInventory_AggregatesOnlyCompleteExactChildren(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + first := writeRollout(t, root, "first.jsonl", "first", []json.RawMessage{ + patchEvent("first.txt"), + taskEvent("task_started", stringPointer("first-turn")), + taskEvent("task_complete", stringPointer("first-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 5, "cached_input_tokens": 2, "output_tokens": 1}}), + }) + second := writeRollout(t, root, "second.jsonl", "second", []json.RawMessage{ + patchEvent("second.txt"), + taskEvent("task_started", stringPointer("second-turn")), + taskEvent("task_complete", nil), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 10, "cached_input_tokens": 3, "output_tokens": 5}}), + }) + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ + {AgentID: "first", DeclaredTranscriptPath: first}, + {AgentID: "second", ResolvedTranscriptPath: second}, + }) + require.NoError(t, err) + require.Len(t, result.Children, 2, "one analysis is retained for each supplied reference") + require.Equal(t, "first", result.Children[0].AgentID) + require.Equal(t, first, result.Children[0].ResolvedPath) + require.Equal(t, []string{"first.txt"}, result.Children[0].ModifiedFiles) + require.Equal(t, []string{"first-turn"}, result.Children[0].TerminalTurnIDs) + require.Equal(t, &agent.TokenUsage{InputTokens: 3, CacheReadTokens: 2, OutputTokens: 1, APICallCount: 1}, result.Children[0].TokenUsage) + require.Equal(t, "second", result.Children[1].AgentID) + require.Equal(t, second, result.Children[1].ResolvedPath) + require.Equal(t, []string{"second.txt"}, result.Children[1].ModifiedFiles) + require.Equal(t, []string{"second-turn"}, result.Children[1].TerminalTurnIDs) + require.Equal(t, &agent.TokenUsage{InputTokens: 7, CacheReadTokens: 3, OutputTokens: 5, APICallCount: 1}, result.Children[1].TokenUsage) + require.NotNil(t, result.TokenUsage) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Equal(t, &agent.TokenUsage{InputTokens: 10, CacheReadTokens: 5, OutputTokens: 6, APICallCount: 2}, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_EmptyInventoryIsExactWithoutChildTotal(t *testing.T) { + t.Parallel() + + root := t.TempDir() + walks := 0 + ag := &CodexAgent{ + RolloutRoots: []string{root}, + walkDir: func(root string, visit fs.WalkDirFunc) error { + walks++ + return filepath.WalkDir(root, visit) + }, + } + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, nil) + require.NoError(t, err) + require.Zero(t, walks, "an exact empty inventory has no unresolved child and must not scan rollout archives") + require.Empty(t, result.Children) + require.NotNil(t, result.TokenUsage) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_UnresolvedChildPreventsPartialAggregate(t *testing.T) { + t.Parallel() + + root := t.TempDir() + ag := &CodexAgent{RolloutRoots: []string{root}} + available := writeRollout(t, root, "available.jsonl", "available", []json.RawMessage{ + patchEvent("available.txt"), + taskEvent("task_started", stringPointer("available-turn")), + taskEvent("task_complete", stringPointer("available-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 1}}), + }) + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ + {AgentID: "available", DeclaredTranscriptPath: available}, + {AgentID: "missing"}, + }) + require.NoError(t, err) + require.Len(t, result.Children, 2) + require.Equal(t, []string{"available-turn"}, result.Children[0].TerminalTurnIDs) + require.NotNil(t, result.Children[0].TokenUsage) + require.Empty(t, result.Children[1].ResolvedPath) + require.Nil(t, result.Children[1].TokenUsage) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_LoaderFailureFailsClosedBeforeResolution(t *testing.T) { + t.Parallel() + + root := t.TempDir() + path := writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{ + patchEvent("child.txt"), + taskEvent("task_started", stringPointer("child-turn")), + taskEvent("task_complete", stringPointer("child-turn")), + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 1, "output_tokens": 1}}), + }) + readFailure := errors.New("injected child load failure") + ag := &CodexAgent{ + RolloutRoots: []string{}, + loadRollout: func(gotPath string) (loadedRollout, error) { + if gotPath == "" { + return loadedRollout{}, readFailure + } + require.Equal(t, path, gotPath) + return loadedRollout{}, readFailure + }, + } + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{ + AgentID: "child", + DeclaredTranscriptPath: path, + }}) + require.NoError(t, err) + require.Len(t, result.Children, 1) + require.Equal(t, "child", result.Children[0].AgentID) + require.Empty(t, result.Children[0].ResolvedPath, "same-byte validation cannot retain a failed load") + require.Empty(t, result.Children[0].ModifiedFiles) + require.Empty(t, result.Children[0].TerminalTurnIDs) + require.Nil(t, result.Children[0].TokenUsage) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestSubagentInventory_RevalidatesInjectedRolloutBytes(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "child.jsonl") + ag := &CodexAgent{ + RolloutRoots: []string{}, + loadRollout: func(gotPath string) (loadedRollout, error) { + if gotPath == "" { + return loadedRollout{}, errors.New("empty path") + } + require.Equal(t, path, gotPath) + return loadedRollout{Path: path, Data: rolloutData(t, "other-child", nil)}, nil + }, + } + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{ + AgentID: "child", + DeclaredTranscriptPath: path, + }}) + require.NoError(t, err) + require.Empty(t, result.Children[0].ResolvedPath) + require.False(t, *result.TokenUsage.SubagentTokensComplete) +} + +func TestSubagentInventory_FallbackTraversalFailureDiscardsMatches(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 1, "cached_input_tokens": 0, "output_tokens": 1}})}) + traversalFailure := errors.New("injected traversal failure after match") + ag := &CodexAgent{ + RolloutRoots: []string{root}, + walkDir: func(root string, visit fs.WalkDirFunc) error { + require.NoError(t, filepath.WalkDir(root, visit)) + return traversalFailure + }, + } + + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "child"}}) + require.NoError(t, err) + require.Empty(t, result.Children[0].ResolvedPath) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Nil(t, result.TokenUsage.SubagentTokens) +} + +func TestRolloutScanLimits_Defaults(t *testing.T) { + t.Parallel() + + require.Equal(t, 500*time.Millisecond, defaultRolloutScanLimits.timeout) + require.Equal(t, 20_000, defaultRolloutScanLimits.candidateLimit) + require.Equal(t, int64(64<<10), defaultRolloutScanLimits.metadataByteLimit) + require.Equal(t, int64(128<<20), defaultRolloutScanLimits.bodyByteLimit) + require.Equal(t, int64(256<<20), defaultRolloutScanLimits.aggregateByteLimit) + require.Equal(t, 128, defaultRolloutScanLimits.readDirBatch) +} + +func TestRolloutScanBudget_DefaultBoundaries(t *testing.T) { + t.Parallel() + + start := time.Unix(1, 0) + now := start + limits := defaultRolloutScanLimits + limits.now = func() time.Time { return now } + + candidates := newRolloutScanBudget(t.Context(), limits) + for range limits.candidateLimit { + require.NoError(t, candidates.observeCandidate()) + } + require.ErrorIs(t, candidates.observeCandidate(), errRolloutScanBudget) + + bytes := newRolloutScanBudget(t.Context(), limits) + require.NoError(t, bytes.observeBytes(limits.aggregateByteLimit)) + require.ErrorIs(t, bytes.observeBytes(1), errRolloutScanBudget) + + deadline := newRolloutScanBudget(t.Context(), limits) + now = start.Add(limits.timeout - time.Nanosecond) + require.NoError(t, deadline.check()) + now = start.Add(limits.timeout) + require.ErrorIs(t, deadline.check(), errRolloutScanBudget) +} + +func TestSubagentInventory_FallbackReadsOnlyMetadataForUnrelatedRollouts(t *testing.T) { + t.Parallel() + + root := t.TempDir() + unrelated := writeRollout(t, root, "unrelated.jsonl", "unrelated", nil) + require.NoError(t, os.WriteFile(unrelated, append(mustReadFile(t, unrelated), []byte(strings.Repeat("x", 1<<20))...), 0o600)) + writeRollout(t, root, "wanted.jsonl", "wanted", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 2, "cached_input_tokens": 0, "output_tokens": 1}}), + }) + + readByPath := make(map[string]int64) + ag := &CodexAgent{ + RolloutRoots: []string{root}, + observeRolloutRead: func(path string, n int) { + readByPath[path] += int64(n) + }, + } + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{{AgentID: "wanted"}}) + require.NoError(t, err) + require.True(t, *result.TokenUsage.SubagentTokensComplete) + require.Less(t, readByPath[unrelated], int64(4<<10), "unrelated rollout must not be read beyond its metadata prefix") +} + +func TestRolloutScanLimits_FailClosed(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + limits rolloutScanLimits + setup func(*testing.T, string) + refs []agent.SubagentReference + }{ + { + name: "candidate count", + limits: testRolloutScanLimits(1, 64<<10, 128<<20, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "one.jsonl", "one", nil) + writeRollout(t, root, "two.jsonl", "two", nil) + }, + refs: []agent.SubagentReference{{AgentID: "one"}, {AgentID: "two"}}, + }, + { + name: "metadata bytes", + limits: testRolloutScanLimits(10, 8, 128<<20, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "child.jsonl", "child", nil) + }, + refs: []agent.SubagentReference{{AgentID: "child"}}, + }, + { + name: "body bytes", + limits: testRolloutScanLimits(10, 64<<10, 80, 256<<20), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "child.jsonl", "child", []json.RawMessage{patchEvent("child.txt")}) + }, + refs: []agent.SubagentReference{{AgentID: "child"}}, + }, + { + name: "aggregate bytes", + limits: testRolloutScanLimits(10, 64<<10, 1<<20, 120), + setup: func(t *testing.T, root string) { + writeRollout(t, root, "one.jsonl", "one", []json.RawMessage{patchEvent("one.txt")}) + writeRollout(t, root, "two.jsonl", "two", []json.RawMessage{patchEvent("two.txt")}) + }, + refs: []agent.SubagentReference{{AgentID: "one"}, {AgentID: "two"}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + root := t.TempDir() + tt.setup(t, root) + ag := &CodexAgent{RolloutRoots: []string{root}, scanLimits: &tt.limits} + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, tt.refs) + require.NoError(t, err) + require.NotNil(t, result.TokenUsage) + require.Nil(t, result.TokenUsage.SubagentTokens) + require.NotNil(t, result.TokenUsage.SubagentTokensComplete) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + for _, child := range result.Children { + require.Empty(t, child.ResolvedPath, "a scan breach must discard all partial fallback matches") + } + }) + } +} + +func TestSubagentInventory_IncrementalCancellation(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writeRollout(t, root, "child.jsonl", "child", nil) + ctx, cancel := context.WithCancel(t.Context()) + cancel() + ag := &CodexAgent{RolloutRoots: []string{root}} + result, err := ag.ExtractWithSubagentInventory(ctx, nil, 0, []agent.SubagentReference{{AgentID: "child"}}) + require.NoError(t, err) + require.False(t, *result.TokenUsage.SubagentTokensComplete) + require.Empty(t, result.Children[0].ResolvedPath) +} + +func testRolloutScanLimits(candidateLimit int, metadata, body, aggregate int64) rolloutScanLimits { + return rolloutScanLimits{ + timeout: time.Hour, + candidateLimit: candidateLimit, + metadataByteLimit: metadata, + bodyByteLimit: body, + aggregateByteLimit: aggregate, + readDirBatch: 2, + now: time.Now, + } +} + +func mustReadFile(t *testing.T, path string) []byte { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + return data +} + +func extractChild(t *testing.T, ag *CodexAgent, ref agent.SubagentReference) (agent.SubagentAnalysis, *agent.TokenUsage) { + t.Helper() + result, err := ag.ExtractWithSubagentInventory(t.Context(), nil, 0, []agent.SubagentReference{ref}) + require.NoError(t, err) + require.Len(t, result.Children, 1) + return result.Children[0], result.TokenUsage +} + +func writeRollout(t *testing.T, root, name, id string, events []json.RawMessage) string { + t.Helper() + path := filepath.Join(root, name) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, rolloutData(t, id, events), 0o600)) + return path +} + +func rolloutData(t *testing.T, id string, events []json.RawMessage) []byte { + t.Helper() + lines := make([][]byte, 0, len(events)+1) + meta, err := json.Marshal(map[string]any{"type": "session_meta", "payload": map[string]any{"id": id}}) + require.NoError(t, err) + lines = append(lines, meta) + for _, event := range events { + lines = append(lines, event) + } + return append([]byte(joinLines(lines)), '\n') +} + +func tokenCountEvent(info map[string]any) json.RawMessage { + data, err := json.Marshal(map[string]any{"type": "event_msg", "payload": map[string]any{"type": "token_count", "info": info}}) + if err != nil { + panic(err) + } + return data +} + +func taskEvent(eventType string, turnID *string) json.RawMessage { + payload := map[string]any{"type": eventType} + if turnID != nil { + payload["turn_id"] = *turnID + } + data, err := json.Marshal(map[string]any{"type": "event_msg", "payload": payload}) + if err != nil { + panic(err) + } + return data +} + +func stringPointer(value string) *string { return &value } + +func patchEvent(path string) json.RawMessage { + data, err := json.Marshal(map[string]any{"type": "response_item", "payload": map[string]any{"type": "custom_tool_call", "name": "apply_patch", "input": "*** Update File: " + path}}) + if err != nil { + panic(err) + } + return data +} + +func joinLines(lines [][]byte) string { + var result strings.Builder + for index, line := range lines { + if index > 0 { + result.WriteByte('\n') + } + result.Write(line) + } + return result.String() +} + +func TestAnalyzeRollout_ForkWithoutOrdinalDoesNotAttributeParentUsage(t *testing.T) { + t.Parallel() + data := []byte(`{"type":"session_meta","payload":{"id":"child","forked_from_id":"parent"}}` + "\n" + + `{"type":"event_msg","payload":{"type":"task_started","turn_id":"parent-turn"}}` + "\n" + + string(patchEvent("parent.txt")) + "\n" + + string(tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 1000, "cached_input_tokens": 900, "output_tokens": 50}})) + "\n" + + string(taskEvent("task_started", stringPointer("child-turn"))) + "\n" + + string(patchEvent("child.txt")) + "\n" + + string(taskEvent("task_complete", stringPointer("child-turn"))) + "\n") + result := analyzeRollout(data) + require.Nil(t, result.ExactTokenUsage, "inherited cumulative counters cannot prove child-only usage") + require.Empty(t, result.ModifiedFiles, "without a boundary or observed child turns, files are unscoped") + require.Contains(t, result.TerminalTurnIDs, "child-turn", "the inherited open parent turn must not invalidate a balanced child turn") + scoped := analyzeRolloutForTurns(t.Context(), data, []string{"child-turn"}) + require.Equal(t, []string{"child.txt"}, scoped.ModifiedFiles) + require.Contains(t, scoped.TerminalTurnIDs, "child-turn") + require.Nil(t, scoped.ExactTokenUsage) +} + +func TestExactTokenUsage_MalformedTokenEnvelopeClearsSnapshot(t *testing.T) { + t.Parallel() + data := rolloutData(t, "child", []json.RawMessage{ + tokenCountEvent(map[string]any{"total_token_usage": map[string]any{"input_tokens": 10, "cached_input_tokens": 2, "output_tokens": 3}}), + json.RawMessage(`{"type":"event_msg","payload":{"type":"token_count","turn_id":7}}`), + }) + require.Nil(t, analyzeRollout(data).ExactTokenUsage) +} diff --git a/cmd/entire/cli/agent/codex/transcript.go b/cmd/entire/cli/agent/codex/transcript.go index 3c98d9f7ff..c51afc211d 100644 --- a/cmd/entire/cli/agent/codex/transcript.go +++ b/cmd/entire/cli/agent/codex/transcript.go @@ -3,10 +3,12 @@ package codex import ( "bufio" "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "log/slog" "os" "regexp" "sort" @@ -14,30 +16,172 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/logging" ) // Compile-time interface assertions. var ( _ agent.TranscriptAnalyzer = (*CodexAgent)(nil) _ agent.TokenCalculator = (*CodexAgent)(nil) + _ agent.InventoryAwareExtractor = (*CodexAgent)(nil) _ agent.PromptExtractor = (*CodexAgent)(nil) _ agent.RestoredSessionPathResolver = (*CodexAgent)(nil) _ agent.TranscriptSanitizer = (*CodexAgent)(nil) ) +func sessionMetaID(data []byte) (string, error) { + lines := splitJSONL(data) + if len(lines) == 0 { + return "", errors.New("rollout is empty") + } + var line rolloutLine + if err := json.Unmarshal(lines[0], &line); err != nil { + return "", fmt.Errorf("parse first rollout record: %w", err) + } + if line.Type != rolloutLineTypeSessionMeta { + return "", fmt.Errorf("first transcript line is %q, want session_meta", line.Type) + } + var meta sessionMetaPayload + if err := json.Unmarshal(line.Payload, &meta); err != nil { + return "", fmt.Errorf("parse session_meta payload: %w", err) + } + if meta.ID == "" { + return "", errors.New("session_meta id is empty") + } + return meta.ID, nil +} + // rolloutLine is the top-level JSONL line structure in Codex rollout files. type rolloutLine struct { Timestamp string `json:"timestamp"` + Ordinal *int `json:"ordinal,omitempty"` Type string `json:"type"` // "session_meta", "response_item", "event_msg", "turn_context" Payload json.RawMessage `json:"payload"` } -const rolloutLineTypeResponseItem = "response_item" +const ( + rolloutLineTypeResponseItem = "response_item" + rolloutLineTypeSessionMeta = "session_meta" + rolloutLineTypeEventMsg = "event_msg" + eventMsgTypeTokenCount = "token_count" +) + +// rolloutClassification identifies whether a rollout belongs to a root thread +// or a child thread. Uncertainty remains distinct so callers can diagnose it; +// root lifecycle hooks preserve their event unless the rollout is a confirmed child. +type rolloutClassification uint8 + +const ( + rolloutUnknown rolloutClassification = iota + rolloutRoot + rolloutChild +) + +type rolloutClassificationIssue string + +const ( + rolloutIssueNullPath rolloutClassificationIssue = "null_transcript_path" + rolloutIssueUnreadable rolloutClassificationIssue = "unreadable_transcript" + rolloutIssueMalformedMetadata rolloutClassificationIssue = "malformed_session_metadata" + rolloutIssueUnclassifiedSource rolloutClassificationIssue = "unclassified_source" +) + +type rolloutClassificationResult struct { + Classification rolloutClassification + Issue rolloutClassificationIssue + Detail string +} // sessionMetaPayload is the payload for type="session_meta" lines. type sessionMetaPayload struct { - ID string `json:"id"` - Timestamp string `json:"timestamp"` + ID string `json:"id"` + ForkedFromID string `json:"forked_from_id,omitempty"` + Timestamp string `json:"timestamp"` + ThreadSource string `json:"thread_source"` + Source json.RawMessage `json:"source"` + SubagentHistoryStartOrdinal *int `json:"subagent_history_start_ordinal,omitempty"` +} + +// classifyRolloutDetailed reads only the rollout's session_meta record. Newer +// Codex rollouts use thread_source; older rollouts use source. +func classifyRolloutDetailed(path string, roots []string) rolloutClassificationResult { + if path == "" { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueNullPath} + } + + file, _, err := openScopedRollout(roots, path) + if err != nil { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnreadable, Detail: "open"} + } + defer file.Close() + reader := &CodexAgent{} + lineData, err := reader.readFallbackMetadata(file, path, newRolloutScanBudget(context.Background(), defaultRolloutScanLimits)) + if err != nil { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnreadable, Detail: "read"} + } + + var line rolloutLine + if json.Unmarshal(lineData, &line) != nil { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "first_record_json"} + } + if line.Type != rolloutLineTypeSessionMeta { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "first_record_type"} + } + + var meta sessionMetaPayload + if json.Unmarshal(line.Payload, &meta) != nil { + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueMalformedMetadata, Detail: "session_meta_payload"} + } + + switch meta.ThreadSource { + case "user": + return rolloutClassificationResult{Classification: rolloutRoot} + case "subagent": + return rolloutClassificationResult{Classification: rolloutChild} + case "": + // Fall through to the legacy source encoding. + default: + return rolloutClassificationResult{ + Classification: rolloutUnknown, + Issue: rolloutIssueUnclassifiedSource, + Detail: safeRolloutSource(meta.ThreadSource), + } + } + + var source string + if json.Unmarshal(meta.Source, &source) == nil { + switch source { + case "startup", "resume", "clear", "compact", "cli", codexExecCommand, "vscode", "mcp": + return rolloutClassificationResult{Classification: rolloutRoot} + default: + return rolloutClassificationResult{ + Classification: rolloutUnknown, + Issue: rolloutIssueUnclassifiedSource, + Detail: safeRolloutSource(source), + } + } + } + + var structuredSource struct { + Subagent json.RawMessage `json:"subagent"` + } + if json.Unmarshal(meta.Source, &structuredSource) == nil && + len(structuredSource.Subagent) > 0 && + !bytes.Equal(structuredSource.Subagent, []byte("null")) { + return rolloutClassificationResult{Classification: rolloutChild} + } + + return rolloutClassificationResult{Classification: rolloutUnknown, Issue: rolloutIssueUnclassifiedSource, Detail: "missing_or_structured_legacy_source"} +} + +func safeRolloutSource(source string) string { + const maxSourceRunes = 128 + runes := []rune(strings.ToValidUTF8(source, "�")) + if len(runes) > maxSourceRunes { + runes = runes[:maxSourceRunes] + } + return string(runes) } // responseItemPayload is the payload for type="response_item" lines. @@ -57,8 +201,16 @@ type contentItem struct { // eventMsgPayload is the payload for type="event_msg" lines. type eventMsgPayload struct { - Type string `json:"type"` // "token_count", "task_started", "user_message", "agent_message", "task_complete" - Info json.RawMessage `json:"info,omitempty"` + Type string `json:"type"` // "token_count", "task_started", "user_message", "agent_message", "task_complete" + TurnID *string `json:"turn_id,omitempty"` + Info json.RawMessage `json:"info,omitempty"` + Item json.RawMessage `json:"item,omitempty"` +} + +type fileChangeItem struct { + Type string `json:"type"` + Status string `json:"status"` + Changes map[string]json.RawMessage `json:"changes"` } // tokenCountInfo contains token usage data from event_msg.token_count. @@ -75,6 +227,17 @@ type tokenUsageData struct { TotalTokens int `json:"total_tokens"` } +// exactTokenUsageData uses pointers so a native zero is distinguishable from a +// field Codex did not report. It is used for child cumulative snapshots, where +// approximation would turn an incomplete inventory into a misleading total. +type exactTokenUsageData struct { + InputTokens *int `json:"input_tokens"` + CachedInputTokens *int `json:"cached_input_tokens"` + OutputTokens *int `json:"output_tokens"` + ReasoningOutputTokens *int `json:"reasoning_output_tokens"` + TotalTokens *int `json:"total_tokens"` +} + // Apply-patch envelope verbs Codex uses in tool_input.command — see // codex-rs/core/src/tools/handlers/apply_patch.rs. Capture group 1 is the // verb, group 2 is the path. @@ -170,22 +333,7 @@ func extractFilesFromLine(lineData []byte) []string { if json.Unmarshal(lineData, &line) != nil { return nil } - - if line.Type != rolloutLineTypeResponseItem { - return nil - } - - var payload responseItemPayload - if json.Unmarshal(line.Payload, &payload) != nil { - return nil - } - - // apply_patch custom tool calls contain file paths in the input text - if payload.Type == "custom_tool_call" && payload.Name == "apply_patch" { - return extractFilesFromApplyPatch(payload.Input) - } - - return nil + return extractFilesFromParsedLine(line) } // extractFilesFromApplyPatch returns every file path in an apply_patch envelope, @@ -280,14 +428,14 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) if json.Unmarshal(lineData, &line) != nil { continue } - if line.Type != "event_msg" { + if line.Type != rolloutLineTypeEventMsg { continue } var evt eventMsgPayload if json.Unmarshal(line.Payload, &evt) != nil { continue } - if evt.Type != "token_count" || len(evt.Info) == 0 { + if evt.Type != eventMsgTypeTokenCount || len(evt.Info) == 0 { continue } var info tokenCountInfo @@ -330,6 +478,286 @@ func (c *CodexAgent) CalculateTokenUsage(transcriptData []byte, fromOffset int) }, nil } +type rolloutAnalysis struct { + ModifiedFiles []string + TerminalTurnIDs []string + ExactTokenUsage *agent.TokenUsage +} + +// analyzeRollout extracts every piece of child evidence in one JSONL pass. +// Each evidence channel keeps its own validity: malformed task boundaries +// invalidate terminal turns without discarding file paths already observed, +// while a malformed final token snapshot makes exact usage unavailable. +func analyzeRollout(data []byte) rolloutAnalysis { + return analyzeRolloutForTurns(context.Background(), data, nil) +} + +func analyzeRolloutForTurns(ctx context.Context, data []byte, observedTurns []string) rolloutAnalysis { + var result rolloutAnalysis + terminalValid := true + scopeValid := true + openTurn := "" + seenTurns := make(map[string]struct{}) + seenFiles := make(map[string]struct{}) + var lastTokenSnapshot *exactTokenUsageData + foundToken := false + // Every token_count event carrying a usage snapshot is one model turn — + // the same test CalculateTokenUsage applies when counting the parent's API + // calls. Counting them here keeps a child's APICallCount comparable with + // its parent's, and with the Claude Code and Droid subagent rollups, which + // both sum their children's counts. + tokenSnapshots := 0 + lines := splitJSONL(data) + var localStartOrdinal *int + inherited := false + knownTurns := make(map[string]bool, len(observedTurns)) + for _, turnID := range observedTurns { + knownTurns[turnID] = true + } + if len(lines) > 0 { + var first rolloutLine + if json.Unmarshal(lines[0], &first) == nil && first.Type == rolloutLineTypeSessionMeta { + var meta sessionMetaPayload + if json.Unmarshal(first.Payload, &meta) == nil { + inherited = meta.ForkedFromID != "" + if meta.SubagentHistoryStartOrdinal != nil && *meta.SubagentHistoryStartOrdinal >= 0 { + localStartOrdinal = meta.SubagentHistoryStartOrdinal + } + } + } + } + + for _, lineData := range lines { + if ctx.Err() != nil { + return rolloutAnalysis{} + } + var line rolloutLine + if json.Unmarshal(lineData, &line) != nil { + terminalValid = false + if localStartOrdinal != nil { + scopeValid = false + } + continue + } + if localStartOrdinal != nil { + if line.Ordinal == nil { + scopeValid = false + continue + } + if *line.Ordinal < *localStartOrdinal { + continue + } + } + if !inherited || localStartOrdinal != nil || knownTurns[openTurn] { + for _, file := range extractFilesFromParsedLine(line) { + if _, seen := seenFiles[file]; !seen { + seenFiles[file] = struct{}{} + result.ModifiedFiles = append(result.ModifiedFiles, file) + } + } + } + if line.Type != rolloutLineTypeEventMsg { + continue + } + + var header struct { + Type string `json:"type"` + } + if json.Unmarshal(line.Payload, &header) != nil { + terminalValid = false + continue + } + if header.Type != eventMsgTypeTokenCount && header.Type != "task_started" && header.Type != "task_complete" { + continue + } + var event eventMsgPayload + if json.Unmarshal(line.Payload, &event) != nil { + if header.Type != eventMsgTypeTokenCount { + terminalValid = false + } else { + foundToken = true + lastTokenSnapshot = nil + } + continue + } + switch header.Type { + case eventMsgTypeTokenCount: + foundToken = true + lastTokenSnapshot = decodeTotalTokenUsage(event.Info) + if lastTokenSnapshot != nil { + tokenSnapshots++ + } + case "task_started": + // Forks copy an unfinished parent turn. A later explicit turn start + // replaces that orphan; only balanced pairs can yield terminal IDs. + if inherited && localStartOrdinal == nil && event.TurnID != nil && *event.TurnID != openTurn { + openTurn = "" + } + if openTurn != "" || event.TurnID == nil || *event.TurnID == "" { + terminalValid = false + continue + } + if _, duplicate := seenTurns[*event.TurnID]; duplicate { + terminalValid = false + continue + } + openTurn = *event.TurnID + case "task_complete": + if openTurn == "" || (event.TurnID != nil && (*event.TurnID == "" || *event.TurnID != openTurn)) { + terminalValid = false + continue + } + result.TerminalTurnIDs = append(result.TerminalTurnIDs, openTurn) + seenTurns[openTurn] = struct{}{} + openTurn = "" + } + } + if !scopeValid { + return rolloutAnalysis{} + } + if !terminalValid || openTurn != "" { + result.TerminalTurnIDs = nil + } + if foundToken && (!inherited || localStartOrdinal != nil) { + result.ExactTokenUsage = exactUsageFromSnapshot(lastTokenSnapshot) + if result.ExactTokenUsage != nil { + // Reported only alongside exact usage: an unusable final snapshot + // makes the child's whole total unavailable, and a bare call count + // with no tokens would read as a child that burned nothing. + result.ExactTokenUsage.APICallCount = tokenSnapshots + } + } + return result +} + +func extractFilesFromParsedLine(line rolloutLine) []string { + switch line.Type { + case rolloutLineTypeResponseItem: + var payload responseItemPayload + if json.Unmarshal(line.Payload, &payload) != nil || payload.Type != "custom_tool_call" || payload.Name != "apply_patch" { + return nil + } + return extractFilesFromApplyPatch(payload.Input) + case rolloutLineTypeEventMsg: + var event eventMsgPayload + if json.Unmarshal(line.Payload, &event) != nil || event.Type != "item_completed" { + return nil + } + var item fileChangeItem + if json.Unmarshal(event.Item, &item) != nil || item.Type != "FileChange" || item.Status != "completed" { + return nil + } + files := make([]string, 0, len(item.Changes)) + for path := range item.Changes { + if path != "" { + files = append(files, path) + } + } + sort.Strings(files) + return files + default: + return nil + } +} + +// decodeTotalTokenUsage pulls the cumulative usage snapshot out of a +// token_count event's info payload, or nil when it is absent or malformed. +// The API-call count and the exact-usage read share it so both agree on what +// counts as a usage-bearing event. +func decodeTotalTokenUsage(info json.RawMessage) *exactTokenUsageData { + if len(info) == 0 { + return nil + } + var payload struct { + TotalTokenUsage *exactTokenUsageData `json:"total_token_usage"` + } + if json.Unmarshal(info, &payload) != nil { + return nil + } + return payload.TotalTokenUsage +} + +// exactUsageFromSnapshot converts a decoded snapshot into usage, rejecting any +// snapshot whose fields are absent or mutually inconsistent. +func exactUsageFromSnapshot(usage *exactTokenUsageData) *agent.TokenUsage { + if usage == nil { + return nil + } + if usage.InputTokens == nil || usage.CachedInputTokens == nil || usage.OutputTokens == nil { + return nil + } + input, cached, output := *usage.InputTokens, *usage.CachedInputTokens, *usage.OutputTokens + if input < 0 || cached < 0 || output < 0 || cached > input { + return nil + } + if usage.ReasoningOutputTokens != nil && (*usage.ReasoningOutputTokens < 0 || *usage.ReasoningOutputTokens > output) { + return nil + } + if usage.TotalTokens != nil && (*usage.TotalTokens < 0 || *usage.TotalTokens != input+output) { + return nil + } + return &agent.TokenUsage{InputTokens: input - cached, CacheReadTokens: cached, OutputTokens: output} +} + +// ExtractWithSubagentInventory gathers evidence only for refs supplied by the +// caller's authoritative ledger. It never discovers children from transcript +// text, filenames, timestamps, or token-count events. +func (c *CodexAgent) ExtractWithSubagentInventory(ctx context.Context, parent []byte, fromOffset int, refs []agent.SubagentReference) (agent.InventoryExtraction, error) { + var result agent.InventoryExtraction + parentUsage, err := c.CalculateTokenUsage(parent, fromOffset) + if err != nil { + return result, err + } + complete := true + var childTotal *agent.TokenUsage + result.Children = make([]agent.SubagentAnalysis, len(refs)) + unresolvedIDs := make(map[string]struct{}) + for index, ref := range refs { + if loaded, ok := c.loadDirectRollout(ctx, ref); ok { + // Analyze and release each direct body before reading the next child. + result.Children[index] = analyzeLoadedChild(ctx, ref, loaded) + } else { + result.Children[index].AgentID = ref.AgentID + if ref.AgentID != "" { + unresolvedIDs[ref.AgentID] = struct{}{} + } + } + } + fallback, fallbackErr := c.scanFallbackRollouts(ctx, unresolvedIDs) + if fallbackErr != nil { + fallback = nil + logging.Debug(ctx, "codex: fallback rollout scan incomplete", slog.String("error", fallbackErr.Error())) + } + for index, ref := range refs { + if result.Children[index].ResolvedPath == "" { + result.Children[index] = analyzeLoadedChild(ctx, ref, fallback[ref.AgentID]) + } + child := result.Children[index] + if child.TokenUsage == nil { + complete = false + } else { + childTotal = types.AddTokenUsage(childTotal, child.TokenUsage) + } + } + result.TokenUsage = types.WithClearedSubagentTokens(parentUsage, complete) + if complete && len(refs) > 0 { + result.TokenUsage.SubagentTokens = childTotal + } + return result, nil +} + +func analyzeLoadedChild(ctx context.Context, ref agent.SubagentReference, loaded loadedRollout) agent.SubagentAnalysis { + analysis := agent.SubagentAnalysis{AgentID: ref.AgentID, ResolvedPath: loaded.Path} + if loaded.Path == "" { + return analysis + } + rollout := analyzeRolloutForTurns(ctx, loaded.Data, ref.ObservedTurnIDs) + analysis.ModifiedFiles = rollout.ModifiedFiles + analysis.TerminalTurnIDs = rollout.TerminalTurnIDs + analysis.TokenUsage = rollout.ExactTokenUsage + return analysis +} + // ExtractPrompts returns user prompts from the transcript starting at the given offset. func (c *CodexAgent) ExtractPrompts(sessionRef string, fromOffset int) ([]string, error) { data, err := os.ReadFile(sessionRef) //nolint:gosec // Path comes from agent hook input @@ -585,7 +1013,7 @@ func parseSessionStartTime(data []byte) (time.Time, error) { if err := json.Unmarshal(lines[0], &line); err != nil { return time.Time{}, fmt.Errorf("parse first transcript line: %w", err) } - if line.Type != "session_meta" { + if line.Type != rolloutLineTypeSessionMeta { return time.Time{}, fmt.Errorf("first transcript line is %q, want session_meta", line.Type) } diff --git a/cmd/entire/cli/agent/codex/transcript_test.go b/cmd/entire/cli/agent/codex/transcript_test.go index 0a329f8db4..cd70d8345c 100644 --- a/cmd/entire/cli/agent/codex/transcript_test.go +++ b/cmd/entire/cli/agent/codex/transcript_test.go @@ -32,6 +32,74 @@ func writeSampleRollout(t *testing.T) string { return path } +func TestClassifyRollout(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data string + want rolloutClassification + wantIssue rolloutClassificationIssue + }{ + { + name: "root thread source", + data: `{"type":"session_meta","payload":{"thread_source":"user"}}` + "\n", + want: rolloutRoot, + }, + { + name: "root legacy string source", + data: `{"type":"session_meta","payload":{"source":"exec"}}` + "\n", + want: rolloutRoot, + }, + { + name: "child thread source", + data: `{"type":"session_meta","payload":{"thread_source":"subagent"}}` + "\n", + want: rolloutChild, + }, + { + name: "child legacy structured source", + data: `{"type":"session_meta","payload":{"source":{"subagent":{"thread_spawn":{"parent_thread_id":"root-thread"}}}}}` + "\n", + want: rolloutChild, + }, + { + name: "missing session metadata", + data: `{"type":"response_item","payload":{}}` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueMalformedMetadata, + }, + { + name: "malformed JSON", + data: `{"type":"session_meta","payload":` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueMalformedMetadata, + }, + { + name: "unrecognized source", + data: `{"type":"session_meta","payload":{"source":"other"}}` + "\n", + want: rolloutUnknown, + wantIssue: rolloutIssueUnclassifiedSource, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "rollout.jsonl") + require.NoError(t, os.WriteFile(path, []byte(tt.data), 0o600)) + got := classifyRolloutDetailed(path, []string{filepath.Dir(path)}) + require.Equal(t, tt.want, got.Classification) + require.Equal(t, tt.wantIssue, got.Issue) + }) + } + + t.Run("missing path", func(t *testing.T) { + t.Parallel() + got := classifyRolloutDetailed(filepath.Join(t.TempDir(), "missing.jsonl"), nil) + require.Equal(t, rolloutUnknown, got.Classification) + require.Equal(t, rolloutIssueUnreadable, got.Issue) + }) +} + func TestGetTranscriptPosition(t *testing.T) { t.Parallel() ag := &CodexAgent{} diff --git a/cmd/entire/cli/agent/codex/types.go b/cmd/entire/cli/agent/codex/types.go index 781f0b875b..1177b6ed64 100644 --- a/cmd/entire/cli/agent/codex/types.go +++ b/cmd/entire/cli/agent/codex/types.go @@ -135,6 +135,5 @@ type subagentStopRaw struct { HookEventName string `json:"hook_event_name"` Model string `json:"model"` PermissionMode string `json:"permission_mode"` - StopHookActive bool `json:"stop_hook_active"` TurnID string `json:"turn_id"` } diff --git a/cmd/entire/cli/agent/copilotcli/generate.go b/cmd/entire/cli/agent/copilotcli/generate.go index 375a9850f4..974924ca4c 100644 --- a/cmd/entire/cli/agent/copilotcli/generate.go +++ b/cmd/entire/cli/agent/copilotcli/generate.go @@ -7,6 +7,10 @@ import ( "github.com/entireio/cli/cmd/entire/cli/agent" ) +// flagDenyTool withholds one built-in tool from the generation run; Entire's +// summaries need no tools at all, so every one it knows about is denied. +const flagDenyTool = "--deny-tool" + // generateTextArgs is the pinned tool policy for text generation. // // Summary generation is a text-in text-out call, and its prompt carries @@ -48,9 +52,9 @@ import ( // TestGenerateText_PinsMinimalToolSurface pins the argv so a future flag // change is a reviewed decision rather than a drive-by edit. var generateTextArgs = []string{ - "--deny-tool", "shell", - "--deny-tool", "write", - "--deny-tool", "url", + flagDenyTool, "shell", + flagDenyTool, "write", + flagDenyTool, "url", "--no-ask-user", "--no-custom-instructions", "--disable-builtin-mcps", diff --git a/cmd/entire/cli/agent/event.go b/cmd/entire/cli/agent/event.go index 918e514f49..fb5881596f 100644 --- a/cmd/entire/cli/agent/event.go +++ b/cmd/entire/cli/agent/event.go @@ -108,9 +108,16 @@ type Event struct { // ToolUseID identifies the tool invocation (for SubagentStart/SubagentEnd events). ToolUseID string + // TurnID identifies the agent turn that produced the event. + TurnID string + // SubagentID identifies the subagent instance (for SubagentEnd events). SubagentID string + // ProvisionalSubagentStop is true when a subagent-stop event may arrive + // before the root rollout has reached its final state. + ProvisionalSubagentStop bool + // Final is true only for events that represent true completion of a // subagent (for example Claude Code or Copilot CLI's SubagentStop), never // for the launch-time @@ -261,5 +268,5 @@ func ReadHookInputRawLimited(stdin io.Reader, limit int64) (json.RawMessage, err // instead of blocking on a read that will never complete (issue #1398). func StdinLooksInteractive(r io.Reader) bool { f, ok := r.(*os.File) - return ok && term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + return ok && term.IsTerminal(int(f.Fd())) } diff --git a/cmd/entire/cli/agent/session_test.go b/cmd/entire/cli/agent/session_test.go index cecb0c815f..497b74aef3 100644 --- a/cmd/entire/cli/agent/session_test.go +++ b/cmd/entire/cli/agent/session_test.go @@ -1,4 +1,3 @@ -//nolint:govet // Test file with struct field assignments for completeness package agent import ( diff --git a/cmd/entire/cli/agent/token_usage.go b/cmd/entire/cli/agent/token_usage.go index cd9ec1920f..b8da829ce9 100644 --- a/cmd/entire/cli/agent/token_usage.go +++ b/cmd/entire/cli/agent/token_usage.go @@ -7,6 +7,22 @@ import ( "github.com/entireio/cli/cmd/entire/cli/logging" ) +// ExtractWithSubagentInventory gives built-in agents an authoritative child +// ledger. It deliberately has no external-agent protocol equivalent: callers +// supply the inventory rather than asking an agent to infer children from text. +func ExtractWithSubagentInventory(ctx context.Context, ag Agent, transcriptData []byte, transcriptLinesAtStart int, refs []SubagentReference) (InventoryExtraction, bool) { + extractor, ok := AsInventoryAwareExtractor(ag) + if !ok { + return InventoryExtraction{}, false + } + extraction, err := extractor.ExtractWithSubagentInventory(ctx, transcriptData, transcriptLinesAtStart, refs) + if err != nil { + logging.Debug(ctx, "failed inventory-aware token extraction", slog.String("error", err.Error())) + return InventoryExtraction{}, false + } + return extraction, true +} + // CalculateTokenUsage calculates token usage from transcript data. // Returns nil if the agent doesn't support token calculation or on error. // Errors are debug-logged because callers treat nil token usage as "no data available". diff --git a/cmd/entire/cli/agent/types/token_usage.go b/cmd/entire/cli/agent/types/token_usage.go index 9c6082079d..9c1f6d4368 100644 --- a/cmd/entire/cli/agent/types/token_usage.go +++ b/cmd/entire/cli/agent/types/token_usage.go @@ -15,6 +15,21 @@ type TokenUsage struct { APICallCount int `json:"api_call_count"` // SubagentTokens contains token usage from spawned subagents (if any) SubagentTokens *TokenUsage `json:"subagent_tokens,omitempty"` + // SubagentTokensComplete says whether the outer result has exact child coverage. + SubagentTokensComplete *bool `json:"subagent_tokens_complete,omitempty"` +} + +// WithClearedSubagentTokens returns an independent usage result with child +// totals removed and an explicit coverage marker. A nil usage becomes a +// marker-only result so unavailable coverage persists. +func WithClearedSubagentTokens(usage *TokenUsage, complete bool) *TokenUsage { + if usage == nil { + usage = &TokenUsage{} + } + cleared := *usage + cleared.SubagentTokens = nil + cleared.SubagentTokensComplete = &complete + return &cleared } // MaxSubagentDepth caps how deep a SubagentTokens chain is walked. Real chains @@ -61,18 +76,55 @@ func addTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { bSub = b.SubagentTokens } if depth >= MaxSubagentDepth { + if depth == 0 { + sum.SubagentTokensComplete = tokenCompleteness(a, b) + } return sum } sum.SubagentTokens = addTokenUsageAtDepth(aSub, bSub, depth+1) + if depth == 0 { + sum.SubagentTokensComplete = tokenCompleteness(a, b) + } return sum } +func tokenCompleteness(a, b *TokenUsage) *bool { + seenComplete := false + seenUnknown := false + for _, usage := range []*TokenUsage{a, b} { + if usage == nil { + continue + } + if usage.SubagentTokensComplete == nil { + seenUnknown = true + continue + } + if !*usage.SubagentTokensComplete { + incomplete := false + return &incomplete + } + seenComplete = true + } + if seenUnknown { + return nil + } + if seenComplete { + complete := true + return &complete + } + return nil +} + // SubtractTokenUsage returns a-b, recursing into subagent usage and clamping // every field at zero (a nil operand is treated as zero). Neither input is // mutated. Used to rescope a cumulative-since-session-start snapshot (e.g. // subagent token usage, which is always re-read from the start of each // subagent transcript) down to a delta since a previously captured baseline. func SubtractTokenUsage(a, b *TokenUsage) *TokenUsage { + return subtractTokenUsageAtDepth(a, b, 0) +} + +func subtractTokenUsageAtDepth(a, b *TokenUsage, depth int) *TokenUsage { if a == nil { return nil } @@ -86,7 +138,11 @@ func SubtractTokenUsage(a, b *TokenUsage) *TokenUsage { OutputTokens: clampSubtract(a.OutputTokens, b.OutputTokens), APICallCount: clampSubtract(a.APICallCount, b.APICallCount), } - diff.SubagentTokens = SubtractTokenUsage(a.SubagentTokens, b.SubagentTokens) + diff.SubagentTokens = subtractTokenUsageAtDepth(a.SubagentTokens, b.SubagentTokens, depth+1) + if depth == 0 && a.SubagentTokensComplete != nil { + complete := *a.SubagentTokensComplete + diff.SubagentTokensComplete = &complete + } return diff } diff --git a/cmd/entire/cli/agent/types/token_usage_test.go b/cmd/entire/cli/agent/types/token_usage_test.go index f0d82d250b..a506152aec 100644 --- a/cmd/entire/cli/agent/types/token_usage_test.go +++ b/cmd/entire/cli/agent/types/token_usage_test.go @@ -1,6 +1,44 @@ package types -import "testing" +import ( + "encoding/json" + "testing" +) + +func TestTokenUsage_SubagentTokensCompleteRoundTripAndClear(t *testing.T) { + t.Parallel() + + complete := true + usage := &TokenUsage{ + InputTokens: 3, + SubagentTokens: &TokenUsage{OutputTokens: 2}, + SubagentTokensComplete: &complete, + } + data, err := json.Marshal(usage) + if err != nil { + t.Fatal(err) + } + var roundTripped TokenUsage + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatal(err) + } + if roundTripped.SubagentTokensComplete == nil || !*roundTripped.SubagentTokensComplete { + t.Fatalf("round trip completeness = %v, want true", roundTripped.SubagentTokensComplete) + } + + cleared := WithClearedSubagentTokens(&roundTripped, false) + if cleared == &roundTripped || cleared.SubagentTokens != nil || cleared.SubagentTokensComplete == nil || *cleared.SubagentTokensComplete { + t.Fatalf("cleared usage = %+v, want independent explicitly incomplete copy", cleared) + } + if roundTripped.SubagentTokens == nil || roundTripped.SubagentTokensComplete == nil || !*roundTripped.SubagentTokensComplete { + t.Fatalf("clear mutated input: %+v", roundTripped) + } + + usageCopy := AddTokenUsage(nil, usage) + if usageCopy.SubagentTokensComplete == nil || !*usageCopy.SubagentTokensComplete { + t.Fatalf("AddTokenUsage copy dropped completeness: %+v", usageCopy) + } +} func TestAddTokenUsage(t *testing.T) { t.Parallel() @@ -31,6 +69,32 @@ func TestAddTokenUsage(t *testing.T) { } } +func TestAddTokenUsage_CompletenessCombination(t *testing.T) { + t.Parallel() + + complete := true + incomplete := false + for _, operands := range [][2]*TokenUsage{ + {{SubagentTokensComplete: &complete}, {SubagentTokensComplete: &incomplete}}, + {{SubagentTokensComplete: &incomplete}, {SubagentTokensComplete: &complete}}, + } { + got := AddTokenUsage(operands[0], operands[1]) + if got.SubagentTokensComplete == nil || *got.SubagentTokensComplete { + t.Fatalf("AddTokenUsage(%v, %v) completeness = %v, want false", *operands[0].SubagentTokensComplete, *operands[1].SubagentTokensComplete, got.SubagentTokensComplete) + } + } + + unknown := &TokenUsage{} + for _, operands := range [][2]*TokenUsage{ + {{SubagentTokensComplete: &complete}, unknown}, + {unknown, {SubagentTokensComplete: &complete}}, + } { + if got := AddTokenUsage(operands[0], operands[1]); got.SubagentTokensComplete != nil { + t.Fatalf("AddTokenUsage with unknown coverage = %v, want nil", *got.SubagentTokensComplete) + } + } +} + // TestAddTokenUsage_TruncatesDeepSubagentChains pins MaxSubagentDepth. Token usage // is read back from per-session metadata.json blobs on the shared checkpoint // branch, so the chain depth is not trustworthy; an unbounded chain reaching the diff --git a/cmd/entire/cli/agent_group.go b/cmd/entire/cli/agent_group.go index 3201d599ec..274bfdee9d 100644 --- a/cmd/entire/cli/agent_group.go +++ b/cmd/entire/cli/agent_group.go @@ -16,7 +16,7 @@ import ( // newAgentGroupCmd builds `entire agent`. Replaces `entire configure`. func newAgentGroupCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "agent", + Use: cmdAgent, Short: "Manage agent integrations (add, remove, list)", Long: `Manage agent integrations in this repository. @@ -57,7 +57,7 @@ func runAgentMenu(ctx context.Context, w io.Writer) error { func newAgentListCmd() *cobra.Command { return &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List installed and available agents", RunE: func(cmd *cobra.Command, _ []string) error { return runAgentList(cmd.Context(), cmd.OutOrStdout()) diff --git a/cmd/entire/cli/agent_help_cmd.go b/cmd/entire/cli/agent_help_cmd.go index 55858c3e34..13d6f6651a 100644 --- a/cmd/entire/cli/agent_help_cmd.go +++ b/cmd/entire/cli/agent_help_cmd.go @@ -692,9 +692,9 @@ func renderAgentHelpTop(rootCmd *cobra.Command, repoLine string, trailsEnabled b } // Use an example command that is actually advertised here (trail is gated on // trails being enabled), so we never point at a command the agent can't use. - example := "checkpoint" + example := cmdCheckpoint if trailsEnabled { - example = "trail" + example = cmdTrail } fmt.Fprintf(&b, "\nDrill in for exact, currently-installed flags: entire agent-help (e.g. entire agent-help %s)\n", example) b.WriteString("Add --json for structured output.\n") diff --git a/cmd/entire/cli/auth.go b/cmd/entire/cli/auth.go index 52bae07bac..d627d7682a 100644 --- a/cmd/entire/cli/auth.go +++ b/cmd/entire/cli/auth.go @@ -215,7 +215,7 @@ func newAuthTokenCmd() *cobra.Command { func newAuthStatusCmd() *cobra.Command { var insecureHTTPAuth bool cmd := &cobra.Command{ - Use: "status", + Use: cmdStatus, Short: "Show authentication status", RunE: func(cmd *cobra.Command, _ []string) error { target, err := resolveAuthStatusTarget(cmd.Context(), auth.Contexts, auth.RefreshedLoginToken) diff --git a/cmd/entire/cli/auth/cell_data_api.go b/cmd/entire/cli/auth/cell_data_api.go index 6ba8e5f865..7e58de640d 100644 --- a/cmd/entire/cli/auth/cell_data_api.go +++ b/cmd/entire/cli/auth/cell_data_api.go @@ -14,11 +14,12 @@ import ( "strings" "time" + "github.com/entireio/auth-go/sts" + "github.com/entireio/cli/cmd/entire/cli/api" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/entireio/cli/internal/entireclient/clusterdiscovery" "github.com/entireio/cli/internal/entireclient/contexts" - "github.com/entireio/cli/internal/entireclient/httputil" "github.com/entireio/cli/internal/entireclient/userdirs" ) @@ -185,7 +186,7 @@ func JurisdictionToken(ctx context.Context, insecureHTTP bool, jurisdiction stri } audience := jurisdictionAudience(j, subject.dataOrigin, subject.discoveredCore) - token, err := exchangeJurisdictionToken(ctx, coreURL, subject.loginJWT, audience, subject.httpClient) + token, err := exchangeJurisdictionToken(ctx, coreURL, subject.loginJWT, audience, subject.httpClient.Transport) if err != nil { return "", fmt.Errorf("exchange jurisdictional identity token: %w", err) } @@ -676,18 +677,47 @@ func resolveCellAPIBaseURL(ctx context.Context, coreURL, loginJWT, jurisdiction return strings.TrimRight(chosen.APIURL, "/"), nil } -func exchangeJurisdictionToken(ctx context.Context, coreURL, loginJWT, audience string, httpClient *http.Client) (string, error) { +// exchangeJurisdictionToken mints the jurisdictional identity token for a +// cell, trading the login JWT for one pinned to audience. +// +// Through auth-go's sts client rather than a hand-rolled POST, so the CLI has +// one RFC 8693 implementation: the duplicate this replaced had drifted, losing +// auth-go's terminal-escape sanitisation of server error text and keeping its +// own redirect guard in the CLI rather than the library. +// +// Takes the transport, not the caller's *http.Client: only the transport (and +// so the connection pool) carries over. The Timeout deliberately does not — +// sts applies the same budget via context.WithTimeout, which unlike +// Client.Timeout does not cancel the post-response body read. Note sts also +// narrows plain HTTP to loopback on top of AllowInsecureHTTP, so that is the +// effective policy here regardless of --insecure-http-auth. +// +// subject_token_type stays access_token, not JWT — what the replaced form sent +// and what entire-core matches on. +func exchangeJurisdictionToken(ctx context.Context, coreURL, loginJWT, audience string, transport http.RoundTripper) (string, error) { if coreURL == "" { return "", errors.New("no entire-core URL configured for jurisdiction token exchange") } - form := httputil.TokenExchangeForm(loginJWT, audience, JurisdictionIdentityScope) - - token, _, err := httputil.PostOAuthToken(ctx, httpClient, coreURL, form) + client := &sts.Client{ + Transport: transport, + BaseURL: coreURL, + Path: oauthTokenPath, + AllowInsecureHTTP: shouldUsePlainHTTPDiscovery(coreURL), + RequestTimeout: cellDataAPITimeout, + } + ts, err := client.Exchange(ctx, sts.ExchangeRequest{ + SubjectToken: loginJWT, + SubjectTokenType: sts.SubjectTokenTypeAccessToken, + RequestedTokenType: sts.SubjectTokenTypeAccessToken, + Audience: audience, + Scope: JurisdictionIdentityScope, + ClientID: oauthClientID, + }) if err != nil { return "", fmt.Errorf("post token exchange: %w", err) } - if strings.TrimSpace(token) == "" { + if strings.TrimSpace(ts.AccessToken) == "" { return "", errors.New("token exchange returned an empty access token") } - return token, nil + return ts.AccessToken, nil } diff --git a/cmd/entire/cli/auth/provider.go b/cmd/entire/cli/auth/provider.go index 1b979c54a0..a9e6131368 100644 --- a/cmd/entire/cli/auth/provider.go +++ b/cmd/entire/cli/auth/provider.go @@ -1,7 +1,5 @@ package auth -import "github.com/entireio/cli/internal/entireclient/httputil" - // OAuth wiring for the entire-cli public client against an entire-core // login server. Matches an OIDC-standard auth server's discovery doc — // confirmed against a regional core's (us.auth.entire.io) @@ -16,7 +14,15 @@ import "github.com/entireio/cli/internal/entireclient/httputil" // dialled there, and both are redirected to a region. The token endpoint is // retargeted at that region mid-login — see UseTokenIssuer in client.go. const ( - oauthClientID = httputil.OAuthClientID + // OAuthClientID is the public OAuth client_id the CLI identifies as on + // /oauth/token. Exported because internal/coreapi presents the same + // identity on the cross-jurisdiction exchange; auth-go lifts it into HTTP + // Basic per RFC 6749 §2.3.1 (zitadel/oidc's token endpoint reads client + // credentials only from Basic auth, so a form-only client_id produces + // invalid_client). + OAuthClientID = "entire-cli" + + oauthClientID = OAuthClientID oauthDeviceCodePath = "/device_authorization" oauthAuthorizePath = "/authorize" oauthTokenPath = "/oauth/token" //nolint:gosec // G101: an endpoint path, not a credential diff --git a/cmd/entire/cli/auth/refresh.go b/cmd/entire/cli/auth/refresh.go index 1ba5fdb685..520eaa695e 100644 --- a/cmd/entire/cli/auth/refresh.go +++ b/cmd/entire/cli/auth/refresh.go @@ -112,7 +112,7 @@ func (s contextTokenStore) DeleteTokens(string) error { // multi-core user's credentials never travel to (or get keyed under) a host // the context doesn't belong to. No RFC 8693 exchange runs through it any // more — data-plane bearers are the login JWT itself (ResolveDataAPIToken) and -// jurisdiction tokens are minted via httputil.PostOAuthToken. +// jurisdiction tokens are minted via sts (exchangeJurisdictionToken). // // transport carries the caller's TLS configuration; allowInsecureHTTP permits // an http:// core/resource for loopback/dev. diff --git a/cmd/entire/cli/benchutil/benchutil.go b/cmd/entire/cli/benchutil/benchutil.go index 4c4f50ea7f..e7c2f905d8 100644 --- a/cmd/entire/cli/benchutil/benchutil.go +++ b/cmd/entire/cli/benchutil/benchutil.go @@ -29,6 +29,12 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) +// Fixture identity for every benchmark repo and commit this package builds. +const ( + benchAuthorName = "Bench User" + benchAuthorEmail = "bench@example.com" +) + // BenchRepo is a fully initialized git repository with Entire configured, // ready for checkpoint benchmarks. type BenchRepo struct { @@ -150,8 +156,8 @@ func NewBenchRepo(b *testing.B, opts RepoOpts) *BenchRepo { } headHash, err = wt.Commit(fmt.Sprintf("Commit %d", c+1), &git.CommitOptions{ Author: &object.Signature{ - Name: "Bench User", - Email: "bench@example.com", + Name: benchAuthorName, + Email: benchAuthorEmail, When: time.Now(), }, }) @@ -371,8 +377,8 @@ func (br *BenchRepo) SeedShadowBranch(b *testing.B, sessionID string, checkpoint ModifiedFiles: modified, MetadataDir: metadataDir, CommitMessage: fmt.Sprintf("Checkpoint %d", i+1), - AuthorName: "Bench User", - AuthorEmail: "bench@example.com", + AuthorName: benchAuthorName, + AuthorEmail: benchAuthorEmail, IsFirstCheckpoint: i == 0, }) if err != nil { @@ -412,8 +418,8 @@ func (br *BenchRepo) SeedMetadataBranch(b *testing.B, checkpointCount int) { Prompts: []string{fmt.Sprintf("Implement feature %d", i)}, FilesTouched: files, CheckpointsCount: 3, - AuthorName: "Bench User", - AuthorEmail: "bench@example.com", + AuthorName: benchAuthorName, + AuthorEmail: benchAuthorEmail, Agent: agent.AgentTypeClaudeCode, }) if err != nil { diff --git a/cmd/entire/cli/checkpoint/fsstore/fsstore.go b/cmd/entire/cli/checkpoint/fsstore/fsstore.go index 7fb341edb7..baa4c05084 100644 --- a/cmd/entire/cli/checkpoint/fsstore/fsstore.go +++ b/cmd/entire/cli/checkpoint/fsstore/fsstore.go @@ -326,7 +326,7 @@ func metadataFromWriteOptions(opts cp.WriteOptions) cp.Metadata { TurnID: opts.TurnID, TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart, CheckpointTranscriptStart: opts.CheckpointTranscriptStart, - TranscriptLinesAtStart: opts.CheckpointTranscriptStart, // git writes both for back-compat + TranscriptLinesAtStart: opts.CheckpointTranscriptStart, //nolint:staticcheck // deliberate: git writes both so older CLIs can still read the metadata TokenUsage: opts.TokenUsage, SkillEvents: opts.SkillEvents, PromptAttributions: opts.PromptAttributionsJSON, diff --git a/cmd/entire/cli/checkpoint/id/id.go b/cmd/entire/cli/checkpoint/id/id.go index 7e23736f17..93fba1e53e 100644 --- a/cmd/entire/cli/checkpoint/id/id.go +++ b/cmd/entire/cli/checkpoint/id/id.go @@ -16,8 +16,6 @@ import ( // CheckpointID identifies a checkpoint. It comes in two formats: a legacy // 12-character lowercase hex ID and a 26-character Crockford base32 ULID (see // Kind / CheckpointPattern). It links code commits to their checkpoint metadata. -// -//nolint:recvcheck // UnmarshalJSON requires pointer receiver, others use value receiver - standard pattern type CheckpointID string // EmptyCheckpointID represents an unset or invalid checkpoint ID. diff --git a/cmd/entire/cli/checkpoint/persistent.go b/cmd/entire/cli/checkpoint/persistent.go index bc185ac186..e095d091a7 100644 --- a/cmd/entire/cli/checkpoint/persistent.go +++ b/cmd/entire/cli/checkpoint/persistent.go @@ -727,7 +727,7 @@ func (s *treeWriter) writeSessionToSubdirectory(ctx context.Context, opts WriteO TurnID: opts.TurnID, TranscriptIdentifierAtStart: opts.TranscriptIdentifierAtStart, CheckpointTranscriptStart: opts.CheckpointTranscriptStart, - TranscriptLinesAtStart: opts.CheckpointTranscriptStart, // Deprecated: kept for backward compat + TranscriptLinesAtStart: opts.CheckpointTranscriptStart, //nolint:staticcheck // deliberate: written so older CLIs can still read the metadata CompactTranscriptStart: compactTranscriptStart, TokenUsage: opts.TokenUsage, SkillEventsVersion: skillEventsVersion(opts.SkillEvents), diff --git a/cmd/entire/cli/checkpoint/remote/util.go b/cmd/entire/cli/checkpoint/remote/util.go index ac6056e440..55339ba9c3 100644 --- a/cmd/entire/cli/checkpoint/remote/util.go +++ b/cmd/entire/cli/checkpoint/remote/util.go @@ -55,6 +55,38 @@ func FetchURL(ctx context.Context, opts ...FetchURLOptions) (string, error) { return url, err } +// ReadsDedicatedStore reports whether checkpoint READS resolve to the +// configured dedicated checkpoint_remote. +// +// leadReadRemote contributes an IDENTITY to the ownership vote, and nothing +// else: when a checkpoint_remote is configured the URL is always derived from +// origin, which is why FetchURLOptions.LeadReadRemote says the dedicated path +// "ignores it entirely". Pass the elected sync remote so the vote sees the +// same fork-shaped identity the push side sees. +// +// The read counterpart of PushURL's enabled bit, and deliberately a separate +// question: both require every identity to be owned by the checkpoint repo's +// owner, but the push identity set is origin plus the elected remote's PUSH +// URLs while the fetch set is origin plus leadReadRemote's FETCH URL — so a +// remote whose two URLs have different owners is eligible on one side and not +// the other. A caller reporting where checkpoints COME FROM must ask this +// one; PushURL answers where they would GO. +// +// False covers every reason reads do not land on the configured store, not +// only an inherited one: no checkpoint_remote configured, ownership not +// confirmed, unreadable settings, an origin URL that will not parse, or a +// protocol that maps to no checkpoint URL and no provider host. A caller that +// needs to explain WHY cannot read it off this bool; the reasons are logged +// where they are decided. +// +// Local-only, like FetchURL: git config and settings reads, no dialing. An +// error means no read URL resolves at all — distinct from false, which means +// reads resolve somewhere else. +func ReadsDedicatedStore(ctx context.Context, leadReadRemote string) (bool, error) { + _, authoritative, err := fetchURLAuthoritative(ctx, FetchURLOptions{LeadReadRemote: leadReadRemote}) + return authoritative, err +} + // fetchURLAuthoritative is FetchURL plus whether the returned URL is // authoritative for checkpoint refs. It is false exactly when a // checkpoint_remote IS configured (or cannot be determined) but resolution diff --git a/cmd/entire/cli/checkpoint_group.go b/cmd/entire/cli/checkpoint_group.go index 8210aac97f..767f018336 100644 --- a/cmd/entire/cli/checkpoint_group.go +++ b/cmd/entire/cli/checkpoint_group.go @@ -12,8 +12,8 @@ import ( // registers list/explain/tokens/search/resume as children. func newCheckpointGroupCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "checkpoint", - Aliases: []string{"cp", "checkpoints"}, + Use: cmdCheckpoint, + Aliases: []string{"cp", cmdCheckpointsAlias}, Short: "Inspect and search checkpoints", Long: `Operations on checkpoints — the persistent records of agent work tied to commits. @@ -75,7 +75,7 @@ func newCheckpointListCmd() *cobra.Command { var pendingFlag bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List checkpoints on the current branch", Long: `List checkpoints on the current branch. diff --git a/cmd/entire/cli/checkpoint_tokens.go b/cmd/entire/cli/checkpoint_tokens.go index 6f5bb557f9..b9c37b9566 100644 --- a/cmd/entire/cli/checkpoint_tokens.go +++ b/cmd/entire/cli/checkpoint_tokens.go @@ -218,20 +218,20 @@ func buildCheckpointTokensReport(cpID id.CheckpointID, summary *checkpoint.Check report.Tokens = tokens if tokens.SubagentTotal > 0 { report.Contributors = append(report.Contributors, sessionTokensContributor{ - Kind: "subagents", + Kind: tokensKindSubagents, Label: "Subagents", Tokens: tokens.SubagentTotal, - Confidence: "reported", - Signals: []string{"subagent_tokens"}, + Confidence: tokensConfidenceReported, + Signals: []string{tokensSignalSubagentTokens}, }) } } else { report.Limitations = append(report.Limitations, "No token usage recorded for this checkpoint.") report.Recommendations = append(report.Recommendations, sessionTokensRecommendation{ ID: "no-token-data", - Severity: "low", + Severity: tokensSeverityLow, Message: "Token usage is unavailable for this checkpoint; the agent may not expose token data yet, or this checkpoint predates token tracking.", - Signals: []string{"missing_token_usage"}, + Signals: []string{tokensSignalMissingUsage}, }) } if metadataWarnings > 0 { @@ -251,11 +251,11 @@ func buildCheckpointTokensReport(cpID id.CheckpointID, summary *checkpoint.Check if contextInfo := buildSessionTokensContext(metrics.ContextTokens, metrics.ContextWindowSize); contextInfo != nil { report.Context = contextInfo report.Contributors = append(report.Contributors, sessionTokensContributor{ - Kind: "context_pressure", + Kind: tokensKindContextPressure, Label: "Context pressure", Percent: contextInfo.Percent, - Confidence: "reported", - Signals: []string{"context_tokens"}, + Confidence: tokensConfidenceReported, + Signals: []string{tokensSignalContextTokens}, }) } } @@ -275,7 +275,7 @@ func buildCheckpointTokensReport(cpID id.CheckpointID, summary *checkpoint.Check report.Contributors = append(report.Contributors, sessionTokensContributor{ Kind: "skills", Label: "Skills/slash commands: " + strings.Join(labels, ", "), - Confidence: "reported", + Confidence: tokensConfidenceReported, Signals: []string{"skill_events"}, }) } diff --git a/cmd/entire/cli/corecmd_json_flag_test.go b/cmd/entire/cli/corecmd_json_flag_test.go index 137ff33f18..c859330cae 100644 --- a/cmd/entire/cli/corecmd_json_flag_test.go +++ b/cmd/entire/cli/corecmd_json_flag_test.go @@ -50,6 +50,10 @@ func TestControlPlaneJSONFlag_OnlyOnHonoringCommands(t *testing.T) { "repo mirror collaborators list": true, "repo visibility get": true, "repo visibility set": true, + // add/remove print the resulting rule list, so they render JSON too. + "repo protection list": true, + "repo protection add": true, + "repo protection remove": true, // grant "grant org add": true, "grant org list": true, diff --git a/cmd/entire/cli/dispatch.go b/cmd/entire/cli/dispatch.go index f1fb64e5d8..92589423ee 100644 --- a/cmd/entire/cli/dispatch.go +++ b/cmd/entire/cli/dispatch.go @@ -147,7 +147,7 @@ func runDispatchCommand(ctx context.Context, outW io.Writer, opts dispatchpkg.Op } func isTerminalStdin(file *os.File) bool { - return term.IsTerminal(int(file.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + return term.IsTerminal(int(file.Fd())) } func shouldRunDispatchWizard(flagCount int, stdinIsTerminal bool, stdoutIsTerminal bool) bool { diff --git a/cmd/entire/cli/explain.go b/cmd/entire/cli/explain.go index 3bba24fd0b..d8727848fc 100644 --- a/cmd/entire/cli/explain.go +++ b/cmd/entire/cli/explain.go @@ -1037,7 +1037,7 @@ func generateCheckpointSummary(ctx context.Context, w, errW io.Writer, store che if content.Metadata.Summary != nil && !force { return renderExplainFailure(errW, "Summary already exists", []explainRow{ {Label: "id", Value: checkpointID.String()}, - {Label: "try", Value: fmt.Sprintf("entire checkpoint explain --generate --force %s", checkpointID)}, + {Label: explainLabelTry, Value: fmt.Sprintf("entire checkpoint explain --generate --force %s", checkpointID)}, }, fmt.Errorf("checkpoint %s already has a summary", checkpointID)) } @@ -1257,28 +1257,28 @@ func formatCheckpointSummaryError(err error, attempt *summaryAttempt) (string, [ case claudecode.ClaudeErrorAuth: label := "Claude authentication failed" rows := []explainRow{ - {Label: "try", Value: "run `claude login` and retry"}, + {Label: explainLabelTry, Value: "run `claude login` and retry"}, } if claudeErr.Message != "" { - rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...) + rows = append([]explainRow{{Label: explainLabelMessage, Value: claudeErr.Message}}, rows...) } return label, rows, fmt.Errorf("Claude authentication failed%s", formatMessageSuffix(claudeErr.Message)) //nolint:staticcheck // ST1005: Claude is a proper noun case claudecode.ClaudeErrorRateLimit: label := "Claude rejected the summary request due to rate limits or quota" rows := []explainRow{ - {Label: "try", Value: "wait and retry"}, + {Label: explainLabelTry, Value: "wait and retry"}, } if claudeErr.Message != "" { - rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...) + rows = append([]explainRow{{Label: explainLabelMessage, Value: claudeErr.Message}}, rows...) } return label, rows, fmt.Errorf("Claude rejected the summary request due to rate limits or quota%s", formatMessageSuffix(claudeErr.Message)) //nolint:staticcheck // ST1005 case claudecode.ClaudeErrorConfig: label := "Claude rejected the summary request" rows := []explainRow{ - {Label: "try", Value: "check your Claude CLI config and selected model"}, + {Label: explainLabelTry, Value: "check your Claude CLI config and selected model"}, } if claudeErr.Message != "" { - rows = append([]explainRow{{Label: "message", Value: claudeErr.Message}}, rows...) + rows = append([]explainRow{{Label: explainLabelMessage, Value: claudeErr.Message}}, rows...) } return label, rows, fmt.Errorf("Claude rejected the summary request%s", formatMessageSuffix(claudeErr.Message)) //nolint:staticcheck // ST1005 case claudecode.ClaudeErrorCLIMissing: @@ -1356,33 +1356,33 @@ func timeoutDiagnostic(_ error, attempt *summaryAttempt) (string, []explainRow) case attempt.phasesReached[agent.PhaseDone]: label = "model finished but the result was not delivered in time" rows = []explainRow{ - {Label: "cause", Value: "the deadline fired while the finished result was being read"}, - {Label: "try", Value: "raise --summary-timeout-seconds and retry"}, + {Label: explainLabelCause, Value: "the deadline fired while the finished result was being read"}, + {Label: explainLabelTry, Value: "raise --summary-timeout-seconds and retry"}, } case attempt.phasesReached[agent.PhaseGenerating], attempt.phasesReached[agent.PhaseFirstToken]: label = "model responded but did not finish" rows = []explainRow{ - {Label: "cause", Value: "transcript may be too large for the chosen cap, or model is slow"}, - {Label: "try", Value: "raise --summary-timeout-seconds or pick a faster model"}, + {Label: explainLabelCause, Value: "transcript may be too large for the chosen cap, or model is slow"}, + {Label: explainLabelTry, Value: "raise --summary-timeout-seconds or pick a faster model"}, } case attempt.phasesReached[agent.PhaseConnecting]: label = "provider sent request but received no response" rows = []explainRow{ - {Label: "cause", Value: "network/firewall, provider API degraded, or auth check stuck"}, - {Label: "try", Value: "check connectivity to the provider, then retry"}, + {Label: explainLabelCause, Value: "network/firewall, provider API degraded, or auth check stuck"}, + {Label: explainLabelTry, Value: "check connectivity to the provider, then retry"}, } default: label = "provider never sent its request" rows = []explainRow{ - {Label: "cause", Value: "the provider CLI may be stalled before subprocess startup"}, - {Label: "try", Value: tryRunCLI}, + {Label: explainLabelCause, Value: "the provider CLI may be stalled before subprocess startup"}, + {Label: explainLabelTry, Value: tryRunCLI}, } } // attempt.streaming is set eagerly when a streaming-capable provider // is selected, so a provider that stalls before its first event lands // here — surface the captured stderr rather than dropping it. if stderr != "" { - rows = append(rows, explainRow{Label: "stderr", Value: stderr}) + rows = append(rows, explainRow{Label: explainLabelStderr, Value: stderr}) } return prefix + label, rows } @@ -1391,21 +1391,21 @@ func timeoutDiagnostic(_ error, attempt *summaryAttempt) (string, []explainRow) if stdoutBytes == 0 { rows := []explainRow{ - {Label: "cause", Value: "provider CLI produced no output (likely network/auth/CLI path issue)"}, - {Label: "try", Value: tryRunCLI}, + {Label: explainLabelCause, Value: "provider CLI produced no output (likely network/auth/CLI path issue)"}, + {Label: explainLabelTry, Value: tryRunCLI}, } if stderr != "" { - rows = append(rows, explainRow{Label: "stderr", Value: stderr}) + rows = append(rows, explainRow{Label: explainLabelStderr, Value: stderr}) } return prefix + "provider produced no output", rows } rows := []explainRow{ - {Label: "cause", Value: "provider was generating output but did not finish before cap"}, - {Label: "try", Value: "raise --summary-timeout-seconds"}, + {Label: explainLabelCause, Value: "provider was generating output but did not finish before cap"}, + {Label: explainLabelTry, Value: "raise --summary-timeout-seconds"}, } if stderr != "" { - rows = append(rows, explainRow{Label: "stderr", Value: stderr}) + rows = append(rows, explainRow{Label: explainLabelStderr, Value: stderr}) } return prefix + "provider was generating output when killed", rows } @@ -1728,7 +1728,7 @@ func explainTemporaryCheckpoint(ctx context.Context, w, errW io.Writer, repo *gi label := fmt.Sprintf("Checkpoint %s [temporary]", shortID) rows := []explainRow{ - {Label: "session", Value: tc.SessionID}, + {Label: explainLabelSession, Value: tc.SessionID}, {Label: "created", Value: tc.Timestamp.Format("2006-01-02 15:04:05")}, } sb.WriteString(styles.renderIdentity(label, "", rows)) @@ -3083,7 +3083,7 @@ func outputWithPager(w io.Writer, content string) { // Check if we're writing to stdout and it's a terminal if f, ok := w.(*os.File); ok && f == os.Stdout && interactive.IsTerminalWriter(w) { // Get terminal height - _, height, err := term.GetSize(int(f.Fd())) //nolint:gosec // G115: same as above + _, height, err := term.GetSize(int(f.Fd())) if err != nil { height = 24 // Default fallback } @@ -3153,9 +3153,9 @@ func formatBranchCheckpoints(w io.Writer, branchName string, points []strategy.P {Label: "branch", Value: branchName}, } if sessionFilter != "" { - branchRows = append(branchRows, explainRow{Label: "session", Value: sessionFilter}) + branchRows = append(branchRows, explainRow{Label: explainLabelSession, Value: sessionFilter}) } - branchRows = append(branchRows, explainRow{Label: "checkpoints", Value: strconv.Itoa(len(groups))}) + branchRows = append(branchRows, explainRow{Label: explainLabelCheckpoints, Value: strconv.Itoa(len(groups))}) sb.WriteString(styles.metadataRows(branchRows)) sb.WriteString("\n") diff --git a/cmd/entire/cli/grant.go b/cmd/entire/cli/grant.go index 4d62946c47..f38efeb4f6 100644 --- a/cmd/entire/cli/grant.go +++ b/cmd/entire/cli/grant.go @@ -67,8 +67,8 @@ func newGrantCmd() *cobra.Command { // grants, so GRANTEE shows a friendly name (handle/org name) with SOURCE // saying where the grant comes from; ID keeps the ULID for revoke. var ( - orgMemberColumns = []string{"ACCOUNT", "ROLE", "STATUS"} - grantColumns = []string{"GRANTEE", "ROLE", "SOURCE", "TYPE", "ID"} + orgMemberColumns = []string{"ACCOUNT", colHeaderRole, colHeaderStatus} + grantColumns = []string{"GRANTEE", colHeaderRole, "SOURCE", "TYPE", "ID"} ) func orgMemberRow(m coreapi.Membership) []string { @@ -98,7 +98,7 @@ func granteeName(name coreapi.OptString, granteeID string) string { func newGrantOrgCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "org", + Use: cmdOrg, Short: "Manage org membership", } cmd.AddCommand(newGrantOrgAddCmd()) @@ -313,6 +313,10 @@ func newGrantProjectRemoveCmd() *cobra.Command { return cmd } +// granteeTypeAccount is the only grantee kind the revoke-by-id calls take; the +// provider-qualified variant has its own endpoint. +const granteeTypeAccount = "account" + // revokeProjectGrantee revokes a grantee (provider:handle or account ULID) from // a resolved project. projectRef is the user's original (pre-resolution) project // ref, used only for the success message. @@ -321,7 +325,7 @@ func revokeProjectGrantee(ctx context.Context, cmd *cobra.Command, c *coreapi.Cl func() error { return c.RevokeProjectAccess(ctx, coreapi.RevokeProjectAccessParams{ ProjectId: projID, - GranteeType: "account", + GranteeType: granteeTypeAccount, GranteeId: grantee, }) }, @@ -363,7 +367,7 @@ func revokeGrantee( func newGrantRepoCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "repo", + Use: cmdRepo, Short: "Manage repo access", } cmd.AddCommand(newGrantRepoAddCmd()) @@ -476,7 +480,7 @@ func revokeRepoGrantee(ctx context.Context, cmd *cobra.Command, c *coreapi.Clien func() error { return c.RevokeRepoAccess(ctx, coreapi.RevokeRepoAccessParams{ RepoId: repoID, - GranteeType: "account", + GranteeType: granteeTypeAccount, GranteeId: grantee, }) }, diff --git a/cmd/entire/cli/hook_registry.go b/cmd/entire/cli/hook_registry.go index 09972d10eb..a4ffc7636f 100644 --- a/cmd/entire/cli/hook_registry.go +++ b/cmd/entire/cli/hook_registry.go @@ -77,6 +77,13 @@ func newAgentHooksCmd(agentName types.AgentName, handler agent.HookSupport) *cob return cmd } +// Hook categories reported by getHookType. +const ( + hookTypeAgent = "agent" + hookTypeTool = "tool" + hookTypeSubagent = "subagent" +) + // getHookType returns the hook type based on the hook name. // Returns "subagent" for task-related hooks (pre-task, post-task, post-todo, // subagent-stop), "tool" for tool-related hooks (before-tool, after-tool), @@ -85,11 +92,11 @@ func getHookType(hookName string) string { switch hookName { case claudecode.HookNamePreTask, claudecode.HookNamePostTask, claudecode.HookNamePostTodo, claudecode.HookNameSubagentStop: - return "subagent" + return hookTypeSubagent case geminicli.HookNameBeforeTool, geminicli.HookNameAfterTool: - return "tool" + return hookTypeTool default: - return "agent" + return hookTypeAgent } } diff --git a/cmd/entire/cli/integration_test/codex_image_externalize_test.go b/cmd/entire/cli/integration_test/codex_image_externalize_test.go index 97cc518307..c292f6ba9e 100644 --- a/cmd/entire/cli/integration_test/codex_image_externalize_test.go +++ b/cmd/entire/cli/integration_test/codex_image_externalize_test.go @@ -42,7 +42,7 @@ func TestCodexImageExternalization_FullHookFlow(t *testing.T) { // A Codex rollout: session meta, then a user message with an inline image // data-URI (the confirmed real format), then an assistant reply. rollout := strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + env.RepoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + env.RepoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[` + `{"type":"input_text","text":"add feature.txt and look at this screenshot"},` + `{"type":"input_image","image_url":"data:image/png;base64,` + b64 + `"}` + diff --git a/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go b/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go index b010d457c4..ece4683874 100644 --- a/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go +++ b/cmd/entire/cli/integration_test/codex_shadow_sanitize_test.go @@ -29,7 +29,7 @@ var codexCiphertext = strings.Repeat("QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVph", 40) // the `encrypted_content` key; that is the only key the sanitizer strips. func codexRolloutWithEncryptedReasoning(sessionID, repoDir, ciphertext string) string { return strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + repoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + repoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":"add feature.txt"}]}}`, `{"timestamp":"2026-01-01T00:00:02Z","type":"response_item","payload":{"type":"reasoning","summary":[],"encrypted_content":"` + ciphertext + `"}}`, `{"timestamp":"2026-01-01T00:00:03Z","type":"response_item","payload":{"type":"compaction","encrypted_content":"` + ciphertext + `"}}`, @@ -318,7 +318,7 @@ func TestCodexCondense_NoAssetsFromSanitizedAwayContent(t *testing.T) { sessionID := "codex-sanitize-before-extract" transcriptPath := filepath.Join(env.RepoDir, ".entire", "tmp", "codex-rollout.jsonl") rollout := strings.Join([]string{ - `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","cwd":"` + env.RepoDir + `"}}`, + `{"timestamp":"2026-01-01T00:00:00Z","type":"session_meta","payload":{"id":"` + sessionID + `","thread_source":"user","cwd":"` + env.RepoDir + `"}}`, `{"timestamp":"2026-01-01T00:00:01Z","type":"response_item","payload":{"type":"message","role":"user","content":[` + `{"type":"input_text","text":"add feature.txt and look at this screenshot"},` + `{"type":"input_image","image_url":"data:image/png;base64,` + keptB64 + `"}` + diff --git a/cmd/entire/cli/integration_test/codex_subagent_test.go b/cmd/entire/cli/integration_test/codex_subagent_test.go index 50ec2b2c16..ac381211d3 100644 --- a/cmd/entire/cli/integration_test/codex_subagent_test.go +++ b/cmd/entire/cli/integration_test/codex_subagent_test.go @@ -30,21 +30,28 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { agentID = "child-thread-9" editedFile = "docs/red.md" ) + complete := true require.NoError(t, env.WriteSessionState(sessionID, &session.State{ - SessionID: sessionID, - AgentType: agent.AgentTypeCodex, - BaseCommit: env.GetHeadHash(), + SessionID: sessionID, + AgentType: agent.AgentTypeCodex, + BaseCommit: env.GetHeadHash(), + SubagentInventoryComplete: &complete, })) rolloutDir := filepath.Join(env.RepoDir, ".entire", "tmp", "codex-rollouts") require.NoError(t, os.MkdirAll(rolloutDir, 0o750)) parentRollout := filepath.Join(rolloutDir, "rollout-"+sessionID+".jsonl") - require.NoError(t, os.WriteFile(parentRollout, []byte(`{"type":"session_meta","payload":{"id":"`+sessionID+`"}}`+"\n"), 0o600)) + require.NoError(t, os.WriteFile(parentRollout, []byte(`{"type":"session_meta","payload":{"id":"`+sessionID+`","thread_source":"user"}}`+"\n"), 0o600)) subagentRollout := filepath.Join(rolloutDir, "rollout-"+agentID+".jsonl") - require.NoError(t, os.WriteFile(subagentRollout, - []byte(`{"type":"response_item","payload":{"content":"wrote `+editedFile+`"}}`+"\n"), 0o600)) - + require.NoError(t, os.WriteFile(subagentRollout, []byte( + `{"type":"session_meta","payload":{"id":"`+agentID+`","forked_from_id":"`+sessionID+`"}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"task_started","turn_id":"inherited-parent-turn"}}`+"\n"+ + `{"type":"response_item","payload":{"type":"custom_tool_call","name":"apply_patch","input":"*** Begin Patch\n*** Add File: parent-only.txt\n+x\n*** End Patch"}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"task_started","turn_id":"turn-1"}}`+"\n"+ + `{"type":"response_item","payload":{"type":"custom_tool_call","status":"completed","name":"apply_patch","input":"*** Begin Patch\n*** Add File: `+editedFile+`\n+red\n*** End Patch"}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":2,"output_tokens":3}}}}`+"\n"+ + `{"type":"event_msg","payload":{"type":"task_complete","turn_id":"turn-1"}}`+"\n"), 0o600)) hook := codexHooker(t, env.RepoDir, sessionID, parentRollout) hook("subagent-start", map[string]any{ "hook_event_name": "SubagentStart", @@ -70,11 +77,22 @@ func TestCodexSubagent_StoresDeclaredSubagentTranscript(t *testing.T) { require.NoError(t, err) rec := state.FindTaskRecord(agentID) require.NotNil(t, rec, "expected a task record keyed by agent_id") - require.False(t, rec.CompletedAt.IsZero(), "subagent-stop must complete the record") - require.Equal(t, subagentRollout, rec.DeclaredTranscriptPath, - "the declared agent_transcript_path was not honoured") - require.True(t, containsFile(rec.Files, editedFile), - "the record must carry the subagent's edit, got %v", rec.Files) + require.True(t, rec.CompletedAt.IsZero(), "provisional subagent-stop must not complete the record") + + // The fixture includes the real fork shape: inherited parent history with + // an open parent turn and no ordinal boundary. Root Stop observes terminal evidence in the same verified child rollout. + hook("stop", map[string]any{"hook_event_name": "Stop", "last_assistant_message": "done"}) + state, err = env.GetSessionState(sessionID) + require.NoError(t, err) + require.Len(t, state.SubagentInventory, 1) + require.Equal(t, subagentRollout, state.SubagentInventory[0].DeclaredTranscriptPath) + require.Equal(t, []string{"turn-1"}, state.SubagentInventory[0].FinalizedTurnIDs) + rec = state.FindTaskRecord(agentID) + require.NotNil(t, rec) + require.False(t, rec.CompletedAt.IsZero(), "terminal child rollout must reconcile the record") + require.Equal(t, []string{editedFile}, rec.Files, "only hook-observed child turns contribute files") + require.Nil(t, rec.TokenUsage, "unscoped fork counters are not exact child usage") + require.False(t, *state.TokenUsage.SubagentTokensComplete) // Committing condenses the session, and the materializer must store the rollout // itself — the storage guarantee this test is named for. diff --git a/cmd/entire/cli/integration_test/external_command_signal_unix_test.go b/cmd/entire/cli/integration_test/external_command_signal_unix_test.go index c5c519d18a..fd0a8511f2 100644 --- a/cmd/entire/cli/integration_test/external_command_signal_unix_test.go +++ b/cmd/entire/cli/integration_test/external_command_signal_unix_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -92,3 +93,72 @@ func waitForFile(path string, timeout time.Duration) bool { } return false } + +// A signal Entire received outranks the one its child died of. +// +// Cancelling the context makes runPlugin send the plugin SIGINT whatever +// Entire itself was sent, so the child's signal is often Entire's own signal +// laundered — and laundered lossily. A supervisor's SIGTERM must still leave +// Entire dying of SIGTERM (143), not of whatever the child ended up with: +// this plugin ignores SIGINT, so it outlives WaitDelay and os/exec SIGKILLs +// it, which reported 137 before the precedence was fixed. +func TestExternalCommand_ParentsSignalOutranksTheChilds(t *testing.T) { + t.Parallel() + dir := t.TempDir() + readyFile := filepath.Join(dir, "ready.txt") + // Longer than the parent's WaitDelay (5s) plus grace, so the child is + // still alive when the delay expires and is killed rather than exiting. + const pluginLoopSeconds = 20 + body := fmt.Sprintf( + "#!/bin/sh\ntrap '' INT\n"+ + "echo ready > %q\n"+ + "i=0\nwhile [ $i -lt %d ]; do sleep 0.1; i=$((i+1)); done\nexit 0\n", + readyFile, pluginLoopSeconds*10, + ) + if err := os.WriteFile(filepath.Join(dir, "entire-ignoreint"), []byte(body), 0o755); err != nil { + t.Fatalf("write plugin: %v", err) + } + + cmd := execx.NonInteractive(context.Background(), getTestBinary(), "ignoreint") + cmd.Env = pathWith(dir) + var pStderr bytes.Buffer + cmd.Stdout = &bytes.Buffer{} + cmd.Stderr = &pStderr + if err := cmd.Start(); err != nil { + t.Fatalf("start: %v", err) + } + if !waitForFile(readyFile, 5*time.Second) { + if killErr := cmd.Process.Kill(); killErr != nil { + t.Logf("kill process: %v", killErr) + } + if waitErr := cmd.Wait(); waitErr != nil { + t.Logf("wait after kill: %v", waitErr) + } + t.Fatalf("plugin never reached ready state\nparent stderr:\n%s", pStderr.String()) + } + + // A supervisor or container stop, not a terminal Ctrl-C: only the parent + // is signalled, and with SIGTERM rather than SIGINT. + if err := cmd.Process.Signal(syscall.SIGTERM); err != nil { + t.Fatalf("signal parent: %v", err) + } + waitErr := cmd.Wait() + if waitErr == nil { + t.Fatalf("parent exited 0 after SIGTERM\nparent stderr:\n%s", pStderr.String()) + } + + // Re-raised, so the parent is genuinely WIFSIGNALED: an os.Exit(143) would + // not break an enclosing shell loop. + ws, ok := cmd.ProcessState.Sys().(syscall.WaitStatus) + if !ok { + t.Fatalf("no wait status: %v", waitErr) + } + if !ws.Signaled() { + t.Fatalf("parent exited %d rather than dying from a signal\nparent stderr:\n%s", + cmd.ProcessState.ExitCode(), pStderr.String()) + } + if ws.Signal() != syscall.SIGTERM { + t.Errorf("parent died of %v, want SIGTERM — the child's signal (SIGKILL here) must not outrank ours\nparent stderr:\n%s", + ws.Signal(), pStderr.String()) + } +} diff --git a/cmd/entire/cli/integration_test/hooks.go b/cmd/entire/cli/integration_test/hooks.go index 23bee389d9..ebfa064e0e 100644 --- a/cmd/entire/cli/integration_test/hooks.go +++ b/cmd/entire/cli/integration_test/hooks.go @@ -661,7 +661,7 @@ func (r *CodexHookRunner) runCodexHook(hookName string, inputJSON []byte) error cmd := exec.CommandContext(context.Background(), getTestBinary(), "hooks", "codex", hookName) cmd.Dir = r.RepoDir cmd.Stdin = bytes.NewReader(inputJSON) - cmd.Env = testutil.GitIsolatedEnv() + cmd.Env = append(testutil.GitIsolatedEnv(), "ENTIRE_TEST_CODEX_SESSION_DIR="+filepath.Join(r.RepoDir, ".entire", "tmp")) output, err := cmd.CombinedOutput() if err != nil { @@ -807,33 +807,33 @@ func (s *FactoryDroidSession) CreateDroidTranscript(prompt string, changes []Fil // User message with prompt lines = append(lines, map[string]interface{}{ - "type": "message", + "type": entryTypeMessage, "id": "m1", "message": map[string]interface{}{ - "role": "user", + "role": roleUser, "content": []map[string]interface{}{ - {"type": "text", "text": prompt}, + {"type": blockTypeText, "text": prompt}, }, }, }) // Assistant message with tool uses assistantContent := []interface{}{ - map[string]interface{}{"type": "text", "text": "I'll help you with that."}, + map[string]interface{}{"type": blockTypeText, "text": "I'll help you with that."}, } for i, change := range changes { assistantContent = append(assistantContent, map[string]interface{}{ - "type": "tool_use", + "type": blockTypeToolUse, "id": fmt.Sprintf("toolu_%d", i+1), "name": "Write", "input": map[string]string{"file_path": change.Path, "content": change.Content}, }) } lines = append(lines, map[string]interface{}{ - "type": "message", + "type": entryTypeMessage, "id": "m2", "message": map[string]interface{}{ - "role": "assistant", + "role": roleAssistant, "content": assistantContent, }, }) @@ -842,28 +842,28 @@ func (s *FactoryDroidSession) CreateDroidTranscript(prompt string, changes []Fil toolResultContent := make([]map[string]interface{}, 0, len(changes)) for i := range changes { toolResultContent = append(toolResultContent, map[string]interface{}{ - "type": "tool_result", + "type": blockTypeToolResult, "tool_use_id": fmt.Sprintf("toolu_%d", i+1), "content": "Success", }) } lines = append(lines, map[string]interface{}{ - "type": "message", + "type": entryTypeMessage, "id": "m3", "message": map[string]interface{}{ - "role": "user", + "role": roleUser, "content": toolResultContent, }, }) // Final assistant message lines = append(lines, map[string]interface{}{ - "type": "message", + "type": entryTypeMessage, "id": "m4", "message": map[string]interface{}{ - "role": "assistant", + "role": roleAssistant, "content": []map[string]interface{}{ - {"type": "text", "text": "Done!"}, + {"type": blockTypeText, "text": "Done!"}, }, }, }) @@ -1096,11 +1096,11 @@ func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []File s.messages = append(s.messages, map[string]interface{}{ "info": map[string]interface{}{ "id": fmt.Sprintf("msg-%d", s.msgCounter), - "role": "user", + "role": roleUser, "time": map[string]interface{}{"created": 1708300000 + s.msgCounter}, }, "parts": []map[string]interface{}{ - {"type": "text", "text": prompt}, + {"type": blockTypeText, "text": prompt}, }, }) @@ -1108,7 +1108,7 @@ func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []File s.msgCounter++ var parts []map[string]interface{} parts = append(parts, map[string]interface{}{ - "type": "text", + "type": blockTypeText, "text": "I'll help you with that.", }) for i, change := range changes { @@ -1124,14 +1124,14 @@ func (s *OpenCodeSession) CreateOpenCodeTranscript(prompt string, changes []File }) } parts = append(parts, map[string]interface{}{ - "type": "text", + "type": blockTypeText, "text": "Done!", }) s.messages = append(s.messages, map[string]interface{}{ "info": map[string]interface{}{ "id": fmt.Sprintf("msg-%d", s.msgCounter), - "role": "assistant", + "role": roleAssistant, "time": map[string]interface{}{ "created": 1708300000 + s.msgCounter, "completed": 1708300000 + s.msgCounter + 5, diff --git a/cmd/entire/cli/integration_test/testenv.go b/cmd/entire/cli/integration_test/testenv.go index 7271beae64..9dfc2f5e1a 100644 --- a/cmd/entire/cli/integration_test/testenv.go +++ b/cmd/entire/cli/integration_test/testenv.go @@ -36,6 +36,22 @@ import ( "github.com/go-git/go-git/v6/plumbing/object" ) +// Fixture git identity used by every repo this harness initializes. +const ( + testAuthorName = "Test User" + testAuthorEmail = "test@example.com" +) + +// Values from the agent transcript JSONL wire formats the harness synthesizes. +const ( + entryTypeMessage = "message" + roleUser = "user" + roleAssistant = "assistant" + blockTypeText = "text" + blockTypeToolUse = "tool_use" + blockTypeToolResult = "tool_result" +) + // testBinaryPath holds the path to the CLI binary built once in TestMain. // All tests share this binary to avoid repeated builds. var testBinaryPath string @@ -227,8 +243,8 @@ func (env *TestEnv) InitRepo() { if err != nil { env.T.Fatalf("failed to get repo config: %v", err) } - cfg.User.Name = "Test User" - cfg.User.Email = "test@example.com" + cfg.User.Name = testAuthorName + cfg.User.Email = testAuthorEmail // Disable GPG signing for test commits (prevents failures if user has commit.gpgsign=true globally) if cfg.Raw == nil { @@ -484,8 +500,8 @@ func (env *TestEnv) GitCommit(message string) { _, err = worktree.Commit(message, &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -515,8 +531,8 @@ func (env *TestEnv) GitCommitWithCheckpointID(message, checkpointID string) { _, err = worktree.Commit(fullMessage, &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -552,8 +568,8 @@ func (env *TestEnv) GitCommitWithMultipleCheckpoints(message string, checkpointI _, err = worktree.Commit(sb.String(), &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -1034,8 +1050,8 @@ func (env *TestEnv) gitCommitWithShadowHooks(message string, simulateTTY bool, f _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -1112,8 +1128,8 @@ func (env *TestEnv) GitCommitAmendWithShadowHooks(message string, files ...strin _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, Amend: true, @@ -1218,8 +1234,8 @@ func (env *TestEnv) GitCommitWithTrailerRemoved(message string, files ...string) _, err = worktree.Commit(cleanedMsg, &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -1293,8 +1309,8 @@ func (env *TestEnv) gitCommitStagedWithShadowHooks(message string, simulateTTY b _, err = worktree.Commit(string(modifiedMsg), &git.CommitOptions{ Author: &object.Signature{ - Name: "Test User", - Email: "test@example.com", + Name: testAuthorName, + Email: testAuthorEmail, When: time.Now(), }, }) @@ -1823,8 +1839,8 @@ func (env *TestEnv) CloneFrom(bareDir string) *TestEnv { // Configure git user (clone doesn't inherit local config from the bare repo) for _, kv := range [][2]string{ - {"user.name", "Test User"}, - {"user.email", "test@example.com"}, + {"user.name", testAuthorName}, + {"user.email", testAuthorEmail}, {"commit.gpgsign", "false"}, } { testutil.RunGit(env.T, cloneDir, "config", kv[0], kv[1]) diff --git a/cmd/entire/cli/integration_test/transcript.go b/cmd/entire/cli/integration_test/transcript.go index 55038bef53..1120dc89be 100644 --- a/cmd/entire/cli/integration_test/transcript.go +++ b/cmd/entire/cli/integration_test/transcript.go @@ -28,7 +28,7 @@ func NewTranscriptBuilder() *TranscriptBuilder { func (b *TranscriptBuilder) AddUserMessage(content string) { b.messages = append(b.messages, map[string]interface{}{ "uuid": fmt.Sprintf("user-%d", len(b.messages)+1), - "type": "user", + "type": roleUser, "message": map[string]interface{}{"content": content}, "timestamp": time.Now().UTC().Format(time.RFC3339), }) @@ -38,10 +38,10 @@ func (b *TranscriptBuilder) AddUserMessage(content string) { func (b *TranscriptBuilder) AddAssistantMessage(content string) { b.messages = append(b.messages, map[string]interface{}{ "uuid": fmt.Sprintf("asst-%d", len(b.messages)+1), - "type": "assistant", + "type": roleAssistant, "message": map[string]interface{}{ "content": []map[string]interface{}{ - {"type": "text", "text": content}, + {"type": blockTypeText, "text": content}, }, }, "timestamp": time.Now().UTC().Format(time.RFC3339), @@ -55,7 +55,7 @@ func (b *TranscriptBuilder) AddToolUse(toolName, filePath, content string) strin toolUseID := fmt.Sprintf("toolu_%d", b.toolUseCounter) toolUse := map[string]interface{}{ - "type": "tool_use", + "type": blockTypeToolUse, "id": toolUseID, "name": toolName, "input": map[string]interface{}{ @@ -66,7 +66,7 @@ func (b *TranscriptBuilder) AddToolUse(toolName, filePath, content string) strin b.messages = append(b.messages, map[string]interface{}{ "uuid": fmt.Sprintf("asst-%d", len(b.messages)+1), - "type": "assistant", + "type": roleAssistant, "message": map[string]interface{}{ "content": []interface{}{toolUse}, }, @@ -80,11 +80,11 @@ func (b *TranscriptBuilder) AddToolUse(toolName, filePath, content string) strin func (b *TranscriptBuilder) AddToolResult(toolUseID string) { b.messages = append(b.messages, map[string]interface{}{ "uuid": fmt.Sprintf("user-%d", len(b.messages)+1), - "type": "user", + "type": roleUser, "message": map[string]interface{}{ "content": []map[string]interface{}{ { - "type": "tool_result", + "type": blockTypeToolResult, "tool_use_id": toolUseID, "content": "Success", }, @@ -103,7 +103,7 @@ func (b *TranscriptBuilder) AddTaskToolUse(toolUseID, prompt string) string { } toolUse := map[string]interface{}{ - "type": "tool_use", + "type": blockTypeToolUse, "id": toolUseID, "name": "Task", "input": map[string]interface{}{ @@ -114,7 +114,7 @@ func (b *TranscriptBuilder) AddTaskToolUse(toolUseID, prompt string) string { b.messages = append(b.messages, map[string]interface{}{ "uuid": fmt.Sprintf("asst-%d", len(b.messages)+1), - "type": "assistant", + "type": roleAssistant, "message": map[string]interface{}{ "content": []interface{}{toolUse}, }, @@ -131,11 +131,11 @@ func (b *TranscriptBuilder) AddTaskToolResult(toolUseID, agentID string) string b.messages = append(b.messages, map[string]interface{}{ "uuid": uuid, - "type": "user", + "type": roleUser, "message": map[string]interface{}{ "content": []map[string]interface{}{ { - "type": "tool_result", + "type": blockTypeToolResult, "tool_use_id": toolUseID, "content": "agentId: " + agentID, }, diff --git a/cmd/entire/cli/interactive/interactive.go b/cmd/entire/cli/interactive/interactive.go index e6274bcc22..4b50064632 100644 --- a/cmd/entire/cli/interactive/interactive.go +++ b/cmd/entire/cli/interactive/interactive.go @@ -119,7 +119,7 @@ func IsTerminalReader(r io.Reader) bool { if !ok { return false } - return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + return term.IsTerminal(int(f.Fd())) } // IsTerminalWriter reports whether w is an *os.File backed by a terminal. @@ -130,7 +130,7 @@ func IsTerminalWriter(w io.Writer) bool { if !ok { return false } - return term.IsTerminal(int(f.Fd())) //nolint:gosec // G115: uintptr->int is safe for fd + return term.IsTerminal(int(f.Fd())) } // ShouldStyle reports whether ANSI-styled output (color, bold, rendered diff --git a/cmd/entire/cli/interactive/rawmode_unix.go b/cmd/entire/cli/interactive/rawmode_unix.go index a3d7d11380..90841aa490 100644 --- a/cmd/entire/cli/interactive/rawmode_unix.go +++ b/cmd/entire/cli/interactive/rawmode_unix.go @@ -40,7 +40,7 @@ import ( // rawModeIoctl is the platform's termios read ioctl (rawmode_darwin.go, // rawmode_linux.go); rawmode_other.go covers platforms without termios. func ttyInRawMode(f *os.File) bool { - termios, err := unix.IoctlGetTermios(int(f.Fd()), rawModeIoctl) //nolint:gosec // G115: uintptr->int is safe for fd + termios, err := unix.IoctlGetTermios(int(f.Fd()), rawModeIoctl) if err != nil { // Can't tell — fail open so an unexpected ioctl failure never silently // disables prompting. This check may only ever suppress prompts we diff --git a/cmd/entire/cli/internal/flock/flock_unix.go b/cmd/entire/cli/internal/flock/flock_unix.go index 80693b2d69..35250667fe 100644 --- a/cmd/entire/cli/internal/flock/flock_unix.go +++ b/cmd/entire/cli/internal/flock/flock_unix.go @@ -78,7 +78,7 @@ func lockFile(ctx context.Context, open func() (*os.File, error), holdsCurrent f if err != nil { return nil, fmt.Errorf("open flock: %w", err) } - if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { //nolint:gosec // file descriptors are non-negative; standard Go pattern for syscall.Flock + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX); err != nil { _ = f.Close() return nil, fmt.Errorf("flock: %w", err) } @@ -101,7 +101,7 @@ func lockFile(ctx context.Context, open func() (*os.File, error), holdsCurrent f if err != nil { return nil, fmt.Errorf("open flock: %w", err) } - lockErr := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) //nolint:gosec // see above + lockErr := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) if lockErr == nil { current, err := holdsCurrent(f) if err != nil { diff --git a/cmd/entire/cli/labs.go b/cmd/entire/cli/labs.go index 606d382d36..8540a25b7d 100644 --- a/cmd/entire/cli/labs.go +++ b/cmd/entire/cli/labs.go @@ -16,7 +16,7 @@ type experimentalCommandInfo struct { var experimentalCommands = []experimentalCommandInfo{ { - CommandPath: []string{"review"}, + CommandPath: []string{cmdReview}, Invocation: "entire review", Summary: "Run a multi-agent review against the current branch", }, @@ -31,7 +31,7 @@ var experimentalCommands = []experimentalCommandInfo{ Summary: "Import existing Claude Code transcripts as local, read-only history", }, { - CommandPath: []string{"tokens"}, + CommandPath: []string{cmdTokens}, Invocation: "entire tokens", Summary: "Analyze experimental token usage diagnostics", }, @@ -41,7 +41,7 @@ var experimentalCommands = []experimentalCommandInfo{ Summary: "Aggregate token usage across committed checkpoints", }, { - CommandPath: []string{"session", "tokens"}, + CommandPath: []string{cmdSession, cmdTokens}, Invocation: "entire session tokens", Summary: "Show token usage and recommendations for a session", }, diff --git a/cmd/entire/cli/lifecycle.go b/cmd/entire/cli/lifecycle.go index 2a9fed3376..c20f4aa488 100644 --- a/cmd/entire/cli/lifecycle.go +++ b/cmd/entire/cli/lifecycle.go @@ -965,6 +965,19 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev relModifiedFiles = filterToUncommittedFiles(ctx, relModifiedFiles, repoRoot) normalizeSpan.End() + // Codex owns an authoritative child ledger. Refresh it before the + // no-files gate: a read-only child can finish without producing a shadow + // checkpoint, but its exact availability still must replace stale coverage. + var codexInventoryUsage *agent.TokenUsage + var codexLedgerVersion *uint64 + if ag.Type() == agent.AgentTypeCodex { + inventoryOffset := 0 + if preState != nil { + inventoryOffset = preState.TranscriptOffset + } + codexInventoryUsage, codexLedgerVersion = refreshCodexInventory(ctx, ag, sessionID, transcriptData, inventoryOffset) + } + // Check if there are any changes totalChanges := len(relModifiedFiles) + len(relNewFiles) + len(relDeletedFiles) if totalChanges == 0 { @@ -1024,7 +1037,11 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev // to include subagent tokens. tokenUsage := event.TokenUsage if tokenUsage == nil { - tokenUsage = agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + if codexInventoryUsage != nil { + tokenUsage = codexInventoryUsage + } else { + tokenUsage = agent.CalculateTokenUsage(ctx, ag, transcriptData, transcriptLinesAtStart, subagentsDir) + } } // Build fully-populated step context and delegate to strategy @@ -1042,6 +1059,7 @@ func handleLifecycleTurnEnd(ctx context.Context, ag agent.Agent, event *agent.Ev StepTranscriptIdentifier: transcriptIdentifierAtStart, StepTranscriptStart: transcriptLinesAtStart, TokenUsage: tokenUsage, + SubagentLedgerVersion: codexLedgerVersion, } // finishTurn is the shared turn-end tail, run whether the save succeeded @@ -1146,6 +1164,30 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent // (sessionEndCondenseDeadline) that budget-capped agents get; Claude Code // sets no budget, and for agents that do, bounding the final captures // against the same deadline is a known follow-up. + if ag.Type() == agent.AgentTypeCodex { + // Persist the cheap end transition before any potentially large rollout + // reads. If the host kills this hook, the session must not remain ACTIVE. + ended, err := markSessionEnded(ctx, event, event.SessionID, nil, endedNow) + if err != nil { + return fmt.Errorf("mark codex session ended: %w", err) + } + if !ended { + return nil + } + deadline := sessionEndCondenseDeadline(ag) + if !deadline.IsZero() { + var cancel context.CancelFunc + ctx, cancel = context.WithDeadline(ctx, deadline) + defer cancel() + } + // Only child evidence is persisted here; rereading the parent is unused. + _, _ = refreshCodexInventory(ctx, ag, event.SessionID, nil, 0) + finalizeCodexObservedAtSessionEnd(ctx, event.SessionID) + completeLiveTaskRecords(ctx, ag, event.SessionID, event.SessionRef) + condenseEndedSession(ctx, event.SessionID, deadline) + return nil + } + completeLiveTaskRecords(ctx, ag, event.SessionID, event.SessionRef) if _, err := endSessionNow(ctx, event, event.SessionID, nil, sessionEndCondenseDeadline(ag), endedNow); err != nil { @@ -1156,6 +1198,135 @@ func handleLifecycleSessionEnd(ctx context.Context, ag agent.Agent, event *agent return nil } +// finalizeCodexObservedAtSessionEnd closes every observed turn of a VERIFIED +// child that did not have a matching terminal record in the same rollout +// analysis. This deliberately iterates the inventory rather than live task +// records: a follow-up can be hidden behind a completed-but-unmaterialized +// record. +// +// A child whose rollout this session never resolved is skipped, because +// completing its record here is unrecoverable: it hides the record from the +// completeLiveTaskRecords sweep that runs next — whose independent +// ResolveAgentTranscriptPath attempt and analyzer pass are a genuinely +// different resolution path — and condensation then writes a path-free +// "unavailable" reason and drops the record (removeCompletedTaskRecords), with +// no later hook to reconcile it. Left pending, the record stays live, so the +// sweep retries it now and each later condensation re-materializes it. +func finalizeCodexObservedAtSessionEnd(ctx context.Context, sessionID string) { + if err := strategy.MutateSessionState(ctx, sessionID, func(state *strategy.SessionState) error { + for _, entry := range state.SubagentInventory { + // refreshCodexInventory records this path only after loading the + // rollout and matching session_meta.id to AgentID, so it is the + // only evidence here that the child was ever read. Its absence + // covers every way resolution can fail — an unreadable or absent + // rollout, a fallback scan that timed out or breached its budget, + // an extraction that never ran at all. + if entry.ResolvedTranscriptPath == "" { + continue + } + for _, turnID := range entry.ObservedTurnIDs { + if !state.FinalizeSubagentTurn(entry.AgentID, turnID) { + continue + } + for i := range state.TaskRecords { + record := &state.TaskRecords[i] + if record.AgentID != entry.AgentID { + continue + } + if record.CompletedAt.IsZero() { + record.CompletedAt = time.Now() + } + // Carry the verified path into the durable task record so + // condensation can still materialize the transcript even + // though this fallback has no exact terminal file/token + // snapshot. + record.DeclaredTranscriptPath = entry.ResolvedTranscriptPath + // No new snapshot exists here. Preserve evidence captured for earlier turns. + break + } + } + } + return nil + }); err != nil && !errors.Is(err, strategy.ErrStateNotFound) { + logging.Debug(ctx, "failed to finalize codex turns at session end", slog.String("error", err.Error())) + } +} + +// refreshCodexInventory snapshots the durable child ledger, performs the +// potentially slow filesystem analysis outside its lock, then applies only +// path enrichment and terminal evidence if no new child observation raced it. +// It never manufactures an exact-empty result for an unknown/legacy ledger. +func refreshCodexInventory(ctx context.Context, ag agent.Agent, sessionID string, parent []byte, fromOffset int) (*agent.TokenUsage, *uint64) { + state, err := strategy.LoadSessionState(ctx, sessionID) + if err != nil || state == nil || state.SubagentInventoryComplete == nil { + return nil, nil + } + refs := make([]agent.SubagentReference, 0, len(state.SubagentInventory)) + for _, entry := range state.SubagentInventory { + refs = append(refs, agent.SubagentReference{ObservedTurnIDs: entry.ObservedTurnIDs, AgentID: entry.AgentID, DeclaredTranscriptPath: entry.DeclaredTranscriptPath, ResolvedTranscriptPath: entry.ResolvedTranscriptPath}) + } + version := state.SubagentLedgerVersion + extraction, ok := agent.ExtractWithSubagentInventory(ctx, ag, parent, fromOffset, refs) + if !ok { + return nil, &version + } + + usage := types.WithClearedSubagentTokens(extraction.TokenUsage, false) + if err := strategy.MutateSessionState(ctx, sessionID, func(current *strategy.SessionState) error { + if current.SubagentLedgerVersion != version { + return strategy.ErrMutationSkip + } + usage = extraction.TokenUsage + if current.SubagentInventoryComplete == nil || !*current.SubagentInventoryComplete { + // A legacy/partial inventory may still provide main transcript evidence, + // but cannot truthfully claim full child coverage. + usage = types.WithClearedSubagentTokens(usage, false) + } + for _, child := range extraction.Children { + childFiles := FilterAndNormalizePaths(child.ModifiedFiles, current.WorktreePath) + current.UpdateSubagentTranscriptPaths(child.AgentID, "", child.ResolvedPath) + for _, turnID := range child.TerminalTurnIDs { + if !current.FinalizeSubagentTurn(child.AgentID, turnID) { + continue + } + for i := range current.TaskRecords { + record := ¤t.TaskRecords[i] + if record.AgentID != child.AgentID { + continue + } + if record.CompletedAt.IsZero() { + record.CompletedAt = time.Now() + } + record.Files = childFiles + record.DeclaredTranscriptPath = child.ResolvedPath + // nil is evidence too: a newer terminal snapshot without exact + // usage must clear, never preserve, an earlier total. + record.TokenUsage = child.TokenUsage + current.FilesTouched = mergeUnique(current.FilesTouched, childFiles) + break + } + } + } + // This is also the no-file refresh path: retain the latest exact child + // snapshot (including authoritative empty or unavailable) without + // creating a checkpoint step or changing main-agent counters. + if usage != nil { + if current.TokenUsage == nil { + current.TokenUsage = &agent.TokenUsage{} + } + current.TokenUsage.SubagentTokens = usage.SubagentTokens + if usage.SubagentTokensComplete != nil { + complete := *usage.SubagentTokensComplete + current.TokenUsage.SubagentTokensComplete = &complete + } + } + return nil + }); err != nil && !errors.Is(err, strategy.ErrStateNotFound) { + logging.Debug(ctx, "failed to persist codex inventory evidence", slog.String("error", err.Error())) + } + return usage, &version +} + // processStart approximates when this hook process began. Package // initialization runs before main, so it is within milliseconds of exec — // precise enough to bound work against a deadline the agent measures from the @@ -1218,12 +1389,17 @@ func endSessionNow(ctx context.Context, event *agent.Event, sessionID string, gu if err != nil || !ended { return ended, err } + condenseEndedSession(ctx, sessionID, condenseDeadline) + return true, nil +} + +func condenseEndedSession(ctx context.Context, sessionID string, condenseDeadline time.Time) { logCtx := logging.WithComponent(ctx, "lifecycle") if !condenseDeadline.IsZero() { if remaining := time.Until(condenseDeadline); remaining <= 0 { logging.Info(logCtx, "skipping eager condense: session-end budget already spent", slog.String("session_id", sessionID)) - return true, nil + return } var cancel context.CancelFunc ctx, cancel = context.WithDeadline(ctx, condenseDeadline) @@ -1234,7 +1410,6 @@ func endSessionNow(ctx context.Context, event *agent.Event, sessionID string, gu slog.String("session_id", sessionID), slog.String("error", condErr.Error())) } - return true, nil } // handleLifecycleSubagentStart handles subagent start: captures pre-task state. @@ -1247,6 +1422,31 @@ func handleLifecycleSubagentStart(ctx context.Context, ag agent.Agent, event *ag slog.String("transcript", event.SessionRef), ) + if ag.Type() == agent.AgentTypeCodex { + if event.SubagentID == "" || event.TurnID == "" || event.ToolUseID == "" { + return errors.New("invalid codex subagent start: agent, turn, and tool IDs are required") + } + // The ledger is authoritative. Persist it before the generic capture, + // whose worktree read is intentionally best effort for Codex children. + if err := GetStrategy(ctx).EnsureSessionExists(ctx, event.SessionID, ag.Type()); err != nil { + return fmt.Errorf("ensure codex subagent session: %w", err) + } + if err := strategy.MutateSessionState(ctx, event.SessionID, func(state *strategy.SessionState) error { + state.RegisterSubagent(event.SubagentID, event.TurnID) + state.EnsureTaskRecord(session.TaskRecord{ + ToolUseID: event.ToolUseID, AgentID: event.SubagentID, StartedAt: time.Now(), + SubagentType: event.SubagentType, TaskDescription: event.TaskDescription, + }) + return nil + }); err != nil { + return fmt.Errorf("register codex subagent: %w", err) + } + if err := CapturePreTaskState(ctx, event.ToolUseID); err != nil { + logging.Warn(logCtx, "best-effort codex pre-task capture failed", slog.String("error", err.Error())) + } + return nil + } + // Capture pre-task state if err := CapturePreTaskState(ctx, event.ToolUseID); err != nil { return fmt.Errorf("failed to capture pre-task state: %w", err) @@ -1295,6 +1495,32 @@ func handleLifecycleSubagentEnd(ctx context.Context, ag agent.Agent, event *agen // Extract subagent type and description from tool input event.SubagentType, event.TaskDescription = ParseSubagentTypeAndDescription(event.ToolInput) } + if ag.Type() == agent.AgentTypeCodex && event.ProvisionalSubagentStop { + if event.SubagentID == "" || event.TurnID == "" { + return errors.New("invalid codex provisional subagent stop: agent and turn IDs are required") + } + if err := GetStrategy(ctx).EnsureSessionExists(ctx, event.SessionID, ag.Type()); err != nil { + return fmt.Errorf("ensure codex subagent session: %w", err) + } + // Codex's stop hook is deliberately not completion: its rollout can + // still be changing. Record only the observation for later transcript + // reconciliation; do not capture the parent worktree or mark a task done. + err := strategy.MutateSessionState(logCtx, event.SessionID, func(state *strategy.SessionState) error { + if state.Phase == session.PhaseEnded || state.EndedAt != nil { + return strategy.ErrMutationSkip + } + state.RecordSubagentStop(event.SubagentID, event.TurnID) + state.UpdateSubagentTranscriptPaths(event.SubagentID, event.SubagentTranscriptPath, "") + return nil + }) + if errors.Is(err, strategy.ErrStateNotFound) { + return nil + } + if err != nil { + return fmt.Errorf("record codex provisional subagent stop: %w", err) + } + return nil + } if event.Final { return handleSubagentStopFinal(logCtx, ag, event) @@ -2216,11 +2442,15 @@ func tryAdoptEnv(ctx context.Context, state *session.State, expectedAgent string spec.apply(ctx, state, envAgent) } +// sessionKindLabelReview is the human label for a review session, shared by +// env adoption and the trail resume summary. +const sessionKindLabelReview = "review" + // adoptReviewEnv tags the session as a review session when ENTIRE_REVIEW_* // env vars are present on the current process. func adoptReviewEnv(ctx context.Context, state *session.State, expectedAgent string) { tryAdoptEnv(ctx, state, expectedAgent, envAdoptionSpec{ - kindLabel: "review", + kindLabel: sessionKindLabelReview, envSession: review.EnvSession, envAgent: review.EnvAgent, envStartingSHA: review.EnvStartingSHA, diff --git a/cmd/entire/cli/lifecycle_test.go b/cmd/entire/cli/lifecycle_test.go index 83cca93f9b..0536cc3948 100644 --- a/cmd/entire/cli/lifecycle_test.go +++ b/cmd/entire/cli/lifecycle_test.go @@ -134,6 +134,231 @@ func (m *mockAnalyzerAgent) ExtractModifiedFilesFromOffset(_ string, _ int) ([]s return m.analyzerFiles, 0, nil } +type mockInventoryAgent struct { + *mockLifecycleAgent + + extraction agent.InventoryExtraction + beforeReturn func() +} + +var _ agent.InventoryAwareExtractor = (*mockInventoryAgent)(nil) + +func (m *mockInventoryAgent) ExtractWithSubagentInventory(_ context.Context, _ []byte, _ int, _ []agent.SubagentReference) (agent.InventoryExtraction, error) { + if m.beforeReturn != nil { + m.beforeReturn() + } + return m.extraction, nil +} + +func TestRefreshCodexInventory_MultiTurnChildRefreshesCompletedTaskRecord(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + const ( + sessionID = "codex-multi-turn-child" + agentID = "child-1" + ) + repoRoot, err := os.Getwd() + require.NoError(t, err) + completedAt := time.Now().UTC().Truncate(time.Microsecond) + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + WorktreePath: repoRoot, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventoryComplete: &complete, + SubagentLedgerVersion: 2, + SubagentInventory: []session.SubagentInventoryEntry{{ + AgentID: agentID, + ObservedTurnIDs: []string{"turn-1", "turn-2"}, + FinalizedTurnIDs: []string{"turn-1"}, + }}, + TaskRecords: []session.TaskRecord{{ + ToolUseID: agentID, + AgentID: agentID, + StartedAt: completedAt.Add(-time.Minute), + CompletedAt: completedAt, + Files: []string{"first.go"}, + TokenUsage: &agent.TokenUsage{InputTokens: 10}, + }}, + FilesTouched: []string{"first.go"}, + })) + + ag := &mockInventoryAgent{ + mockLifecycleAgent: newMockAgent(), + extraction: agent.InventoryExtraction{Children: []agent.SubagentAnalysis{{ + AgentID: agentID, + ResolvedPath: "/tmp/child-1.jsonl", + ModifiedFiles: []string{filepath.Join(repoRoot, "first.go"), filepath.Join(repoRoot, "second.go")}, + TokenUsage: &agent.TokenUsage{InputTokens: 25}, + TerminalTurnIDs: []string{"turn-2"}, + }}}, + } + + _, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) + require.NotNil(t, version) + require.Equal(t, uint64(2), *version) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + record := state.FindTaskRecord(agentID) + require.NotNil(t, record) + assert.Equal(t, completedAt, record.CompletedAt, "a later terminal turn updates evidence without completing the task twice") + assert.Equal(t, []string{"first.go", "second.go"}, record.Files) + require.NotNil(t, record.TokenUsage) + assert.Equal(t, 25, record.TokenUsage.InputTokens) + assert.Equal(t, "/tmp/child-1.jsonl", record.DeclaredTranscriptPath) + assert.ElementsMatch(t, []string{"first.go", "second.go"}, state.FilesTouched) + assert.Contains(t, state.FindSubagentInventory(agentID).FinalizedTurnIDs, "turn-2") +} + +func TestFinalizeCodexObservedAtSessionEnd(t *testing.T) { + // A turn is force-closed only when the inventory carries a rollout path + // refreshCodexInventory verified by matching session_meta.id to AgentID. + // Without one there is no evidence to close on, and closing anyway would + // complete the record — hiding it from the SessionEnd sweep that runs next + // and letting condensation drop it with neither files nor a transcript. + const agentID = "child-1" + tests := []struct { + name string + resolvedPath string + seedCompleted bool + wantFinalized bool + wantCompletion string // "unchanged" | "set" | "live" + wantDeclaredPath string + }{ + { + name: "verified path closes a live turn and completes the record", + resolvedPath: "/tmp/verified-child-1.jsonl", + wantFinalized: true, + wantCompletion: "set", + wantDeclaredPath: "/tmp/verified-child-1.jsonl", + }, + { + name: "verified path closes a later turn without completing twice", + resolvedPath: "/tmp/verified-child-1.jsonl", + seedCompleted: true, + wantFinalized: true, + wantCompletion: "unchanged", + wantDeclaredPath: "/tmp/verified-child-1.jsonl", + }, + { + name: "unresolved rollout leaves the turn pending and the record retryable", + wantFinalized: false, + wantCompletion: "live", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + sessionID := "codex-session-end-" + strings.ReplaceAll(tt.name, " ", "-") + seededAt := time.Now().UTC().Truncate(time.Microsecond) + record := session.TaskRecord{ + ToolUseID: agentID, + AgentID: agentID, + StartedAt: seededAt.Add(-time.Minute), + Files: []string{"first.go"}, + TokenUsage: &agent.TokenUsage{InputTokens: 10}, + } + if tt.seedCompleted { + record.CompletedAt = seededAt + } + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventory: []session.SubagentInventoryEntry{{ + AgentID: agentID, + ResolvedTranscriptPath: tt.resolvedPath, + ObservedTurnIDs: []string{"turn-1", "turn-2"}, + FinalizedTurnIDs: []string{"turn-1"}, + }}, + TaskRecords: []session.TaskRecord{record}, + })) + + finalizeCodexObservedAtSessionEnd(ctx, sessionID) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + got := state.FindTaskRecord(agentID) + require.NotNil(t, got) + + entry := state.FindSubagentInventory(agentID) + require.NotNil(t, entry) + if tt.wantFinalized { + assert.Contains(t, entry.FinalizedTurnIDs, "turn-2") + } else { + assert.NotContains(t, entry.FinalizedTurnIDs, "turn-2", + "an unresolved rollout is not evidence the turn ended") + } + + switch tt.wantCompletion { + case "unchanged": + assert.Equal(t, seededAt, got.CompletedAt, "force-closing a later turn must not complete the task twice") + case "set": + assert.False(t, got.CompletedAt.IsZero(), "a verified path closes the record") + case "live": + assert.True(t, got.CompletedAt.IsZero(), + "the record must stay live so the SessionEnd sweep can retry it and condensation retains it") + } + + assert.Equal(t, tt.wantDeclaredPath, got.DeclaredTranscriptPath) + assert.Equal(t, []string{"first.go"}, got.Files, "closing a turn must preserve previously captured files") + assert.Equal(t, &agent.TokenUsage{InputTokens: 10}, got.TokenUsage, "without a new snapshot, preserve captured tokens") + }) + } +} + +func TestRefreshCodexInventory_UsesCurrentCompletenessWhenPersistingUsage(t *testing.T) { + // NOT parallel: setupStopTestRepo changes the process working directory. + setupStopTestRepo(t) + ctx := context.Background() + const sessionID = "codex-completeness-race" + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{ + SessionID: sessionID, + StartedAt: time.Now(), + Phase: session.PhaseActive, + SubagentInventoryComplete: &complete, + SubagentLedgerVersion: 2, + })) + + extractedComplete := true + ag := &mockInventoryAgent{ + mockLifecycleAgent: newMockAgent(), + extraction: agent.InventoryExtraction{TokenUsage: &agent.TokenUsage{ + SubagentTokens: &agent.TokenUsage{InputTokens: 25}, + SubagentTokensComplete: &extractedComplete, + }}, + beforeReturn: func() { + require.NoError(t, strategy.MutateSessionState(ctx, sessionID, func(state *strategy.SessionState) error { + incomplete := false + state.SubagentInventoryComplete = &incomplete + return nil + })) + }, + } + + usage, version := refreshCodexInventory(ctx, ag, sessionID, nil, 0) + require.NotNil(t, version) + assert.Equal(t, uint64(2), *version) + require.NotNil(t, usage) + require.NotNil(t, usage.SubagentTokensComplete) + assert.False(t, *usage.SubagentTokensComplete) + assert.Nil(t, usage.SubagentTokens) + + state, err := strategy.LoadSessionState(ctx, sessionID) + require.NoError(t, err) + require.NotNil(t, state.TokenUsage) + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + assert.False(t, *state.TokenUsage.SubagentTokensComplete) + assert.Nil(t, state.TokenUsage.SubagentTokens) +} + // --- DispatchLifecycleEvent tests --- func TestDispatchLifecycleEvent_NilAgent(t *testing.T) { @@ -3977,3 +4202,50 @@ func TestAppendEventSkillEventsToState_ReturnsOnlyNewlyAppended(t *testing.T) { // Full re-delivery is a no-op. require.Nil(t, appendEventSkillEventsToState(&agent.Event{SkillEvents: []agent.SkillEvent{first, second}}, state)) } + +func TestRefreshCodexInventory_RejectsStaleReturnedCoverage(t *testing.T) { + // NOT parallel: setupStopTestRepo changes CWD. + setupStopTestRepo(t) + ctx := t.Context() + const id = "stale-inventory-return" + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{SessionID: id, StartedAt: time.Now(), SubagentInventoryComplete: &complete})) + ag := &mockInventoryAgent{mockLifecycleAgent: newMockAgent(), extraction: agent.InventoryExtraction{TokenUsage: &agent.TokenUsage{SubagentTokensComplete: &complete, SubagentTokens: &agent.TokenUsage{InputTokens: 99}}}, beforeReturn: func() { + require.NoError(t, strategy.MutateSessionState(ctx, id, func(s *strategy.SessionState) error { s.RegisterSubagent("new-child", "new-turn"); return nil })) + }} + usage, _ := refreshCodexInventory(ctx, ag, id, nil, 0) + require.NotNil(t, usage) + require.False(t, *usage.SubagentTokensComplete) + require.Nil(t, usage.SubagentTokens) +} + +func TestCodexProvisionalStopBeforeStartRetainsObservation(t *testing.T) { + // NOT parallel: setupStopTestRepo changes CWD. + setupStopTestRepo(t) + ag := newMockAgent() + ag.agentType = agent.AgentTypeCodex + event := &agent.Event{SessionID: "stop-first", SubagentID: "child", TurnID: "turn", ProvisionalSubagentStop: true} + require.NoError(t, handleLifecycleSubagentEnd(t.Context(), ag, event)) + state, err := strategy.LoadSessionState(t.Context(), event.SessionID) + require.NoError(t, err) + require.NotNil(t, state) + require.NotNil(t, state.FindSubagentInventory("child")) + require.NotNil(t, state.FindTaskRecord("child")) +} + +func TestCodexSessionEndPersistsEndedBeforeInventoryRead(t *testing.T) { + // NOT parallel: setupStopTestRepo changes CWD. + setupStopTestRepo(t) + ctx := t.Context() + const id = "end-before-child-read" + complete := true + require.NoError(t, strategy.SaveSessionState(ctx, &strategy.SessionState{SessionID: id, StartedAt: time.Now(), Phase: session.PhaseActive, SubagentInventoryComplete: &complete})) + ag := &mockInventoryAgent{mockLifecycleAgent: newMockAgent(), beforeReturn: func() { + state, err := strategy.LoadSessionState(ctx, id) + require.NoError(t, err) + require.NotNil(t, state.EndedAt, "the host may kill the process during child reads") + require.Equal(t, session.PhaseEnded, state.Phase) + }} + ag.agentType = agent.AgentTypeCodex + require.NoError(t, handleLifecycleSessionEnd(ctx, ag, &agent.Event{SessionID: id})) +} diff --git a/cmd/entire/cli/mcp.go b/cmd/entire/cli/mcp.go index 57cc60d549..39f164f4b9 100644 --- a/cmd/entire/cli/mcp.go +++ b/cmd/entire/cli/mcp.go @@ -61,6 +61,9 @@ type mcpRequest struct { Params json.RawMessage `json:"params,omitempty"` } +// jsonRPCVersion is the only JSON-RPC version this server speaks. +const jsonRPCVersion = "2.0" + type mcpResponse struct { JSONRPC string `json:"jsonrpc"` ID json.RawMessage `json:"id"` @@ -91,7 +94,7 @@ func runMCPServer(ctx context.Context, rootCmd *cobra.Command, in io.Reader, out var req mcpRequest if err := json.Unmarshal(line, &req); err != nil { - if encErr := enc.Encode(mcpResponse{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &mcpError{Code: -32700, Message: "parse error"}}); encErr != nil { + if encErr := enc.Encode(mcpResponse{JSONRPC: jsonRPCVersion, ID: json.RawMessage("null"), Error: &mcpError{Code: -32700, Message: "parse error"}}); encErr != nil { return fmt.Errorf("write mcp parse-error response: %w", encErr) } continue @@ -100,12 +103,12 @@ func runMCPServer(ctx context.Context, rootCmd *cobra.Command, in io.Reader, out // Reject a parseable-but-invalid request (missing/incorrect jsonrpc version // or empty method) with -32600 before dispatch, per JSON-RPC, rather than // treating it as method-not-found. - if req.JSONRPC != "2.0" || req.Method == "" { + if req.JSONRPC != jsonRPCVersion || req.Method == "" { id := req.ID if len(id) == 0 { id = json.RawMessage("null") } - if encErr := enc.Encode(mcpResponse{JSONRPC: "2.0", ID: id, Error: &mcpError{Code: -32600, Message: "invalid request"}}); encErr != nil { + if encErr := enc.Encode(mcpResponse{JSONRPC: jsonRPCVersion, ID: id, Error: &mcpError{Code: -32600, Message: "invalid request"}}); encErr != nil { return fmt.Errorf("write mcp invalid-request response: %w", encErr) } continue @@ -124,7 +127,7 @@ func runMCPServer(ctx context.Context, rootCmd *cobra.Command, in io.Reader, out continue } - resp := mcpResponse{JSONRPC: "2.0", ID: req.ID} + resp := mcpResponse{JSONRPC: jsonRPCVersion, ID: req.ID} if rpcErr != nil { resp.Error = rpcErr } else { @@ -138,7 +141,7 @@ func runMCPServer(ctx context.Context, rootCmd *cobra.Command, in io.Reader, out // A single line exceeded maxMCPMessageBytes (the scanner can't resynchronize // past an over-long token) or the read failed; report once and stop. if errors.Is(err, bufio.ErrTooLong) { - if encErr := enc.Encode(mcpResponse{JSONRPC: "2.0", ID: json.RawMessage("null"), Error: &mcpError{Code: -32600, Message: "request too large"}}); encErr != nil { + if encErr := enc.Encode(mcpResponse{JSONRPC: jsonRPCVersion, ID: json.RawMessage("null"), Error: &mcpError{Code: -32600, Message: "request too large"}}); encErr != nil { return fmt.Errorf("write mcp oversize response: %w", encErr) } return nil diff --git a/cmd/entire/cli/mdrender/mdrender.go b/cmd/entire/cli/mdrender/mdrender.go index 079d724ec6..bf63ed876c 100644 --- a/cmd/entire/cli/mdrender/mdrender.go +++ b/cmd/entire/cli/mdrender/mdrender.go @@ -93,7 +93,7 @@ func shouldRender(w io.Writer) bool { // Falls back to stdout/stderr probing, then DefaultTerminalWidth. func terminalWidth(w io.Writer) int { if f, ok := w.(*os.File); ok { - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return min(width, DefaultTerminalWidth) } } @@ -101,7 +101,7 @@ func terminalWidth(w io.Writer) int { if f == nil { continue } - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return min(width, DefaultTerminalWidth) } } diff --git a/cmd/entire/cli/names.go b/cmd/entire/cli/names.go new file mode 100644 index 0000000000..fdc7fdb994 --- /dev/null +++ b/cmd/entire/cli/names.go @@ -0,0 +1,42 @@ +package cli + +// Command and verb names that more than one file spells. Cobra takes these as +// plain strings, so without a name here a rename means finding every `Use:`, +// alias and hard-coded command path by hand. +const ( + cmdAgent = "agent" + cmdCheckpoint = "checkpoint" + cmdCreateName = "create " + cmdList = "list" + cmdOrg = "org" + cmdRepo = "repo" + cmdReview = "review" + cmdSession = "session" + cmdStatus = "status" + cmdTokens = "tokens" + cmdTrail = "trail" + + // Plural aliases, kept beside the names they alias. + cmdCheckpointsAlias = "checkpoints" + cmdSessionsAlias = "sessions" +) + +// Column headers shared by the control-plane tables: org, project, repo, grant +// and the mirror subtree each print several of the same columns. +const ( + colHeaderCloneURL = "CLONE URL" + colHeaderCluster = "CLUSTER" + colHeaderName = "NAME" + colHeaderRegion = "REGION" + colHeaderRole = "ROLE" + colHeaderStatus = "STATUS" +) + +// Display nouns selected by a count. Spelled here rather than inline because +// several commands phrase the same "N thing(s)" line. +const ( + nounCheckpoint = "checkpoint" + nounCheckpoints = "checkpoints" + nounSession = "session" + nounSessions = "sessions" +) diff --git a/cmd/entire/cli/org.go b/cmd/entire/cli/org.go index 5fab505889..3357af2c41 100644 --- a/cmd/entire/cli/org.go +++ b/cmd/entire/cli/org.go @@ -13,7 +13,7 @@ import ( // delete organizations on the Entire control plane. func newOrgCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "org", + Use: cmdOrg, Short: "Manage Entire organizations", } addControlPlaneFlags(cmd) @@ -26,7 +26,7 @@ func newOrgCmd() *cobra.Command { // orgColumns is the human table/field view of an org, shared by list and // any future `org get`. -var orgColumns = []string{"ID", "NAME", "REGION", "CREATED"} +var orgColumns = []string{"ID", colHeaderName, colHeaderRegion, "CREATED"} func orgRow(o coreapi.Org) []string { return []string{o.ID, o.Name, o.Region, o.CreatedAt.Format("2006-01-02")} @@ -35,7 +35,7 @@ func orgRow(o coreapi.Org) []string { func newOrgCreateCmd() *cobra.Command { var region string cmd := &cobra.Command{ - Use: "create ", + Use: cmdCreateName, Short: "Create an organization", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { @@ -59,7 +59,7 @@ func newOrgCreateCmd() *cobra.Command { func newOrgListCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List organizations you can see", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmd/entire/cli/osroot/rootbase_guard_test.go b/cmd/entire/cli/osroot/rootbase_guard_test.go index 6b460fb720..94790c777c 100644 --- a/cmd/entire/cli/osroot/rootbase_guard_test.go +++ b/cmd/entire/cli/osroot/rootbase_guard_test.go @@ -35,6 +35,7 @@ var rootOpeners = []string{".OpenRoot(", "osroot.Shared("} // (entiredir, gitdir, worktreedir, userdirs, agent.SessionStore) over a new // root; those exist so a call site does not have to decide what its base is. var allowedRootBases = map[string]string{ + "cmd/entire/cli/agent/codex/codex.go": "configured Codex session and archive roots, resolved independently of hook-supplied transcript paths", // The anchors themselves. Each opens exactly one directory, resolved // independently of anything it is later asked to read. "cmd/entire/cli/osroot/osroot.go": "the registry", diff --git a/cmd/entire/cli/plugin.go b/cmd/entire/cli/plugin.go index 449091395f..823f9badfd 100644 --- a/cmd/entire/cli/plugin.go +++ b/cmd/entire/cli/plugin.go @@ -36,6 +36,26 @@ const ( // disk (`entire upgrade` → entire-upgrade). const selfUpdatePluginName = "upgrade" +// onDemandInstallPluginName is the one missing plugin the dispatcher offers to +// install rather than falling through to Cobra's unknown-command path. Kept as +// a named constant beside the other plugin names the dispatcher special-cases, +// so the set is readable in one place. +const onDemandInstallPluginName = "graph" + +// ExitPluginSignalled reports that a plugin was terminated by a signal, or +// that a signal interrupted an on-demand install before the plugin ran. It is +// deliberately not a valid exit status — os.Exit(-1) truncates to 255 — so +// main.go re-raises the signal instead of exiting with it. -1 is already what +// exec.ExitError.ExitCode() reports for a signalled child. +// +// MaybeRunPlugin's killedBy return says WHICH signal, when it is knowable. +// The two are separate because they have different sources: the exit code +// comes from the child's wait status, while the signal may have reached only +// the child (`kill -TERM` at the plugin, SIGPIPE from a closed pipe) or only +// this process (a Ctrl-C during the on-demand install, where there is no +// child yet). +const ExitPluginSignalled = -1 + // postPluginVersionCheck is a test seam for the version-check notice that // fires after a successful plugin run. var postPluginVersionCheck = versioncheck.CheckAndNotify @@ -43,17 +63,57 @@ var postPluginVersionCheck = versioncheck.CheckAndNotify // MaybeRunPlugin returns (true, exitCode) when an external command was // resolved and run. On launch failure (e.g. missing executable bit) // returns (true, 1) after printing to stderr. On no-match returns -// (false, 0) so the caller can fall through to Cobra. +// (false, 0) so the caller can fall through to Cobra. exitCode is +// ExitPluginSignalled when the plugin was killed by a signal, or when a +// signal interrupted an on-demand install before it ran; the caller turns +// that into a re-raised signal rather than an exit status. +// +// killedBy is the signal the plugin was killed by, when the platform reports +// one. It is nil for an ordinary exit, on Windows, and for an install +// interrupted before any child existed — in that last case the signal is the +// one this process received, which the caller already has. // // Telemetry and the version-check notice mirror Cobra's PersistentPostRun // behavior for built-ins: both fire only on a successful (exit-0) run. -func MaybeRunPlugin(ctx context.Context, rootCmd *cobra.Command, args []string) (handled bool, exitCode int) { +func MaybeRunPlugin(ctx context.Context, rootCmd *cobra.Command, args []string) (handled bool, exitCode int, killedBy os.Signal) { binPath, pluginArgs, ok := resolvePlugin(rootCmd, args) if !ok { - return false, 0 + return false, 0, nil } pluginName := args[0] - exitCode = runPlugin(ctx, pluginName, binPath, pluginArgs) + if binPath == "" { + var err error + binPath, err = installMissingPlugin(ctx, rootCmd, pluginName) + if err != nil { + var silent *SilentError + if errors.As(silencePluginCancel(ctx, err), &silent) { + // A signal interrupted the install. Report it as a signal + // rather than a plain failure so main.go re-raises it: a + // shell breaks an enclosing loop only on WIFSIGNALED. No + // child ran, so there is no child signal to name — the + // caller falls back to the one it received. + return true, ExitPluginSignalled, nil + } + fmt.Fprintln(rootCmd.ErrOrStderr(), RenderUserFacingError(err)) + return true, 1, nil + } + if binPath == "" { + // The command was not executed because installation was declined. + return true, 1, nil + } + // Say what is happening now: the install may have taken a while, and + // it is the reason the user is still waiting. + // + // The binary's name, never the arguments. They are the user's own + // command line, already on their screen, so echoing them back adds + // nothing — and it would put whatever they contain into stderr and + // into anything capturing it: a token passed as a flag, a newline + // that forges a second line of output, a terminal escape that + // repositions the cursor or repaints what is above it. That last one + // is the same hazard hasTerminalControlChars exists for. + fmt.Fprintf(rootCmd.ErrOrStderr(), "Running %s%s\n", pluginBinaryPrefix, pluginName) + } + exitCode, killedBy = runPlugin(ctx, pluginName, binPath, pluginArgs) if exitCode == 0 { maybeTrackPluginInvocation(ctx, pluginName) // Stderr, matching the built-in PersistentPostRun: the plugin's own @@ -66,7 +126,7 @@ func MaybeRunPlugin(ctx context.Context, rootCmd *cobra.Command, args []string) postPluginVersionCheck(ctx, os.Stderr, versioninfo.Version) } } - return true, exitCode + return true, exitCode, killedBy } // maybeTrackPluginInvocation fires telemetry only for plugins on the @@ -86,6 +146,9 @@ func maybeTrackPluginInvocation(ctx context.Context, pluginName string) { telemetry.TrackPluginDetached(pluginName, s.Enabled, versioninfo.Version) } +// resolvePlugin returns an empty binary path for a missing +// onDemandInstallPluginName so the dispatcher can offer installation. Other +// missing names fall through. func resolvePlugin(rootCmd *cobra.Command, args []string) (binPath string, pluginArgs []string, ok bool) { if len(args) == 0 { return "", nil, false @@ -115,6 +178,9 @@ func resolvePlugin(rootCmd *cobra.Command, args []string) (binPath string, plugi if p, found := findInaccessiblePlugin(binName); found { return p, args[1:], true } + if name == onDemandInstallPluginName && errors.Is(err, exec.ErrNotFound) { + return "", args[1:], true + } return "", nil, false } if isAgentProtocolBinary(binPath) { @@ -171,7 +237,7 @@ func isAgentProtocolBinary(binPath string) bool { // On context cancellation the child gets SIGINT (with a 5s grace before the // runtime falls back to SIGKILL) so plugins can clean up. Terminal signals // reach the child directly via the shared process group. -func runPlugin(ctx context.Context, pluginName, binPath string, args []string) int { +func runPlugin(ctx context.Context, pluginName, binPath string, args []string) (exitCode int, killedBy os.Signal) { cmd := exec.CommandContext(ctx, binPath, args...) cmd.Cancel = func() error { return cmd.Process.Signal(os.Interrupt) } cmd.WaitDelay = 5 * time.Second @@ -214,12 +280,17 @@ func runPlugin(ctx context.Context, pluginName, binPath string, args []string) i if err := cmd.Run(); err != nil { var exitErr *exec.ExitError if errors.As(err, &exitErr) { - return exitErr.ExitCode() + // A signalled child reports -1, i.e. ExitPluginSignalled: it is + // not an exit status, so the caller re-raises the signal rather + // than letting os.Exit truncate it to 255. Which signal comes + // from the wait status, because it need not be one this process + // received — see pluginTerminatingSignal. + return exitErr.ExitCode(), pluginTerminatingSignal(exitErr.ProcessState) } // Prefix with the plugin name so users can tell parent vs child // errors apart in mixed stderr. fmt.Fprintf(os.Stderr, "Failed to run plugin %s: %v\n", filepath.Base(binPath), err) - return 1 + return 1, nil } - return 0 + return 0, nil } diff --git a/cmd/entire/cli/plugin_confirm.go b/cmd/entire/cli/plugin_confirm.go new file mode 100644 index 0000000000..34079fdc66 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm.go @@ -0,0 +1,132 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/muesli/cancelreader" + + "github.com/entireio/cli/cmd/entire/cli/interactive" +) + +// pluginPromptTerminal is the controlling terminal a confirmation prompt uses. +// +// in is the terminal rather than os.Stdin, so a confirmation never consumes +// bytes the plugin was piped. out is the terminal's own output handle, used +// only when the writer the caller supplied is not itself a terminal — see +// runPluginConfirm. It is nil when there is nothing to fall back to, which is +// the case in tests that replace the opener. +type pluginPromptTerminal struct { + in io.ReadCloser + out io.Writer + // closeOut releases out when it is a handle of its own. Unix hands back + // one file for both directions, so closing in covers it there; Windows + // opens CONIN$ and CONOUT$ separately, and writing to CONIN$ renders + // nothing — which is why the pair cannot be collapsed to one handle. + closeOut func() +} + +// Tests replace the opener rather than redirecting the command's data stream. +var openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + in, out, err := tea.OpenTTY() + if err != nil { + return pluginPromptTerminal{}, fmt.Errorf("open confirmation terminal: %w", err) + } + t := pluginPromptTerminal{in: in, out: out} + if out != in { + t.closeOut = func() { _ = out.Close() } + } + return t, nil +} + +func runPluginConfirm(ctx context.Context, out io.Writer, prompt string, defaultYes bool) (bool, error) { + if err := ctx.Err(); err != nil { + return false, fmt.Errorf("confirmation cancelled: %w", err) + } + term, err := openPluginPromptTerminal() + if err != nil { + return false, err + } + input := term.in + closeInput := sync.OnceFunc(func() { _ = input.Close() }) + defer closeInput() + if term.closeOut != nil { + defer term.closeOut() + } + // The answer is read from the terminal, so the question has to be visible + // there. A supplied writer that is not a terminal — `entire graph 2>log`, + // or any wrapper capturing stderr — left the prompt invisible while the + // terminal sat in raw mode waiting for a keypress, and with Yes as the + // default an idle Enter authorized a download-and-exec nobody was shown. + // Render on the terminal the answer comes from instead; the escape + // sequences had no business in the redirect either way. + render := out + if term.out != nil && !interactive.IsTerminalWriter(out) { + render = term.out + } + answer := defaultYes + form := NewAccessibleForm(huh.NewGroup(huh.NewConfirm().Title(prompt).Value(&answer))).WithOutput(render).WithInput(input) + if IsAccessibleMode() { + // Huh's accessible scanner ignores context and treats EOF as the default. + // Make the read cancellable and retain EOF so it cannot authorize an install. + reader, readErr := cancelreader.NewReader(input) + if readErr != nil { + return false, fmt.Errorf("confirmation input: %w", readErr) + } + defer reader.Close() + cancelled := make(chan struct{}) + stop := context.AfterFunc(ctx, func() { + if !reader.Cancel() { + // Some platforms cannot cancel reads on a separately opened + // terminal. This descriptor belongs to the prompt, so closing + // it is safe and also releases a blocked read. + closeInput() + } + close(cancelled) + }) + defer func() { + if !stop() { + <-cancelled + } + }() + checked := &pluginConfirmReader{Reader: reader} + err = form.WithInput(checked).RunWithContext(ctx) + if ctx.Err() != nil { + return false, fmt.Errorf("confirmation cancelled: %w", ctx.Err()) + } + if checked.err != nil { + if errors.Is(checked.err, io.EOF) { + return false, nil + } + return false, fmt.Errorf("confirmation input: %w", checked.err) + } + } else { + err = form.RunWithContext(ctx) + } + if ctx.Err() != nil { + return false, fmt.Errorf("confirmation cancelled: %w", ctx.Err()) + } + if err != nil { + return false, fmt.Errorf("confirmation form: %w", err) + } + return answer, nil +} + +type pluginConfirmReader struct { + io.Reader + + err error +} + +func (r *pluginConfirmReader) Read(p []byte) (int, error) { + n, err := r.Reader.Read(p) + if n == 0 { + r.err = err + } + return n, err //nolint:wrapcheck // preserve io.Reader EOF semantics for the scanner +} diff --git a/cmd/entire/cli/plugin_confirm_terminal_test.go b/cmd/entire/cli/plugin_confirm_terminal_test.go new file mode 100644 index 0000000000..2dd779bc80 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm_terminal_test.go @@ -0,0 +1,78 @@ +//go:build !windows + +package cli + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "strings" + "syscall" + "testing" + "time" + + "github.com/creack/pty" +) + +// The child has a controlling terminal but piped stdin. Both prompt modes must +// read the terminal answer and leave every byte of the plugin's input intact. +func TestPluginConfirmationRedirectedInput(t *testing.T) { + t.Parallel() + const marker = "ENTIRE_TEST_PLUGIN_CONFIRM_CHILD" + const question = "Install test plugin?" + const payload = "plugin input that must survive\n" + if os.Getenv(marker) == "1" { + answer, err := runPluginConfirm(t.Context(), os.Stderr, question, true) + if err != nil || !answer { + t.Fatalf("confirmation: answer=%v err=%v", answer, err) + } + data, err := io.ReadAll(os.Stdin) + if err != nil || string(data) != payload { + t.Fatalf("plugin stdin=%q err=%v", data, err) + } + fmt.Fprintln(os.Stderr, "INPUT_PRESERVED") + return + } + for _, accessible := range []string{"", "1"} { + t.Run("accessible="+accessible, func(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestPluginConfirmationRedirectedInput$") + cmd.Env = append(os.Environ(), marker+"=1", "ACCESSIBLE="+accessible, "TERM=xterm-256color") + cmd.Stdin = strings.NewReader(payload) + terminal, err := pty.StartWithAttrs(cmd, &pty.Winsize{Rows: 24, Cols: 100}, &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 1}) + if err != nil { + t.Fatal(err) + } + defer terminal.Close() + output := make(chan string, 1) + go func() { + var transcript strings.Builder + answered := false + buf := make([]byte, 4096) + for { + n, readErr := terminal.Read(buf) + transcript.Write(buf[:n]) + if !answered && strings.Contains(transcript.String(), question) { + answered = true + if _, err := io.WriteString(terminal, "\r"); err != nil { + cancel() + } + } + if readErr != nil { + output <- transcript.String() + return + } + } + }() + err = cmd.Wait() + transcript := <-output + if err != nil || !strings.Contains(transcript, "INPUT_PRESERVED") { + t.Fatalf("child: %v\n%s", err, transcript) + } + }) + } +} diff --git a/cmd/entire/cli/plugin_confirm_test.go b/cmd/entire/cli/plugin_confirm_test.go new file mode 100644 index 0000000000..d6799896c0 --- /dev/null +++ b/cmd/entire/cli/plugin_confirm_test.go @@ -0,0 +1,157 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "strings" + "testing" + "time" +) + +func TestPluginDependencyConfirmationUsesWriter(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TEST_TTY", "1") + original := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("y\n"))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = original }) + var stderr bytes.Buffer + ok, err := confirmPluginAction(t.Context(), &stderr, "Install them now?", false) + if err != nil || !ok { + t.Fatalf("answer=%v err=%v", ok, err) + } + if !strings.Contains(stderr.String(), "Install them now? [y/N]") { + t.Fatalf("missing prompt on stderr: %q", stderr.String()) + } +} + +func TestPluginAccessibleConfirmationCancellation(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + for _, fallback := range []bool{false, true} { + name := "pollable input" + if fallback { + name = "fallback input" + } + t.Run(name, func(t *testing.T) { + t.Setenv("ACCESSIBLE", "1") + input, writer, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + defer writer.Close() + tracked := &pluginPromptCloseTracker{ReadCloser: input} + original := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + if fallback { + return pluginPromptTerminal{in: tracked}, nil + } + return pluginPromptTerminal{in: input}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = original }) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + ready := make(chan struct{}, 1) + result := make(chan error, 1) + go func() { + _, promptErr := runPluginConfirm(ctx, pluginPromptNotifyWriter{ready}, "Install?", true) + result <- promptErr + }() + select { + case <-ready: + case <-time.After(5 * time.Second): + t.Fatal("prompt did not start") + } + cancel() + select { + case err := <-result: + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want cancellation", err) + } + case <-time.After(5 * time.Second): + _ = writer.Close() + <-result + t.Fatal("accessible prompt did not stop on cancellation") + } + if fallback && tracked.closes != 1 { + t.Fatalf("input closed %d times, want exactly once", tracked.closes) + } + }) + } +} + +type pluginPromptNotifyWriter struct{ ready chan<- struct{} } + +func (w pluginPromptNotifyWriter) Write(p []byte) (int, error) { + select { + case w.ready <- struct{}{}: + default: + } + return len(p), nil +} + +// Hiding the file descriptor forces cancelreader's non-pollable fallback: +// Cancel returns false, so closing the input must unblock the prompt. +type pluginPromptCloseTracker struct { + io.ReadCloser + + closes int +} + +func (r *pluginPromptCloseTracker) Close() error { + r.closes++ + return r.ReadCloser.Close() +} + +// The answer comes from the terminal, so the question has to appear there. +// A writer that is not a terminal — `entire graph 2>log`, or a wrapper +// capturing stderr — rendered the prompt into the redirect: nothing reached +// the terminal, which sat in raw mode waiting for a keypress, and with Yes as +// the default an idle Enter authorized a download-and-exec nobody was shown. +func TestPluginConfirmationRendersOnTerminalWhenWriterIsRedirected(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TEST_TTY", "1") + var terminal bytes.Buffer + original := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("y\n")), out: &terminal}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = original }) + + var redirected bytes.Buffer // stands in for a redirected stderr + ok, err := runPluginConfirm(t.Context(), &redirected, "Install the entire-graph plugin?", true) + if err != nil || !ok { + t.Fatalf("answer=%v err=%v", ok, err) + } + if !strings.Contains(terminal.String(), "Install the entire-graph plugin?") { + t.Errorf("prompt did not reach the terminal: %q", terminal.String()) + } + if redirected.Len() != 0 { + t.Errorf("prompt leaked into the redirected writer: %q", redirected.String()) + } +} + +// The other half of the same rule: a writer that IS a terminal is what the +// prompt renders to, so the caller keeps deciding where its own output goes. +// Covered for real terminals by TestPluginConfirmationRedirectedInput; here +// the check is that a non-terminal writer with no terminal handle to fall +// back to is still used rather than dropped. +func TestPluginConfirmationUsesSuppliedWriterWithoutATerminalHandle(t *testing.T) { //nolint:paralleltest // isolates terminal opener and accessibility + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TEST_TTY", "1") + original := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("y\n"))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = original }) + + var supplied bytes.Buffer + if _, err := runPluginConfirm(t.Context(), &supplied, "Install?", true); err != nil { + t.Fatal(err) + } + if !strings.Contains(supplied.String(), "Install?") { + t.Errorf("prompt did not reach the supplied writer: %q", supplied.String()) + } +} diff --git a/cmd/entire/cli/plugin_fetch.go b/cmd/entire/cli/plugin_fetch.go index 2da3d10a29..8c4ed9fe72 100644 --- a/cmd/entire/cli/plugin_fetch.go +++ b/cmd/entire/cli/plugin_fetch.go @@ -276,6 +276,8 @@ type fetchedAsset struct { // one is published. Returns errAssetNotFound (possibly wrapped) when the // tag has no asset for this platform. func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, name, tag, stagingDir string, allowUnverified bool) (*fetchedAsset, error) { + stopLocate := startPluginStep(ctx, "Locating plugin release files...") + defer stopLocate() // Resolve the prefix once. It does not depend on the asset name, so // deriving it per candidate meant re-parsing the repo URL ~36 times in the // probe loop and carrying an error return through three call sites for a @@ -302,6 +304,8 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam errUnverifiedAsset, pluginMetadataFileName, checksumsFileName) } u := expandDownloadTemplate(meta.DownloadURL, name, tag, "") + stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() return fetchAndVerify(ctx, u, assetNameFromURL(u), "", stagingDir) } @@ -323,6 +327,8 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam // directly. continue } + stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() return fetchAndVerify(ctx, assetURL(asset), asset, digest, stagingDir) } @@ -336,6 +342,8 @@ func downloadPluginAsset(ctx context.Context, meta *PluginMetadata, repoURL, nam // (errUnverifiedAsset, which an older tag wouldn't fix). Getting that // wrong would report a missing release for a plugin that simply doesn't // ship checksums. + stopLocate() + defer startPluginStep(ctx, "Downloading plugin archive...")() for _, asset := range assetCandidates(name, tag) { fa, err := fetchAndVerify(ctx, assetURL(asset), asset, "", stagingDir) switch { diff --git a/cmd/entire/cli/plugin_fetch_test.go b/cmd/entire/cli/plugin_fetch_test.go index 1221f9c22c..5cd9bcad33 100644 --- a/cmd/entire/cli/plugin_fetch_test.go +++ b/cmd/entire/cli/plugin_fetch_test.go @@ -472,17 +472,22 @@ func TestDownloadPluginAsset_ViaChecksumManifest(t *testing.T) { func TestDownloadPluginAsset_ProbeFallbackWithoutChecksums(t *testing.T) { t.Parallel() payload := makeTarGz(t, map[string][]byte{"entire-run": []byte("bin")}) - asset := fmt.Sprintf("entire-run_1.0.0_%s_%s.tar.gz", runtime.GOOS, runtime.GOARCH) + candidates := assetCandidates("run", "v1.0.0") + asset := candidates[len(candidates)-1] srv := assetServer(t, asset, payload, "") meta := &PluginMetadata{DownloadURL: srv.URL + "/dl/{asset}"} - fa, err := downloadPluginAsset(context.Background(), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir(), true) + var progress bytes.Buffer + fa, err := downloadPluginAsset(withPluginProgress(t.Context(), &progress), meta, "https://example.invalid/entire-run", "run", "v1.0.0", t.TempDir(), true) if err != nil { t.Fatalf("downloadPluginAsset: %v", err) } if fa.Asset != asset { t.Errorf("Asset = %q, want %q", fa.Asset, asset) } + if got := strings.Count(progress.String(), "Downloading plugin archive..."); got != 1 { + t.Fatalf("download phase reported %d times: %s", got, &progress) + } } // allowUnverified stays false here on purpose: with verification required, diff --git a/cmd/entire/cli/plugin_group.go b/cmd/entire/cli/plugin_group.go index 9fdb29daa5..b6e1a47c7c 100644 --- a/cmd/entire/cli/plugin_group.go +++ b/cmd/entire/cli/plugin_group.go @@ -74,6 +74,18 @@ type installSource struct { // Ref is the repository URL, the filesystem path, or the catalog name, // according to Kind. Ref string + // Resolved is the catalog entry a caller already looked up for Ref. + // + // Set it whenever the caller has SHOWN the user which repository will be + // installed. runRemoteInstall reads the index for its own reasons, and + // that second read can disagree with the first: a refresh that failed + // leaves the freshness marker untouched, so the next call retries the + // fetch and may succeed with different content, and a concurrent + // `plugin index update --force` rewrites the clone under the lock either + // way. Re-resolving after a confirmation therefore lets the prompt name + // repository A while repository B is downloaded and executed — which + // makes naming the repository worse than useless. + Resolved *PluginIndexEntry } // parseInstallSource classifies an install argument and validates it in one @@ -202,6 +214,7 @@ type remoteInstallFlags struct { func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource, flags remoteInstallFlags) error { out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + ctx = withPluginProgress(ctx, errOut) repoURL := src.Ref var trusted bool @@ -210,13 +223,23 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // Both paths need the catalog: one to resolve a name, the other for the // trust check. Sync once. An unreachable index is fatal only for the // name-resolution path; a URL install degrades to "not listed". + stopIndex := startPluginStep(ctx, "Checking plugin index...") idx, idxErr := SyncPluginIndex(ctx, resolvePluginIndexURL(flags.index), false) + stopIndex() if src.Kind == installFromIndex { - if idxErr != nil { - return fmt.Errorf("resolve %q via plugin index: %w", src.Ref, idxErr) + entry := src.Resolved + // Only consult the index when the caller did not already resolve the + // name. An idxErr is fatal only in that case: a caller that arrives + // with an entry has done the lookup, and the index is needed after + // this only for dependency planning, which already degrades to a + // warning when it is unavailable. + if entry == nil { + if idxErr != nil { + return fmt.Errorf("resolve %q via plugin index: %w", src.Ref, idxErr) + } + entry = idx.Find(src.Ref) } - entry := idx.Find(src.Ref) if entry == nil { // Bare names never resolve to local files (see // parseInstallSource), but a user who typed one expecting a @@ -250,7 +273,7 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // An untrusted source cannot proceed unconfirmed: automation never // reaches this prompt, because the non-interactive path fails above // with the --yes hint. - proceed, err := confirmInstallOrCancel(ctx, out, + proceed, err := confirmInstallOrCancel(ctx, errOut, fmt.Sprintf("Install from %s? The repository is not listed in the plugin index.", redactURL(repoURL)), flags.yes) if err != nil || !proceed { @@ -300,7 +323,9 @@ func runRemoteInstall(ctx context.Context, cmd *cobra.Command, src installSource // error — doctor reports the gap afterwards. func installPlannedDeps(ctx context.Context, cmd *cobra.Command, reqs []PluginRequirement, idx *PluginIndex, flags remoteInstallFlags) error { out, errOut := cmd.OutOrStdout(), cmd.ErrOrStderr() + stopPlan := startPluginStep(ctx, "Checking plugin dependencies...") plan, err := PlanDependencyInstalls(ctx, reqs, idx) + stopPlan() if err != nil { return fmt.Errorf("resolve dependencies: %w", err) } @@ -325,7 +350,7 @@ func installPlannedDeps(ctx context.Context, cmd *cobra.Command, reqs []PluginRe fmt.Fprintf(out, " %s (%s)\n", a.Name, redactURL(a.RepoURL)) } } - ok, err := confirmPluginAction(ctx, "Install them now?", flags.yes) + ok, err := confirmPluginAction(ctx, errOut, "Install them now?", flags.yes) switch { case errors.Is(err, errConfirmNeedsTerminal): // Non-interactive without --yes: the main install already @@ -375,19 +400,15 @@ var errConfirmNeedsTerminal = errors.New("confirmation required but no terminal // non-interactive runs without --yes return errConfirmNeedsTerminal rather // than guessing. Prompt errors (including huh.ErrUserAborted on Ctrl+C/Esc) // are returned raw for callers to map via handleFormCancellation. -func confirmPluginAction(ctx context.Context, prompt string, assumeYes bool) (bool, error) { +func confirmPluginAction(ctx context.Context, out io.Writer, prompt string, assumeYes bool) (bool, error) { if assumeYes { return true, nil } if !interactive.CanPromptInteractively() { return false, fmt.Errorf("%w (%s)", errConfirmNeedsTerminal, prompt) } - confirmed := false - form := NewAccessibleForm(huh.NewGroup( - huh.NewConfirm().Title(prompt).Value(&confirmed), - )) - if err := form.RunWithContext(ctx); err != nil { - // %w keeps huh.ErrUserAborted reachable for handleFormCancellation. + confirmed, err := runPluginConfirm(ctx, out, prompt, false) + if err != nil { return false, fmt.Errorf("confirm: %w", err) } return confirmed, nil @@ -400,7 +421,7 @@ func confirmPluginAction(ctx context.Context, prompt string, assumeYes bool) (bo // wrapped, and errConfirmNeedsTerminal propagates unchanged so the caller // decides whether an unattended run may proceed without an answer. func confirmInstallOrCancel(ctx context.Context, out io.Writer, prompt string, assumeYes bool) (bool, error) { - ok, err := confirmPluginAction(ctx, prompt, assumeYes) + ok, err := confirmPluginAction(ctx, out, prompt, assumeYes) switch { case errors.Is(err, errConfirmNeedsTerminal): return false, err @@ -449,7 +470,7 @@ func warnIfShadowsBuiltin(cmd *cobra.Command, name string) { func newPluginListCmd() *cobra.Command { return &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List plugins installed in the managed directory", RunE: func(cmd *cobra.Command, _ []string) error { return runPluginList(cmd.OutOrStdout()) @@ -540,7 +561,11 @@ manifest upgrades need; local-dev symlink installs are skipped. Plugins installed with --pin are skipped until reinstalled without the pin.`, Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { - ctx := cmd.Context() + // Upgrading does the same network work as installing — list tags, + // fetch metadata, download, place the binary — so it reports the + // same stages. Without this the startPluginStep calls on that path + // are no-ops, because progress travels on the context. + ctx := withPluginProgress(cmd.Context(), cmd.ErrOrStderr()) out := cmd.OutOrStdout() var names []string switch { @@ -738,7 +763,9 @@ in scripts and non-interactive runs.`, huh.NewSelect[string]().Title("Install a plugin").Options(options...).Value(&choice), )) if err := form.RunWithContext(ctx); err != nil { - return handleFormCancellation(cmd.OutOrStdout(), "Browse", err) + // Stderr, like the confirmation below it: stdout carries the + // install result and nothing else. + return handleFormCancellation(cmd.ErrOrStderr(), "Browse", err) } if choice == "" { return nil @@ -750,7 +777,7 @@ in scripts and non-interactive runs.`, // binary and links it onto PATH in one keystroke. The picker also // only shows name and description, so the repository the binary // actually comes from is named here for the first time. - out := cmd.OutOrStdout() + out := cmd.ErrOrStderr() prompt := fmt.Sprintf("Install %q?", choice) if entry := idx.Find(choice); entry != nil { prompt = fmt.Sprintf("Install %q from %s?", choice, redactURL(entry.RepoURL)) diff --git a/cmd/entire/cli/plugin_install_remote.go b/cmd/entire/cli/plugin_install_remote.go index 9016a6dcc9..16ae051d0e 100644 --- a/cmd/entire/cli/plugin_install_remote.go +++ b/cmd/entire/cli/plugin_install_remote.go @@ -81,7 +81,9 @@ func InstallPluginFromRepo(ctx context.Context, repoURL, expectedName string, op tags = []string{opts.Pin} } else { var err error + stopTags := startPluginStep(ctx, "Finding latest plugin release...") tags, err = listRemoteSemverTags(ctx, repoURL) + stopTags() if err != nil { return nil, err } @@ -109,7 +111,9 @@ func InstallPluginFromRepo(ctx context.Context, repoURL, expectedName string, op } func installRepoAtTag(ctx context.Context, repoURL, expectedName, tag string, opts RemoteInstallOptions) (*RemoteInstallResult, error) { + stopMetadata := startPluginStep(ctx, fmt.Sprintf("Fetching plugin metadata for %s...", tag)) meta, err := fetchPluginMetadataAtTag(ctx, repoURL, tag) + stopMetadata() if err != nil { return nil, err } @@ -190,6 +194,8 @@ func installRepoAtTag(ctx context.Context, repoURL, expectedName, tag string, op return nil, err } + stopInstall := startPluginStep(ctx, fmt.Sprintf("Installing entire-%s %s...", name, tag)) + defer stopInstall() binBase := pluginBinaryName(name) stagedName := "extracted-" + binBase if err := extractPluginBinary(asset.Path, name, stagingRoot, stagedName); err != nil { diff --git a/cmd/entire/cli/plugin_on_demand.go b/cmd/entire/cli/plugin_on_demand.go new file mode 100644 index 0000000000..2b0bf80d9b --- /dev/null +++ b/cmd/entire/cli/plugin_on_demand.go @@ -0,0 +1,160 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "os" + + "github.com/entireio/cli/cmd/entire/cli/interactive" + "github.com/spf13/cobra" +) + +// onDemandPluginInstall shares the normal install workflow, including index +// overrides, name/checksum validation and dependency confirmation. Tests replace +// it to exercise the prompt and dispatch without downloading real releases. +var onDemandPluginInstall = runRemoteInstall + +func installMissingPlugin(ctx context.Context, rootCmd *cobra.Command, name string) (string, error) { + // The plugin may already be in the managed directory and merely + // unreachable through PATH — a managed bin dir that could not be + // prepended at startup. Offering to install over it is a dead end: + // installRepoAtTag refuses an existing install without --force, which the + // on-demand path deliberately does not pass, so the user answers Yes, + // waits for three network round-trips and gets "already installed; use + // --force to replace". Execute the managed entry instead, which is what + // this function's own return contract promises below. + // + // A listing error falls through to the install rather than failing here: + // the install path reads the same directory and reports the problem in + // terms of what it was trying to do. + if installed, err := FindInstalledPlugin(name); err == nil && installed != nil { + // The entry is not necessarily runnable. ListInstalledPlugins reports + // what it finds with Lstat, so a local-dev symlink whose target moved + // is listed like any other install — exec'ing it fails with a + // fork/exec ENOENT that names a path the user never chose and offers + // no way forward. Say what is wrong and how to fix it instead. + // + // Reinstalling automatically would be the other option, and it is + // deliberately not taken: replacing a developer's deliberate symlink + // with a released binary is their call to make, not ours. + if reinstallFixes, cerr := checkManagedPluginRunnable(installed.Path); cerr != nil { + broken := fmt.Errorf("the entire-%s plugin is installed at %s but cannot be run: %w", name, installed.Path, cerr) + if !reinstallFixes { + return "", broken + } + return "", fmt.Errorf("%w; reinstall it with 'entire plugin install %s --force'", broken, name) + } + return installed.Path, nil + } + if !interactive.CanPromptInteractively() { + return "", fmt.Errorf("the entire-%s plugin is not installed; run 'entire plugin install %s' and retry", name, name) + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + + // Resolve the source before asking, for two reasons. + // + // The prompt is the only human checkpoint on this path — an index-listed + // install never prompts inside runRemoteInstall, because the catalog is + // the trust decision — so it has to say where the binary comes from. + // confirmInstallOrCancel names redactURL(repoURL) for an unlisted + // repository; a prompt that defaults to Yes and names nothing tells the + // user less about a download-and-exec than the one that defaults to No. + // + // And a name the index does not carry cannot be installed at all, so + // asking first and failing afterwards spends the user's Yes on a question + // that never had an answer — the same prompt-then-dead-end shape as the + // already-installed case above. + // + // This is not an extra round-trip: SyncPluginIndex touches a freshness + // marker, and runRemoteInstall's own call moments later reads the clone + // without fetching (pluginIndexTTL). Progress is reported and stopped + // before the prompt, per the rule that a spinner never overlaps a + // confirmation. + indexURL := resolvePluginIndexURL("") + stopIndex := startPluginStep(withPluginProgress(ctx, rootCmd.ErrOrStderr()), "Checking plugin index...") + idx, err := SyncPluginIndex(ctx, indexURL, false) + stopIndex() + if err != nil { + return "", fmt.Errorf("look up the entire-%s plugin in the plugin index %s: %w", name, redactURL(indexURL), err) + } + entry := idx.Find(name) + if entry == nil { + return "", fmt.Errorf("the entire-%s plugin is not listed in the plugin index %s, so it cannot be installed on demand; install it from its repository URL with 'entire plugin install '", name, redactURL(indexURL)) + } + + confirmed, err := runPluginConfirm(ctx, rootCmd.ErrOrStderr(), + fmt.Sprintf("Install the entire-%s plugin from %s?", name, redactURL(entry.RepoURL)), true) + if err != nil { + if ctx.Err() != nil { + return "", err + } + return "", handleFormCancellation(rootCmd.ErrOrStderr(), "Install", err) + } + if !confirmed { + fmt.Fprintln(rootCmd.ErrOrStderr(), "Install cancelled.") + return "", nil + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + + // Keep install progress off stdout: the original command may emit JSON or + // be piped to another tool. Do not parse any of the plugin's arguments. + cmd := newPluginInstallCmd() + cmd.SetOut(rootCmd.ErrOrStderr()) + cmd.SetErr(rootCmd.ErrOrStderr()) + // Resolved carries the entry the prompt named, so the install cannot + // re-resolve into a different repository after the user has agreed to + // this one — see installSource.Resolved. + src := installSource{Kind: installFromIndex, Ref: name, Resolved: entry} + if err := onDemandPluginInstall(ctx, cmd, src, remoteInstallFlags{}); err != nil { + return "", err + } + if err := ctx.Err(); err != nil { + return "", fmt.Errorf("install plugin: %w", err) + } + installed, err := FindInstalledPlugin(name) + if err != nil { + return "", err + } + if installed == nil { + return "", fmt.Errorf("the entire-%s plugin was not installed; run 'entire plugin install %s' and retry", name, name) + } + // Execute the managed entry directly, even if the managed directory could + // not be prepended to PATH at startup. + return installed.Path, nil +} + +// checkManagedPluginRunnable reports why a managed plugin entry cannot be +// executed, or a nil error when it can. reinstallFixes is true only for a +// condition a reinstall actually repairs, so the caller does not attach that +// remedy to an error it would not resolve — a permission failure on the +// managed directory is not fixed by installing into it, and a remedy hung off +// an unmatched error is how advice comes to name the wrong cause (the mistake +// writeEntireDirRemedy documents for `.entire`). It is meaningless when the +// error is nil. +// +// os.Stat rather than Lstat, deliberately: the question is whether the thing +// at the end of the entry exists, and a dangling symlink is the case that +// brought this function into being. The executable bit is left to the exec — +// findInaccessiblePlugin draws the same line for PATH entries, and the mode +// does not mean the same thing on Windows. +func checkManagedPluginRunnable(path string) (reinstallFixes bool, err error) { + info, statErr := os.Stat(path) + if statErr != nil { + if errors.Is(statErr, os.ErrNotExist) { + // Worded rather than passed through because the errno itself ("no + // such file or directory") describes the entry, which plainly + // exists; what is missing is whatever it points at. + return true, errors.New("it points at a file that no longer exists") + } + return false, statErr //nolint:wrapcheck // the caller adds the plugin name and path + } + if info.IsDir() { + return true, errors.New("it is a directory") + } + return false, nil +} diff --git a/cmd/entire/cli/plugin_on_demand_test.go b/cmd/entire/cli/plugin_on_demand_test.go new file mode 100644 index 0000000000..1d07b30582 --- /dev/null +++ b/cmd/entire/cli/plugin_on_demand_test.go @@ -0,0 +1,404 @@ +package cli + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestMaybeRunPlugin_MissingGraphNonInteractive(t *testing.T) { //nolint:paralleltest // isolates PATH and terminal detection + t.Setenv("PATH", t.TempDir()) + t.Setenv("ENTIRE_TEST_TTY", "0") + // The managed dir is consulted before the prompt (see installMissingPlugin), + // and it is NOT covered by the testdirs fallback — pluginParentDir reads + // $ENTIRE_PLUGIN_DIR/$XDG_DATA_HOME and the home dir itself. Without this + // the test reads the developer's real plugins and passes only on a machine + // that happens not to have graph installed. + withPluginDir(t) + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code, _ := MaybeRunPlugin(t.Context(), root, []string{"graph", "search", "hello"}) + if !handled || code != 1 { + t.Fatalf("handled=%v code=%d, want true, 1", handled, code) + } + if !strings.Contains(stderr.String(), "entire plugin install graph") { + t.Fatalf("missing installation hint: %q", stderr.String()) + } +} + +func TestMaybeRunPlugin_InstallGraphAndRun(t *testing.T) { //nolint:paralleltest // isolates environment and installer seam + for _, tc := range []struct { + name string + answer string + installErr error + cancelInstall bool + pluginCode int + wantCode int + wantInstall bool + wantRun bool + }{ + {name: "enter accepts default yes", answer: "\n", wantInstall: true, wantRun: true}, + {name: "explicit yes preserves exit code", answer: "y\n", pluginCode: 42, wantCode: 42, wantInstall: true, wantRun: true}, + {name: "cancelled install stays quiet", answer: "y\n", cancelInstall: true, installErr: context.Canceled, wantCode: ExitPluginSignalled, wantInstall: true}, + {name: "cancelled dependency confirmation does not run", answer: "y\n", cancelInstall: true, wantCode: ExitPluginSignalled, wantInstall: true}, + {name: "EOF declines", answer: "", wantCode: 1}, + {name: "no cancels", answer: "n\n", wantCode: 1}, + {name: "failed install does not run", answer: "\n", installErr: errors.New("download failed"), wantCode: 1, wantInstall: true}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + // dir first so entire-graph is unresolvable, but git still + // reachable: the index clone shells out to it. + withIsolatedPath(t) + withPathDir(t, dir) + t.Setenv("ENTIRE_PLUGIN_DIR", filepath.Join(dir, "managed")) + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + t.Setenv("ENTIRE_TELEMETRY_OPTOUT", "1") + // The prompt names the repository the binary comes from, so the + // entry has to resolve before it is shown. A local index keeps + // that off the network — without it these tests would consult the + // real published catalog. + withIndexCache(t) + indexURL, _ := newIndexRepo(t, `{"version":1,"plugins":[{"name":"graph","repo_url":"https://github.com/entireio/entire-graph"}]}`) + t.Setenv(pluginIndexEnvVar, indexURL) + interceptVersionCheck(t) + argFile := filepath.Join(dir, "args.txt") + sourceDir := t.TempDir() + source := writePluginBinary(t, sourceDir, "entire-graph", argFile, tc.pluginCode) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + installCalls := 0 + original := onDemandPluginInstall + onDemandPluginInstall = func(_ context.Context, cmd *cobra.Command, src installSource, flags remoteInstallFlags) error { + installCalls++ + if tc.cancelInstall { + cancel() + } + if src.Kind != installFromIndex || src.Ref != "graph" || flags != (remoteInstallFlags{}) { + t.Fatalf("unexpected install request: %+v %+v", src, flags) + } + // The entry the prompt named travels with the request, so the + // install cannot re-resolve into a different repository after + // the user agreed to this one. + if src.Resolved == nil || src.Resolved.RepoURL != "https://github.com/entireio/entire-graph" { + t.Fatalf("install was not bound to the repository shown: %+v", src.Resolved) + } + if tc.installErr != nil { + return tc.installErr + } + _, err := InstallPluginFromPath(InstallPluginOptions{SourcePath: source}) + fmt.Fprintln(cmd.OutOrStdout(), "Installed graph") + return err + } + t.Cleanup(func() { onDemandPluginInstall = original }) + root := newTestRoot() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + originalInput := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader(tc.answer))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = originalInput }) + data := strings.NewReader("plugin data\n") + root.SetIn(data) + args := []string{"graph", "search", "two words", "--json", "--", "$(untouched)", ""} + handled, code, _ := MaybeRunPlugin(ctx, root, args) + if !handled || code != tc.wantCode { + t.Fatalf("handled=%v code=%d, want true, %d; stderr=%s", handled, code, tc.wantCode, &stderr) + } + if (installCalls == 1) != tc.wantInstall { + t.Errorf("install calls=%d, want install=%v", installCalls, tc.wantInstall) + } + // The prompt names its source: this is the only human checkpoint + // before a remote binary is downloaded and executed, and an + // index-listed install never prompts inside runRemoteInstall. + if !strings.Contains(stderr.String(), "Install the entire-graph plugin from https://github.com/entireio/entire-graph?") || + !strings.Contains(stderr.String(), "[Y/n]") { + t.Errorf("prompt must name the repository and default to Yes: %q", stderr.String()) + } + if data.Len() != len("plugin data\n") { + t.Error("confirmation consumed plugin stdin") + } + if stdout.Len() != 0 { + t.Errorf("installation polluted stdout: %q", stdout.String()) + } + got, err := os.ReadFile(argFile) + if tc.wantRun { + if err != nil || string(got) != strings.Join(args[1:], "\n")+"\n" { + t.Errorf("forwarded args=%q err=%v", got, err) + } + } else if !os.IsNotExist(err) { + t.Errorf("plugin unexpectedly ran: args=%q err=%v", got, err) + } + if tc.cancelInstall && strings.Contains(stderr.String(), "context canceled") { + t.Errorf("raw cancellation: %s", &stderr) + } + if tc.installErr != nil && !tc.cancelInstall && !strings.Contains(stderr.String(), tc.installErr.Error()) { + t.Errorf("missing install failure: %q", stderr.String()) + } + }) + } +} + +func TestMaybeRunPlugin_GraphInstalledSkipsPrompt(t *testing.T) { //nolint:paralleltest // isolates PATH and version check + dir := t.TempDir() + argFile := filepath.Join(dir, "args.txt") + writePluginBinary(t, dir, "entire-graph", argFile, 0) + t.Setenv("PATH", dir) + interceptVersionCheck(t) + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code, _ := MaybeRunPlugin(t.Context(), root, []string{"graph", "--help"}) + if !handled || code != 0 || stderr.Len() != 0 { + t.Fatalf("handled=%v code=%d stderr=%q", handled, code, stderr.String()) + } +} + +func TestResolvePlugin_OnDemandEligibility(t *testing.T) { //nolint:paralleltest // isolates PATH + t.Setenv("PATH", t.TempDir()) + for _, args := range [][]string{nil, {"--help"}, {"other-plugin"}, {"Graph"}, {"agent-graph"}, {"session", "graph"}} { + if _, _, ok := resolvePlugin(newTestRoot(), args); ok { + t.Errorf("unexpected plugin resolution for %q", args) + } + } + root := newTestRoot() + root.AddCommand(&cobra.Command{Use: "graph"}) + if _, _, ok := resolvePlugin(root, []string{"graph", "search"}); ok { + t.Fatal("built-in graph must take precedence over on-demand installation") + } +} + +// A managed entry PATH cannot reach is the case installMissingPlugin's return +// contract already promised: run it. Offering to install over it dead-ended, +// because an existing install needs --force and the on-demand path passes +// none — so the user answered Yes, waited for the index and metadata fetches, +// and got "already installed; use --force to replace". +func TestMaybeRunPlugin_GraphInManagedDirIsRunNotReinstalled(t *testing.T) { //nolint:paralleltest // isolates PATH and managed plugins + withIsolatedPluginEnv(t) + interceptVersionCheck(t) + binDir, err := EnsurePluginBinDir() + if err != nil { + t.Fatal(err) + } + argFile := filepath.Join(t.TempDir(), "args.txt") + writePluginBinary(t, binDir, "entire-graph", argFile, 0) + // Deliberately NOT on PATH: this is the managed bin dir that could not be + // prepended at startup. + if _, lookErr := exec.LookPath("entire-graph"); lookErr == nil { + t.Fatal("precondition: entire-graph must not resolve through PATH") + } + + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + originalTerminal := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + t.Error("an already-installed plugin must not prompt for installation") + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("n\n"))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = originalTerminal }) + originalInstall := onDemandPluginInstall + onDemandPluginInstall = func(context.Context, *cobra.Command, installSource, remoteInstallFlags) error { + t.Error("an already-installed plugin must not be reinstalled") + return nil + } + t.Cleanup(func() { onDemandPluginInstall = originalInstall }) + + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code, _ := MaybeRunPlugin(t.Context(), root, []string{"graph", "search", "hello"}) + if !handled || code != 0 { + t.Fatalf("handled=%v code=%d, want true, 0; stderr=%s", handled, code, &stderr) + } + got, err := os.ReadFile(argFile) + if err != nil || string(got) != "search\nhello\n" { + t.Fatalf("managed entry did not run with the forwarded args: %q %v", got, err) + } + // The announcement names the binary and nothing else: the arguments are + // the user's own command line, and echoing them back would carry whatever + // they hold (a token, a newline, a terminal escape) into stderr. + if !strings.Contains(stderr.String(), "Running entire-graph\n") { + t.Errorf("plugin was not announced: %q", stderr.String()) + } + if strings.Contains(stderr.String(), "hello") { + t.Errorf("arguments were echoed back: %q", stderr.String()) + } +} + +// Arguments never reach stderr, whatever they contain. A terminal escape in +// one could reposition the cursor or repaint the lines above it — the hazard +// hasTerminalControlChars guards for index entries — and a flag value could be +// a token that then lands in any log capturing stderr. +func TestMaybeRunPlugin_AnnouncementNeverEchoesArguments(t *testing.T) { //nolint:paralleltest // isolates PATH and managed plugins + withIsolatedPluginEnv(t) + interceptVersionCheck(t) + binDir, err := EnsurePluginBinDir() + if err != nil { + t.Fatal(err) + } + writePluginBinary(t, binDir, "entire-graph", filepath.Join(t.TempDir(), "args.txt"), 0) + + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + args := []string{"graph", "--token", "s3cr3t", "\x1b[1A\x1b[2Kforged", "line\nbreak"} + if handled, code, _ := MaybeRunPlugin(t.Context(), root, args); !handled || code != 0 { + t.Fatalf("handled=%v code=%d; stderr=%s", handled, code, &stderr) + } + if !strings.Contains(stderr.String(), "Running entire-graph\n") { + t.Errorf("plugin was not announced: %q", stderr.String()) + } + for _, leaked := range []string{"s3cr3t", "\x1b", "forged", "line\nbreak"} { + if strings.Contains(stderr.String(), leaked) { + t.Errorf("argument content %q reached stderr: %q", leaked, stderr.String()) + } + } +} + +// A managed entry Lstat reports but exec cannot use — the local-dev symlink +// whose target moved — is neither run nor offered for installation: exec'ing +// it fails with a fork/exec ENOENT naming a path the user never chose, and +// prompting dead-ends on the already-installed guard. Both are replaced by a +// message that says what is broken and how to repair it. +func TestMaybeRunPlugin_BrokenManagedEntryReportsARemedy(t *testing.T) { //nolint:paralleltest // isolates PATH and managed plugins + withIsolatedPluginEnv(t) + interceptVersionCheck(t) + binDir, err := EnsurePluginBinDir() + if err != nil { + t.Fatal(err) + } + entry := filepath.Join(binDir, "entire-graph") + if err := os.Symlink(filepath.Join(t.TempDir(), "gone", "entire-graph"), entry); err != nil { + t.Fatal(err) + } + if found, ferr := FindInstalledPlugin("graph"); ferr != nil || found == nil { + t.Fatalf("precondition: a dangling entry must still be listed: %v %v", found, ferr) + } + + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + originalTerminal := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + t.Error("a broken entry must not be answered with an install prompt") + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("n\n"))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = originalTerminal }) + originalInstall := onDemandPluginInstall + onDemandPluginInstall = func(context.Context, *cobra.Command, installSource, remoteInstallFlags) error { + t.Error("a broken entry must not be silently reinstalled over") + return nil + } + t.Cleanup(func() { onDemandPluginInstall = originalInstall }) + + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code, _ := MaybeRunPlugin(t.Context(), root, []string{"graph", "search"}) + if !handled || code != 1 { + t.Fatalf("handled=%v code=%d, want true, 1; stderr=%s", handled, code, &stderr) + } + for _, want := range []string{ + entry, + "cannot be run", + "points at a file that no longer exists", + "entire plugin install graph --force", + } { + if !strings.Contains(stderr.String(), want) { + t.Errorf("missing %q in the diagnosis: %q", want, stderr.String()) + } + } + if strings.Contains(stderr.String(), "use --force to replace") { + t.Errorf("fell through to the already-installed dead end: %q", stderr.String()) + } +} + +// The remedy is offered only for conditions a reinstall repairs, so it cannot +// be hung off an error it would not resolve. Both identified conditions are +// repairable; the unidentified branch (a stat failure that is not ENOENT) is +// left to review, since staging one means breaking permissions on the managed +// directory, which breaks its discovery first and exercises the wrong path. +func TestCheckManagedPluginRunnable(t *testing.T) { + t.Parallel() + dir := t.TempDir() + runnable := filepath.Join(dir, "entire-ok") + if err := os.WriteFile(runnable, []byte("#!/bin/sh\n"), 0o755); err != nil { + t.Fatal(err) + } + dangling := filepath.Join(dir, "entire-dangling") + if err := os.Symlink(filepath.Join(dir, "gone"), dangling); err != nil { + t.Fatal(err) + } + asDir := filepath.Join(dir, "entire-dir") + if err := os.Mkdir(asDir, 0o755); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + name string + path string + wantErr string + wantReinstallFix bool + }{ + {name: "regular file", path: runnable}, + {name: "dangling symlink", path: dangling, wantErr: "points at a file that no longer exists", wantReinstallFix: true}, + {name: "directory", path: asDir, wantErr: "it is a directory", wantReinstallFix: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + reinstallFixes, err := checkManagedPluginRunnable(tc.path) + if tc.wantErr == "" { + if err != nil { + t.Fatalf("err=%v, want nil", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("err=%v, want %q", err, tc.wantErr) + } + if reinstallFixes != tc.wantReinstallFix { + t.Errorf("reinstallFixes=%v, want %v", reinstallFixes, tc.wantReinstallFix) + } + }) + } +} + +// A name the index does not carry cannot be installed, so it must not be +// offered. Asking first and failing afterwards spends the user's Yes on a +// question that never had an answer. +func TestMaybeRunPlugin_UnlistedNameIsNotOffered(t *testing.T) { //nolint:paralleltest // isolates PATH, index cache and terminal detection + withIsolatedPluginEnv(t) + withIndexCache(t) + indexURL, _ := newIndexRepo(t, `{"version":1,"plugins":[{"name":"other","repo_url":"https://example.invalid/entire-other"}]}`) + t.Setenv(pluginIndexEnvVar, indexURL) + t.Setenv("ENTIRE_TEST_TTY", "1") + t.Setenv("ACCESSIBLE", "1") + originalTerminal := openPluginPromptTerminal + openPluginPromptTerminal = func() (pluginPromptTerminal, error) { + t.Error("an unlisted plugin must not be offered for installation") + return pluginPromptTerminal{in: io.NopCloser(strings.NewReader("y\n"))}, nil + } + t.Cleanup(func() { openPluginPromptTerminal = originalTerminal }) + + root := newTestRoot() + var stderr bytes.Buffer + root.SetErr(&stderr) + handled, code, _ := MaybeRunPlugin(t.Context(), root, []string{"graph"}) + if !handled || code != 1 { + t.Fatalf("handled=%v code=%d, want true, 1; stderr=%s", handled, code, &stderr) + } + if !strings.Contains(stderr.String(), "not listed in the plugin index") { + t.Errorf("missing diagnosis: %q", stderr.String()) + } +} diff --git a/cmd/entire/cli/plugin_progress.go b/cmd/entire/cli/plugin_progress.go new file mode 100644 index 0000000000..9b4bb9a7fe --- /dev/null +++ b/cmd/entire/cli/plugin_progress.go @@ -0,0 +1,36 @@ +package cli + +import ( + "context" + "fmt" + "io" + "sync" + + "github.com/entireio/cli/cmd/entire/cli/interactive" +) + +type pluginProgressKey struct{} + +// withPluginProgress opts the command into progress reporting. The context +// carries the writer through dependency installs without making library callers +// print to the process's terminal. +func withPluginProgress(ctx context.Context, out io.Writer) context.Context { + return context.WithValue(ctx, pluginProgressKey{}, out) +} + +// startPluginStep reports work before it starts. Stop the spinner before any +// prompt, warning or result is printed. The returned stop is idempotent so a +// deferred cleanup can also cover early returns. +func startPluginStep(ctx context.Context, message string) func() { + out, ok := ctx.Value(pluginProgressKey{}).(io.Writer) + if !ok { + return func() {} + } + if IsAccessibleMode() || !interactive.ShouldStyle(out) { + fmt.Fprintln(out, message) + return func() {} + } + stop := startSpinner(out, message) + var once sync.Once + return func() { once.Do(func() { stop(false) }) } +} diff --git a/cmd/entire/cli/plugin_progress_test.go b/cmd/entire/cli/plugin_progress_test.go new file mode 100644 index 0000000000..55d63498b6 --- /dev/null +++ b/cmd/entire/cli/plugin_progress_test.go @@ -0,0 +1,127 @@ +package cli + +import ( + "bytes" + "fmt" + "strings" + "testing" +) + +func TestPluginStepPlainOutputIsImmediate(t *testing.T) { + t.Parallel() + var out bytes.Buffer + stop := startPluginStep(withPluginProgress(t.Context(), &out), "Downloading plugin archive...") + if got := out.String(); got != "Downloading plugin archive...\n" { + t.Fatalf("status must be visible before work completes, got %q", got) + } + stop() + stop() + if strings.Count(out.String(), "Downloading") != 1 || strings.Contains(out.String(), "\x1b") { + t.Fatalf("plain progress duplicated output or wrote terminal escapes: %q", out.String()) + } +} + +func TestPluginInstallReportsStagesOnStderr(t *testing.T) { //nolint:paralleltest // isolates managed plugins and index cache + withIsolatedPluginEnv(t) + withIndexCache(t) + repoURL, _ := newDemoPluginRepo(t, []string{remoteTestTagOld}, "0.1.0") + indexURL, _ := newIndexRepo(t, fmt.Sprintf(`{"version":1,"plugins":[{"name":"demo","repo_url":%q}]}`, repoURL)) + cmd := newPluginInstallCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + err := runRemoteInstall(t.Context(), cmd, installSource{Kind: installFromIndex, Ref: "demo"}, remoteInstallFlags{index: indexURL}) + if err != nil { + t.Fatal(err) + } + stages := []string{ + "Checking plugin index...", + "Finding latest plugin release...", + "Fetching plugin metadata for v0.1.0...", + "Locating plugin release files...", + "Downloading plugin archive...", + "Installing entire-demo v0.1.0...", + } + if got, want := errOut.String(), strings.Join(stages, "\n")+"\n"; got != want { + t.Fatalf("install progress:\ngot %q\nwant %q", got, want) + } + if !strings.HasPrefix(out.String(), `Installed plugin "demo" v0.1.0 from `) || strings.Count(out.String(), "\n") != 1 { + t.Fatalf("stdout should contain only the install result: %q", out.String()) + } +} + +// Progress travels on the context, so a command that does the network work +// without opting in reports nothing. `plugin upgrade` list tags, fetches +// metadata, downloads and places a binary exactly as `plugin install` does, +// and the stages were silent there until the command opted in. +func TestPluginUpgradeReportsStagesOnStderr(t *testing.T) { //nolint:paralleltest // isolates managed plugins and index cache + withIsolatedPluginEnv(t) + withIndexCache(t) + repoURL, _ := newDemoPluginRepo(t, []string{remoteTestTagOld}, "0.1.0") + if _, err := InstallPluginFromRepo(t.Context(), repoURL, "", RemoteInstallOptions{}); err != nil { + t.Fatalf("InstallPluginFromRepo: %v", err) + } + srv := pluginReleaseServer(t, "0.1.0", "0.2.0") + updateRepoMetadata(t, repoURL, fmt.Sprintf("name: demo\ndownload_url: \"%s/dl/{tag}/{asset}\"\n", srv.URL)) + gitTag(t, repoURL, remoteTestTagMid) + + cmd := newPluginUpgradeCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + cmd.SetContext(t.Context()) + cmd.SetArgs([]string{"demo"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + for _, stage := range []string{ + "Finding latest plugin release...", + "Fetching plugin metadata for " + remoteTestTagMid + "...", + "Downloading plugin archive...", + "Installing entire-demo " + remoteTestTagMid + "...", + } { + if !strings.Contains(errOut.String(), stage) { + t.Errorf("missing %q in upgrade progress: %q", stage, errOut.String()) + } + } + if !strings.Contains(out.String(), remoteTestTagOld+" → "+remoteTestTagMid) { + t.Errorf("stdout should carry the upgrade result: %q", out.String()) + } +} + +// installSource.Resolved binds an install to the entry its caller already +// showed the user. Without it runRemoteInstall reads the index a second time, +// and the two reads can disagree — a failed refresh leaves the freshness +// marker untouched so the next call retries and may succeed with different +// content, and a concurrent forced update rewrites the clone either way — +// letting the prompt name repository A while repository B is installed. +// +// The index here does not list "demo" at all, so a re-resolution cannot +// silently substitute: it fails outright, which is what makes the assertion +// unambiguous. +func TestRunRemoteInstall_ResolvedEntryIsNotReResolved(t *testing.T) { //nolint:paralleltest // isolates managed plugins and index cache + withIsolatedPluginEnv(t) + withIndexCache(t) + repoURL, _ := newDemoPluginRepo(t, []string{remoteTestTagOld}, "0.1.0") + indexURL, _ := newIndexRepo(t, `{"version":1,"plugins":[{"name":"somethingelse","repo_url":"https://example.invalid/entire-somethingelse"}]}`) + + cmd := newPluginInstallCmd() + var out, errOut bytes.Buffer + cmd.SetOut(&out) + cmd.SetErr(&errOut) + src := installSource{ + Kind: installFromIndex, + Ref: "demo", + Resolved: &PluginIndexEntry{Name: "demo", RepoURL: repoURL}, + } + if err := runRemoteInstall(t.Context(), cmd, src, remoteInstallFlags{index: indexURL}); err != nil { + t.Fatalf("runRemoteInstall: %v", err) + } + if !strings.Contains(out.String(), `Installed plugin "demo" `+remoteTestTagOld+" from "+repoURL) { + t.Errorf("installed something other than the resolved repository: %q", out.String()) + } + installed, err := FindInstalledPlugin("demo") + if err != nil || installed == nil { + t.Fatalf("FindInstalledPlugin: %v %v", installed, err) + } +} diff --git a/cmd/entire/cli/plugin_signal_unix.go b/cmd/entire/cli/plugin_signal_unix.go new file mode 100644 index 0000000000..3b36ff3cad --- /dev/null +++ b/cmd/entire/cli/plugin_signal_unix.go @@ -0,0 +1,33 @@ +//go:build !windows + +package cli + +import ( + "os" + "syscall" +) + +// pluginTerminatingSignal reports the signal a plugin was killed by, or nil +// when it exited normally. +// +// Build-tagged rather than switched on runtime.GOOS because the API is absent +// on Windows, not merely inapplicable: syscall.WaitStatus there is a bare +// struct with an ExitCode field and no Signaled/Signal methods, so a +// runtime.GOOS branch would not compile. +// +// A signal is not something the parent necessarily saw. Ctrl-C reaches the +// whole foreground process group, but `kill -TERM` aimed at the plugin, and a +// SIGPIPE from `entire graph | head -1`, reach the child alone — and +// kubectl-style dispatch has to propagate the external command's outcome +// either way, so the signal has to come off the child's wait status rather +// than out of what this process was told. +func pluginTerminatingSignal(state *os.ProcessState) os.Signal { + if state == nil { + return nil + } + ws, ok := state.Sys().(syscall.WaitStatus) + if !ok || !ws.Signaled() { + return nil + } + return ws.Signal() +} diff --git a/cmd/entire/cli/plugin_signal_windows.go b/cmd/entire/cli/plugin_signal_windows.go new file mode 100644 index 0000000000..c0a2936490 --- /dev/null +++ b/cmd/entire/cli/plugin_signal_windows.go @@ -0,0 +1,14 @@ +//go:build windows + +package cli + +import "os" + +// pluginTerminatingSignal always reports nil on Windows: there are no signals +// to propagate. A process ended by TerminateProcess reports an ordinary exit +// code, which ProcessState.ExitCode returns as-is, so a killed child never +// reaches the ExitPluginSignalled path here in the first place. +// +// See plugin_signal_unix.go for why this is a build-tagged pair rather than a +// runtime.GOOS branch. +func pluginTerminatingSignal(*os.ProcessState) os.Signal { return nil } diff --git a/cmd/entire/cli/plugin_test.go b/cmd/entire/cli/plugin_test.go index 05bae3b165..95c218c341 100644 --- a/cmd/entire/cli/plugin_test.go +++ b/cmd/entire/cli/plugin_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "strings" + "syscall" "testing" "github.com/spf13/cobra" @@ -210,7 +211,7 @@ func TestMaybeRunPlugin_RelativePathEntry_ErrDotNotBypassed(t *testing.T) { //no // exec.LookPath call inside resolvePlugin will observe. t.Setenv("PATH", "bin"+string(os.PathListSeparator)+origPath) - handled, _ := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"errdotcheck"}) + handled, _, _ := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"errdotcheck"}) if handled { t.Fatal("a plugin reachable only via a RELATIVE PATH entry must not resolve/execute " + "(this is the exec.ErrDot bypass / RCE the fix prevents)") @@ -225,10 +226,13 @@ func TestRunPlugin_ExitCodePropagation(t *testing.T) { dir := t.TempDir() binPath := writePluginBinary(t, dir, "entire-exit42", filepath.Join(dir, "args.txt"), 42) - code := runPlugin(context.Background(), "exit42", binPath, []string{"a", "b"}) + code, killedBy := runPlugin(context.Background(), "exit42", binPath, []string{"a", "b"}) if code != 42 { t.Errorf("exit code: got %d, want 42", code) } + if killedBy != nil { + t.Errorf("ordinary exit reported signal %v", killedBy) + } contents, err := os.ReadFile(filepath.Join(dir, "args.txt")) if err != nil { t.Fatalf("read argfile: %v", err) @@ -255,7 +259,7 @@ func TestMaybeRunPlugin_VersionCheckAfterSuccess(t *testing.T) { //nolint:parall withPathDir(t, dir) calls := interceptVersionCheck(t) - handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"}) + handled, code, _ := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"}) if !handled || code != 0 { t.Fatalf("handled=%v code=%d, want handled=true code=0", handled, code) } @@ -273,7 +277,7 @@ func TestMaybeRunPlugin_NoVersionCheckAfterSelfUpdate(t *testing.T) { //nolint:p withPathDir(t, dir) calls := interceptVersionCheck(t) - handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"upgrade", "--nightly"}) + handled, code, _ := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"upgrade", "--nightly"}) if !handled || code != 0 { t.Fatalf("handled=%v code=%d, want handled=true code=0", handled, code) } @@ -288,7 +292,7 @@ func TestMaybeRunPlugin_NoVersionCheckAfterFailure(t *testing.T) { //nolint:para withPathDir(t, dir) calls := interceptVersionCheck(t) - handled, code := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"}) + handled, code, _ := MaybeRunPlugin(context.Background(), newTestRoot(), []string{"pgr"}) if !handled || code != 3 { t.Fatalf("handled=%v code=%d, want handled=true code=3", handled, code) } @@ -328,3 +332,64 @@ func TestFindInaccessiblePlugin_SkipsRelativePATHEntry(t *testing.T) { t.Errorf("findInaccessiblePlugin found %q via a relative $PATH entry, want no match", got) } } + +// A signal that reaches only the plugin still has to propagate. Ctrl-C hits +// the whole foreground process group, so the parent sees it too — but a +// `kill -TERM` aimed at the plugin and a SIGPIPE from a closed pipe do not, +// and reporting those as a plain exit-1 loses the signal that kubectl-style +// dispatch exists to pass through. +func TestRunPlugin_ReportsTheChildsOwnSignal(t *testing.T) { + t.Parallel() + if runtime.GOOS == windowsGOOS { + t.Skip("no signals to propagate on Windows") + } + for _, tc := range []struct { + name string + script string + want syscall.Signal + }{ + {name: "self-terminated", script: "kill -TERM $$", want: syscall.SIGTERM}, + {name: "self-interrupted", script: "kill -INT $$", want: syscall.SIGINT}, + {name: "broken pipe", script: "kill -PIPE $$", want: syscall.SIGPIPE}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dir := t.TempDir() + binPath := filepath.Join(dir, "entire-signaller") + script := "#!/bin/sh\ntrap - TERM INT PIPE\n" + tc.script + "\n" + if err := os.WriteFile(binPath, []byte(script), 0o755); err != nil { + t.Fatal(err) + } + code, killedBy := runPlugin(t.Context(), "signaller", binPath, nil) + if code != ExitPluginSignalled { + t.Fatalf("exit code=%d, want ExitPluginSignalled", code) + } + if killedBy != tc.want { + t.Errorf("killedBy=%v, want %v — the signal reached only the child, so it has to come off the wait status", killedBy, tc.want) + } + }) + } +} + +// The signal has to survive the trip out of the dispatcher too: MaybeRunPlugin +// is what main.go sees, and it is main.go that re-raises. +func TestMaybeRunPlugin_PropagatesTheChildsSignal(t *testing.T) { //nolint:paralleltest // mutates PATH + if runtime.GOOS == windowsGOOS { + t.Skip("no signals to propagate on Windows") + } + dir := t.TempDir() + binPath := filepath.Join(dir, "entire-signaller") + if err := os.WriteFile(binPath, []byte("#!/bin/sh\ntrap - TERM\nkill -TERM $$\n"), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + interceptVersionCheck(t) + + handled, code, killedBy := MaybeRunPlugin(t.Context(), newTestRoot(), []string{"signaller"}) + if !handled || code != ExitPluginSignalled { + t.Fatalf("handled=%v code=%d, want true, ExitPluginSignalled", handled, code) + } + if killedBy != syscall.SIGTERM { + t.Errorf("killedBy=%v, want SIGTERM; main.go re-raises this, so losing it here exits 1 instead of 143", killedBy) + } +} diff --git a/cmd/entire/cli/project.go b/cmd/entire/cli/project.go index 5743ce5b9d..b3bb5d95ce 100644 --- a/cmd/entire/cli/project.go +++ b/cmd/entire/cli/project.go @@ -25,7 +25,7 @@ func newProjectCmd() *cobra.Command { } // projectColumns is the human table/field view of a project. -var projectColumns = []string{"ID", "NAME", "OWNER-TYPE", "OWNER", "REGION"} +var projectColumns = []string{"ID", colHeaderName, "OWNER-TYPE", "OWNER", colHeaderRegion} func projectRow(p coreapi.Project) []string { return []string{p.ID, p.Name, string(p.OwnerType), p.OwnerId, p.Region} @@ -38,7 +38,7 @@ func newProjectCreateCmd() *cobra.Command { region string ) cmd := &cobra.Command{ - Use: "create ", + Use: cmdCreateName, Short: "Create a project under an org or account", Long: "Creates a project owned by an org or an account. --owner is the " + "owning org (name or ULID) or account (github:handle or ULID), and " + @@ -94,7 +94,7 @@ func newProjectCreateCmd() *cobra.Command { func newProjectListCmd() *cobra.Command { var name, org string cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List projects you can see", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { @@ -198,14 +198,20 @@ func newProjectDeleteCmd() *cobra.Command { return cmd } +// The two owner kinds a project may have, as the --owner-type flag spells them. +const ( + ownerTypeOrg = "org" + ownerTypeAccount = "account" +) + // parseProjectOwnerType maps the --owner-type flag to the generated enum, // rejecting anything but org/account at the CLI boundary so the user gets // a clear message instead of a server 422. func parseProjectOwnerType(s string) (coreapi.CreateProjectInputBodyOwnerType, error) { switch s { - case "org": + case ownerTypeOrg: return coreapi.CreateProjectInputBodyOwnerTypeOrg, nil - case "account": + case ownerTypeAccount: return coreapi.CreateProjectInputBodyOwnerTypeAccount, nil default: // Plain error: the create RunE sets SilenceUsage, and main.go diff --git a/cmd/entire/cli/recap.go b/cmd/entire/cli/recap.go index 7b3d644c1f..9423f19bb0 100644 --- a/cmd/entire/cli/recap.go +++ b/cmd/entire/cli/recap.go @@ -258,7 +258,7 @@ func terminalWidth(w io.Writer) int { if !isatty.IsTerminal(file.Fd()) { return recap.DefaultWidth } - width, _, err := term.GetSize(int(file.Fd())) //nolint:gosec // fd values fit in int on supported platforms + width, _, err := term.GetSize(int(file.Fd())) if err != nil || width <= 0 { return recap.DefaultWidth } diff --git a/cmd/entire/cli/recap_tui.go b/cmd/entire/cli/recap_tui.go index 977b52434f..e8ad57bf9f 100644 --- a/cmd/entire/cli/recap_tui.go +++ b/cmd/entire/cli/recap_tui.go @@ -261,6 +261,14 @@ func (m recapTUIModel) withViewport() recapTUIModel { return m } +// Footer key-hint labels, repeated across the progressively shorter footer +// variants renderFooter picks between. +const ( + recapHintView = "view" + recapHintAgent = "agent" + recapHintQuit = "quit" +) + func (m recapTUIModel) renderFooter() string { choices := []string{ recapFooterLine(m.color, []recapHelpItem{ @@ -268,24 +276,24 @@ func (m recapTUIModel) renderFooter() string { {"w", "week"}, {"m", "month"}, {"r", "90d"}, - {"v", "view"}, - {"a", "agent"}, + {"v", recapHintView}, + {"a", recapHintAgent}, {"R", "reload"}, - {"q", "quit"}, + {"q", recapHintQuit}, }), recapFooterLine(m.color, []recapHelpItem{ {"d/w/m/r", "range"}, - {"v", "view"}, - {"a", "agent"}, - {"q", "quit"}, + {"v", recapHintView}, + {"a", recapHintAgent}, + {"q", recapHintQuit}, }), recapFooterLine(m.color, []recapHelpItem{ {"d/w/m/r", "range"}, - {"v", "view"}, - {"q", "quit"}, + {"v", recapHintView}, + {"q", recapHintQuit}, }), recapFooterLine(m.color, []recapHelpItem{ - {"q", "quit"}, + {"q", recapHintQuit}, }), } for _, choice := range choices { diff --git a/cmd/entire/cli/remote_topology.go b/cmd/entire/cli/remote_topology.go index 59f729c6e3..4a857cf5a0 100644 --- a/cmd/entire/cli/remote_topology.go +++ b/cmd/entire/cli/remote_topology.go @@ -51,6 +51,12 @@ type remoteTopology struct { // primaryIsRefs reports whether the git-refs backend is active, which // decides what a fanning-out remote means for checkpoints. primaryIsRefs bool + // pushDisabled reports that push_sessions is off, which makes every claim + // below about where a push delivers checkpoints conditional: nothing is + // pushed at all. Read as a caveat on the note, not a reason to suppress + // it — the ambiguity is still what re-enabling pushing would run into, + // and still what a user pins with checkpoint_remote. + pushDisabled bool } // inspectRemoteTopology reads the repo's remotes and checkpoint configuration. @@ -92,6 +98,11 @@ func inspectRemoteTopology(ctx context.Context) remoteTopology { if cpCfg, err := settings.LoadCheckpointsConfig(ctx); err == nil { t.primaryIsRefs = checkpoint.PrimaryIsRefs(cpCfg) } + // Best-effort like everything else here: an unreadable settings file reads + // as "pushing enabled", which is the note this code has always printed. + if s, err := settings.Load(ctx); err == nil { + t.pushDisabled = s.IsPushSessionsDisabled() + } return t } @@ -144,6 +155,27 @@ func (t remoteTopology) describeCheckpointDestination(w io.Writer, header string fmt.Fprintln(w, header) + // Said first, because it qualifies every "pushes to" below. The note is + // still worth printing: the ambiguity it describes is what re-enabling + // pushing would run into, and it is what checkpoint_remote pins. + // + // Deliberately silent about where checkpoints are READ from, even though + // that is the live question when nothing is pushed: what this note lists + // is PUSH URLs, and a fan-out remote's reads use its fetch URL instead + // (the git-branch branch below says as much — "only the fetch URL is ever + // reconciled"). + // + // It points at `entire status` for that rather than answering it, and + // without promising an answer: status names a read source where it can + // establish one, and says so when it cannot — a failed election with a + // configured checkpoint_remote resolves to neither. + if t.pushDisabled { + fmt.Fprintln(w, " Automatic checkpoint pushing is disabled (push_sessions=false), so no") + fmt.Fprintln(w, " checkpoints are pushed anywhere right now. The destination below is where") + fmt.Fprintln(w, " they would go if you re-enabled it; for where they are read from today,") + fmt.Fprintln(w, " see `entire status`.") + } + for _, d := range t.destinations { if !d.fansOut() { continue @@ -170,8 +202,8 @@ func (t remoteTopology) describeCheckpointDestination(w io.Writer, header string fmt.Fprintf(w, " This repo has %d remotes (%s).\n", len(names), strings.Join(names, ", ")) fmt.Fprintln(w, " Checkpoints sync to a single elected remote — not to whichever one you") fmt.Fprintln(w, " push to. A push to any other remote carries your code but no session") - fmt.Fprintln(w, " history. Run `entire status` to see the elected destination and how many") - fmt.Fprintln(w, " checkpoints are waiting for it.") + fmt.Fprintln(w, " history. Run `entire status` to see the elected destination and how much") + fmt.Fprintln(w, " checkpoint data has not reached it.") } fmt.Fprintln(w, " To pin one repository for checkpoints, set checkpoint_remote in") diff --git a/cmd/entire/cli/remote_topology_test.go b/cmd/entire/cli/remote_topology_test.go new file mode 100644 index 0000000000..5a31fcc33e --- /dev/null +++ b/cmd/entire/cli/remote_topology_test.go @@ -0,0 +1,65 @@ +package cli + +import ( + "strings" + "testing" +) + +// describeCheckpointDestination writes to an io.Writer from a plain struct, so +// the disabled-pushing caveat needs no repo, no remotes and no CLI run. +// +// What it must not do is present the URLs it lists as the read source: they +// are PUSH urls, and a fan-out remote's reads use its fetch url. It points at +// `entire status` for that instead, and does not promise status will always +// have an answer. +func TestDescribeCheckpointDestination_PushDisabledCaveat(t *testing.T) { + t.Parallel() + topology := remoteTopology{ + destinations: []remoteDestination{ + {name: "backup", pushURLs: []string{"https://github.com/org/backup.git"}}, + {name: "origin", pushURLs: []string{"https://github.com/org/repo.git"}}, + }, + } + for _, tc := range []struct { + name string + pushDisabled bool + want, unwanted []string + }{ + { + name: "pushing enabled", + want: []string{"2 remotes", "single elected remote"}, + unwanted: []string{"push_sessions=false"}, + }, + { + name: "pushing disabled", + pushDisabled: true, + want: []string{ + // The note still renders its body: the ambiguity is what + // re-enabling pushing would run into. + "single elected remote", + "Automatic checkpoint pushing is disabled (push_sessions=false)", + "they would go if you re-enabled it", + }, + // "waiting for it" implied a pending push that cannot happen. + unwanted: []string{"checkpoints are waiting for it"}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + topology := topology + topology.pushDisabled = tc.pushDisabled + var b strings.Builder + topology.describeCheckpointDestination(&b, "Checkpoint destination: REVIEW") + for _, want := range tc.want { + if !strings.Contains(b.String(), want) { + t.Errorf("missing %q:\n%s", want, b.String()) + } + } + for _, unwanted := range tc.unwanted { + if strings.Contains(b.String(), unwanted) { + t.Errorf("must not mention %q:\n%s", unwanted, b.String()) + } + } + }) + } +} diff --git a/cmd/entire/cli/repo.go b/cmd/entire/cli/repo.go index 2ba5a46fb4..bdf20f8637 100644 --- a/cmd/entire/cli/repo.go +++ b/cmd/entire/cli/repo.go @@ -15,12 +15,12 @@ import ( // newRepoCmd is the `entire repo` command group: control-plane // repository lifecycle (create, list within a project, get, delete), the -// `mirror` and `visibility` subtrees, plus the `clone` convenience that -// resolves a mirror and shells out to `git clone`. Other git content -// operations (log, diff, …) remain intentionally out of scope here. +// `mirror`, `visibility` and `protection` subtrees, plus the `clone` +// convenience that resolves a mirror and shells out to `git clone`. Other git +// content operations (log, diff, …) remain intentionally out of scope here. func newRepoCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "repo", + Use: cmdRepo, Short: "Manage Entire repositories", } addControlPlaneFlags(cmd) @@ -31,12 +31,13 @@ func newRepoCmd() *cobra.Command { cmd.AddCommand(newRepoCloneCmd()) cmd.AddCommand(newRepoMirrorCmd()) cmd.AddCommand(newRepoVisibilityCmd()) + cmd.AddCommand(newRepoProtectionCmd()) return cmd } // repoColumns is the human table/field view of a repo, shared by list and // get. CLUSTER/STATE come from optional fields, shown as "-" when unset. -var repoColumns = []string{"ID", "NAME", "PROJECT", "CLUSTER", "STATE"} +var repoColumns = []string{"ID", colHeaderName, "PROJECT", colHeaderCluster, "STATE"} func repoRow(r coreapi.Repo) []string { return []string{r.ID, r.Name, r.OwningProjectId, r.ClusterHost.Or("-"), r.State.Or("-")} @@ -128,7 +129,7 @@ func newRepoCreateCmd() *cobra.Command { objectFormat string ) cmd := &cobra.Command{ - Use: "create ", + Use: cmdCreateName, Short: "Create a repository in a project", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/cmd/entire/cli/repo_mirror.go b/cmd/entire/cli/repo_mirror.go index 3d87acc185..373c88ea68 100644 --- a/cmd/entire/cli/repo_mirror.go +++ b/cmd/entire/cli/repo_mirror.go @@ -35,11 +35,11 @@ type column struct { // so a --sort value needs no quoting; headers stay upper-case display text. var ( colName = column{key: "name", header: "NAME (owner/repo)"} - colCloneURL = column{key: "clone-url", header: "CLONE URL"} + colCloneURL = column{key: "clone-url", header: colHeaderCloneURL} colClusters = column{key: "clusters", header: "CLUSTERS"} colVisibility = column{key: "visibility", header: "VISIBILITY"} colAccess = column{key: "access", header: "ACCESS"} - colStatus = column{key: "status", header: "STATUS"} + colStatus = column{key: "status", header: colHeaderStatus} ) // columnHeaders is the display-header view of a column set, for the table/field @@ -923,7 +923,7 @@ func newRepoMirrorListCmd() *cobra.Command { var pageToken string var noPager, all bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List repos you can see: existing mirrors and GitHub repos you could onboard", Long: "List repos visible from your login in one table: existing mirrors " + "(one row per repo, with the clusters it is mirrored on and the clone " + @@ -1140,7 +1140,7 @@ func renderRepoDetail(w io.Writer, row repoDirRow) { return } - headers := styledHeaders(st, []string{"CLUSTER", "CLONE URL", "STATUS"}) + headers := styledHeaders(st, []string{colHeaderCluster, colHeaderCloneURL, colHeaderStatus}) rows := make([][]string, len(row.Placements)) for i, p := range row.Placements { cluster, status := p.Cluster, p.Status diff --git a/cmd/entire/cli/repo_mirror_collaborators.go b/cmd/entire/cli/repo_mirror_collaborators.go index 5f9b47a519..7eb7d409cf 100644 --- a/cmd/entire/cli/repo_mirror_collaborators.go +++ b/cmd/entire/cli/repo_mirror_collaborators.go @@ -13,7 +13,7 @@ import ( // collaborator: the display handle, the reader/writer role, and the Entire // account ULID (the stable identifier, shown last as the fallback when no // handle resolves). -var mirrorCollaboratorColumns = []string{"HANDLE", "ROLE", "ACCOUNT"} +var mirrorCollaboratorColumns = []string{"HANDLE", colHeaderRole, "ACCOUNT"} func mirrorCollaboratorRow(c coreapi.MirrorCollaborator) []string { handle := c.Handle.Or("") diff --git a/cmd/entire/cli/repo_mirror_create_wizard.go b/cmd/entire/cli/repo_mirror_create_wizard.go index 1a9061bb5f..6532e2e9e3 100644 --- a/cmd/entire/cli/repo_mirror_create_wizard.go +++ b/cmd/entire/cli/repo_mirror_create_wizard.go @@ -292,7 +292,7 @@ type mirrorResult struct { err error } -var mirrorCreateResultColumns = []string{"REPO", "REGION", "STATUS", "CLONE URL"} +var mirrorCreateResultColumns = []string{"REPO", colHeaderRegion, colHeaderStatus, colHeaderCloneURL} func mirrorCreateResultRow(r mirrorResult) []string { url := r.cloneURL diff --git a/cmd/entire/cli/repo_protection.go b/cmd/entire/cli/repo_protection.go new file mode 100644 index 0000000000..814f7c96f6 --- /dev/null +++ b/cmd/entire/cli/repo_protection.go @@ -0,0 +1,272 @@ +package cli + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/entireio/cli/internal/coreapi" +) + +// branchRule is the JSON/table view of one branch-protection rule: the +// pattern as the server stores it and its level. +type branchRule struct { + Ref string `json:"ref"` + ServerSideMergeOnly bool `json:"serverSideMergeOnly"` +} + +var protectionColumns = []string{"BRANCH", "LEVEL"} + +const ( + protectionLevelProtected = "protected" + protectionLevelMergeOnly = "server-side merge only" + // protectionEmpty asserts that nothing protects this repository, so it + // is printed only for a provider positively known to be Entire-native. + protectionEmpty = "Nothing is protected yet." + protectionMirrorNote = "GitHub mirror: branch protection is governed by the upstream repository. " + + "Its default branch is always protected on Entire; no rules can be added here." + // protectionUnknownNote covers every empty list whose provider was not + // established: absent (an older core), a value this build does not know + // (a forge added later), or a lookup that failed. Each of those is + // "we could not find out", which is not the same as "nothing is + // protected" — see reportNoProtectionRules. + protectionUnknownNote = "No branch-protection rules are set here on Entire. This repository's " + + "provider could not be determined, so whether protection is governed elsewhere is unknown; " + + "a mirror's rules are its upstream's." + // headBranchPattern is the server's pattern for "whatever branch HEAD + // points at"; it follows a default-branch rename. + headBranchPattern = "HEAD" + serverSideMergeOnlyFlag = "server-side-merge-only" +) + +func protectionRow(r branchRule) []string { + level := protectionLevelProtected + if r.ServerSideMergeOnly { + level = protectionLevelMergeOnly + } + return []string{r.Ref, level} +} + +func branchRulesFromWire(p *coreapi.BranchProtection) []branchRule { + rules := make([]branchRule, 0, len(p.Rules)) + for _, r := range p.Rules { + rules = append(rules, branchRule{Ref: r.Ref, ServerSideMergeOnly: r.ServerSideMergeOnly.Or(false)}) + } + return rules +} + +// expandBranchRef maps the CLI argument onto the server's pattern syntax: +// "HEAD" and anything under refs/ pass through, a short name is a branch +// under refs/heads/. Wildcards are left to the server to validate. +func expandBranchRef(s string) (string, error) { + switch { + case s == "": + return "", errors.New("branch must not be empty") + case s == headBranchPattern, strings.HasPrefix(s, "refs/"): + return s, nil + default: + return "refs/heads/" + s, nil + } +} + +// newRepoProtectionCmd groups the verbs for a repository's branch protection. +// Each rule names a branch pattern and one of two levels. "protected" refuses +// force pushes and deletion; fast-forward pushes stay allowed. "server-side +// merge only" also refuses every direct push: the branch moves only through a +// merge Entire performs, such as a trail merge, and whether that merge runs is +// decided by the repository's gates. +func newRepoProtectionCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "protection", + Short: "List, add, or remove branch-protection rules", + Long: "List, add, or remove a repository's branch-protection rules.\n\n" + + "Each rule names a branch pattern and a level. \"protected\" refuses force pushes " + + "and deletion; fast-forward pushes stay allowed. \"server-side merge only\" also " + + "refuses every direct push: the branch moves only through a merge Entire performs, " + + "such as a trail merge. A pattern is \"HEAD\" (the default branch), a branch name, " + + "or a branch pattern with * and ? wildcards such as release/*. Entire-native " + + "repositories only; a GitHub mirror's protection is the upstream's.", + } + cmd.AddCommand(newRepoProtectionListCmd()) + cmd.AddCommand(newRepoProtectionAddCmd()) + cmd.AddCommand(newRepoProtectionRemoveCmd()) + return cmd +} + +func newRepoProtectionListCmd() *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "list ", + Short: "Show a repository's branch-protection rules", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + return runCore(cmd, func(ctx context.Context, c *coreapi.Client) error { + repoID, err := resolveRepoRef(ctx, c, args[0], project) + if err != nil { + return err + } + out, err := c.GetBranchProtection(ctx, coreapi.GetBranchProtectionParams{RepoId: repoID}) + if err != nil { + return err + } + rules := branchRulesFromWire(out) + if len(rules) > 0 { + // Only the empty list needs the repo's provider, so the + // common case costs one round trip rather than two. + if jsonRequested(cmd) { + return printJSON(cmd.OutOrStdout(), rules) + } + return printTable(cmd.OutOrStdout(), protectionColumns, rules, protectionRow) + } + return reportNoProtectionRules(ctx, cmd, c, repoID) + }) + }, + } + bindRepoProjectFlag(cmd, &project) + addJSONFlag(cmd) + return cmd +} + +// reportNoProtectionRules renders an empty rule list, naming which kind of +// empty it is. A GitHub mirror always reads as empty — its rules are the +// upstream's, and the data plane protects its default branch regardless — so +// "nothing is protected" would misstate it. +// +// All three outcomes are identified positively, and "native" is not the else +// of "mirror". `provider` is optional (an older core omits it) and open +// (`readModelEnumFields` drops its enum, so a forge added later decodes +// verbatim), and in JSON mode the lookup may fail outright. Every one of +// those is "we could not find out", which is not evidence that nothing is +// protected — treating it as such is how an unqualified "Nothing is +// protected yet." would come to hide a mirror's upstream rules. Only +// repoProviderEntire earns that sentence; everything else gets +// protectionUnknownNote. +// +// The caveat reaches --json callers too, on stderr: stdout stays the bare +// array a script parses, but a script concluding "no rules ⇒ nothing is +// protected" is wrong on a mirror, which is the misreading the note exists +// to prevent. That makes the note advisory for --json and load-bearing for +// the human rendering, so a failed provider lookup is fatal only to the +// latter — the branch-protection answer is already in hand, and a script +// must not lose its array because a secondary lookup flaked. It is still +// told, with the reason, rather than handed a silent []. +func reportNoProtectionRules(ctx context.Context, cmd *cobra.Command, c *coreapi.Client, repoID string) error { + note := protectionUnknownNote + repo, err := c.GetRepo(ctx, coreapi.GetRepoParams{RepoId: repoID}) + switch { + case err != nil: + if !jsonRequested(cmd) { + return err + } + note = fmt.Sprintf("%s (looking it up failed: %v)", protectionUnknownNote, err) + case repo.Provider.Or("") == repoProviderGitHub: + note = protectionMirrorNote + case repo.Provider.Or("") == repoProviderEntire: + note = "" // the one case that positively establishes "native". + } + if jsonRequested(cmd) { + if note != "" { + fmt.Fprintln(cmd.ErrOrStderr(), note) + } + return printJSON(cmd.OutOrStdout(), []branchRule{}) + } + if note == "" { + note = protectionEmpty + } + fmt.Fprintln(cmd.OutOrStdout(), note) + return nil +} + +func newRepoProtectionAddCmd() *cobra.Command { + var project string + var mergeOnly bool + cmd := &cobra.Command{ + Use: "add ", + Short: "Protect a branch, or change the level of an existing rule", + Long: "Protect a branch, or change the level of an existing rule.\n\n" + + " is \"HEAD\", a branch name such as main, or a pattern such as release/*. " + + "A new rule protects the branch from force pushes and deletion. With " + + "--server-side-merge-only, every direct push is refused and the branch moves only " + + "through a merge Entire performs. Re-adding a branch without the flag keeps its " + + "current level; pass --server-side-merge-only=false to lower it. Prints the " + + "resulting rules. Requires manage permission on the repo.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := expandBranchRef(args[1]) + if err != nil { + cmd.SilenceUsage = true + return err + } + // protectionEmpty asserts the repo is native, which list has to + // establish but this verb gets for free: add and remove share one + // PATCH, the server refuses it on a mirror, so reaching the render + // at all means the write landed on a repo that accepts rules. + return runCoreList(cmd, protectionEmpty, protectionColumns, protectionRow, func(ctx context.Context, c *coreapi.Client) ([]branchRule, error) { + repoID, err := resolveRepoRef(ctx, c, args[0], project) + if err != nil { + return nil, err + } + // The level travels only when the flag was given. Absent, the + // server keeps an existing rule's level and protects a new + // branch, so an add that only names a branch can never lower + // it; --server-side-merge-only=false is the explicit way down. + rule := coreapi.BranchRule{Ref: ref} + if cmd.Flags().Changed(serverSideMergeOnlyFlag) { + rule.ServerSideMergeOnly = coreapi.NewOptBool(mergeOnly) + } + body := &coreapi.UpdateBranchProtectionInputBody{AddRules: []coreapi.BranchRule{rule}} + out, err := c.UpdateBranchProtection(ctx, body, coreapi.UpdateBranchProtectionParams{RepoId: repoID}) + if err != nil { + return nil, err + } + return branchRulesFromWire(out), nil + }) + }, + } + bindRepoProjectFlag(cmd, &project) + cmd.Flags().BoolVar(&mergeOnly, serverSideMergeOnlyFlag, false, "Refuse every direct push; the branch moves only through a merge Entire performs. Omit to keep an existing rule's level; =false lowers it") + addJSONFlag(cmd) + return cmd +} + +func newRepoProtectionRemoveCmd() *cobra.Command { + var project string + cmd := &cobra.Command{ + Use: "remove ", + Short: "Remove a branch-protection rule", + Long: "Remove a branch-protection rule.\n\n" + + " names the rule as it was added: \"HEAD\", a branch name, or a pattern. " + + "Removing a branch that has no rule changes nothing. Prints the resulting rules. " + + "Requires manage permission on the repo.", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ref, err := expandBranchRef(args[1]) + if err != nil { + cmd.SilenceUsage = true + return err + } + // protectionEmpty asserts the repo is native, which list has to + // establish but this verb gets for free: add and remove share one + // PATCH, the server refuses it on a mirror, so reaching the render + // at all means the write landed on a repo that accepts rules. + return runCoreList(cmd, protectionEmpty, protectionColumns, protectionRow, func(ctx context.Context, c *coreapi.Client) ([]branchRule, error) { + repoID, err := resolveRepoRef(ctx, c, args[0], project) + if err != nil { + return nil, err + } + body := &coreapi.UpdateBranchProtectionInputBody{RemoveRefs: []string{ref}} + out, err := c.UpdateBranchProtection(ctx, body, coreapi.UpdateBranchProtectionParams{RepoId: repoID}) + if err != nil { + return nil, err + } + return branchRulesFromWire(out), nil + }) + }, + } + bindRepoProjectFlag(cmd, &project) + addJSONFlag(cmd) + return cmd +} diff --git a/cmd/entire/cli/repo_protection_test.go b/cmd/entire/cli/repo_protection_test.go new file mode 100644 index 0000000000..454004ac70 --- /dev/null +++ b/cmd/entire/cli/repo_protection_test.go @@ -0,0 +1,385 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/entireio/cli/internal/coreapi" +) + +const testProtectionRepoULID = "01KS6KFJR2XS6PZ188MVYE07AN" + +func TestExpandBranchRef(t *testing.T) { + t.Parallel() + cases := map[string]string{ + "main": "refs/heads/main", + "release/*": "refs/heads/release/*", + "HEAD": "HEAD", + "refs/heads/main": "refs/heads/main", + "refs/heads/hotfix/": "refs/heads/hotfix/", + } + for in, want := range cases { + got, err := expandBranchRef(in) + require.NoError(t, err, in) + assert.Equal(t, want, got, in) + } + _, err := expandBranchRef("") + require.Error(t, err) +} + +func TestProtectionRow(t *testing.T) { + t.Parallel() + assert.Equal(t, []string{"refs/heads/main", protectionLevelMergeOnly}, protectionRow(branchRule{Ref: "refs/heads/main", ServerSideMergeOnly: true})) + assert.Equal(t, []string{"HEAD", protectionLevelProtected}, protectionRow(branchRule{Ref: "HEAD"})) +} + +// fakeProtectionServer stands in for core's branch-protection resource. It +// applies PATCH bodies with the server's upsert-by-ref rule so a test sees the +// same resulting list the real core would return. +type fakeProtectionServer struct { + mu sync.Mutex + provider string // the repo's provider as GET /repos/{id} reports it + rules []coreapi.BranchRule + patches []coreapi.UpdateBranchProtectionInputBody + // repoGets counts GET /repos/{id}. Only the empty-list rendering needs + // the provider, so every other path must leave this at zero — see + // TestRepoProtection_LooksUpTheRepoOnlyForAnEmptyList. + repoGets int + // repoGetFails makes GET /repos/{id} 500, so the empty rendering has to + // cope with never learning the provider at all. + repoGetFails bool +} + +func (f *fakeProtectionServer) handler(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/repos/"+testProtectionRepoULID { + f.mu.Lock() + f.repoGets++ + fails, provider := f.repoGetFails, f.provider + f.mu.Unlock() + if fails { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + repo := &coreapi.Repo{ + ID: testProtectionRepoULID, Name: "web", OwningProjectId: testProjectULID, + } + // An empty provider stands in for a core that predates the + // field: it must be absent from the body, not sent as "". + if provider != "" { + repo.Provider = coreapi.NewOptString(provider) + } + if err := printJSON(w, repo); err != nil { + t.Errorf("encode repo response: %v", err) + } + return + } + if r.URL.Path != "/api/v1/repos/"+testProtectionRepoULID+"/branch-protection" { + t.Errorf("unexpected request %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + return + } + f.mu.Lock() + defer f.mu.Unlock() + switch r.Method { + case http.MethodGet: + case http.MethodPatch: + raw, err := io.ReadAll(r.Body) + if err != nil { + t.Errorf("read patch body: %v", err) + w.WriteHeader(http.StatusInternalServerError) + return + } + var body coreapi.UpdateBranchProtectionInputBody + if err := json.Unmarshal(raw, &body); err != nil { + t.Errorf("decode patch body %s: %v", raw, err) + w.WriteHeader(http.StatusBadRequest) + return + } + f.patches = append(f.patches, body) + f.apply(body) + default: + t.Errorf("unexpected method %s", r.Method) + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + if err := printJSON(w, &coreapi.BranchProtection{Rules: f.rules}); err != nil { + t.Errorf("encode response: %v", err) + } + } +} + +func (f *fakeProtectionServer) apply(body coreapi.UpdateBranchProtectionInputBody) { + var next []coreapi.BranchRule + for _, r := range f.rules { + removed := false + for _, ref := range body.RemoveRefs { + if ref == r.Ref { + removed = true + } + } + if removed { + continue + } + // Like the server: an entry without a level keeps the rule's. + for _, a := range body.AddRules { + if a.Ref == r.Ref && a.ServerSideMergeOnly.IsSet() { + r.ServerSideMergeOnly = a.ServerSideMergeOnly + } + } + next = append(next, r) + } + for _, a := range body.AddRules { + present := false + for _, r := range next { + if r.Ref == a.Ref { + present = true + } + } + if !present { + next = append(next, a) + } + } + f.rules = next +} + +// newProtectionFixture installs the fake as the active core client. +// Not parallel: swaps the package-level activeCoreClient seam. +func newProtectionFixture(t *testing.T, rules ...coreapi.BranchRule) *fakeProtectionServer { + t.Helper() + fake := &fakeProtectionServer{provider: repoProviderEntire, rules: rules} + srv := httptest.NewServer(fake.handler(t)) + t.Cleanup(srv.Close) + prev := activeCoreClient + activeCoreClient = func(context.Context) (*coreapi.Client, error) { + return coreapi.NewWithBearer(srv.URL, "tok") + } + t.Cleanup(func() { activeCoreClient = prev }) + return fake +} + +// rulesView projects wire rules onto the comparable view; the decoded wire +// structs carry an empty AdditionalProps map that a literal does not. +func rulesView(rs []coreapi.BranchRule) []branchRule { + return branchRulesFromWire(&coreapi.BranchProtection{Rules: rs}) +} + +func execRepoProtection(t *testing.T, args ...string) (stdout string, err error) { + t.Helper() + stdout, _, err = execRepoProtectionBothStreams(t, args...) + return stdout, err +} + +func execRepoProtectionBothStreams(t *testing.T, args ...string) (stdout, stderr string, err error) { + t.Helper() + parent := &cobra.Command{Use: "repo"} + addControlPlaneFlags(parent) + parent.AddCommand(newRepoProtectionCmd()) + var out, errOut bytes.Buffer + parent.SetOut(&out) + parent.SetErr(&errOut) + parent.SetArgs(append([]string{"protection"}, args...)) + err = parent.ExecuteContext(t.Context()) + return out.String(), errOut.String(), err +} + +func TestRepoProtection_ListEmpty(t *testing.T) { + newProtectionFixture(t) + out, err := execRepoProtection(t, "list", testProtectionRepoULID) + require.NoError(t, err) + assert.Equal(t, protectionEmpty+"\n", out) + + out, err = execRepoProtection(t, "list", testProtectionRepoULID, "--json") + require.NoError(t, err) + assert.Equal(t, "[]", strings.TrimSpace(out), "an empty list is a JSON array, not null") +} + +// A GitHub mirror reads as empty from core, but "nothing is protected" would +// misstate it: its default branch is always protected and its rules are the +// upstream's. --json keeps the plain array on stdout so a script still parses +// it, and puts the caveat on stderr — a script concluding "no rules ⇒ nothing +// is protected" is wrong on a mirror, which is what the note exists to say. +func TestRepoProtection_ListOnMirrorExplains(t *testing.T) { + fake := newProtectionFixture(t) + fake.provider = repoProviderGitHub + out, err := execRepoProtection(t, "list", testProtectionRepoULID) + require.NoError(t, err) + assert.Equal(t, protectionMirrorNote+"\n", out) + + out, errOut, err := execRepoProtectionBothStreams(t, "list", testProtectionRepoULID, "--json") + require.NoError(t, err) + assert.Equal(t, "[]", strings.TrimSpace(out), "stdout stays a bare array") + assert.Equal(t, protectionMirrorNote+"\n", errOut, "the caveat reaches --json callers on stderr") +} + +// The provider is needed only to render an empty list, so a list that has +// rules — and a --json render of one — costs a single round trip. A repo +// lookup here would also be a second way for `list` to fail after the +// branch-protection answer is already in hand. +func TestRepoProtection_LooksUpTheRepoOnlyForAnEmptyList(t *testing.T) { + fake := newProtectionFixture(t, coreapi.BranchRule{Ref: "HEAD"}) + + _, err := execRepoProtection(t, "list", testProtectionRepoULID) + require.NoError(t, err) + _, err = execRepoProtection(t, "list", testProtectionRepoULID, "--json") + require.NoError(t, err) + assert.Zero(t, fake.repoGets, "a non-empty list must not fetch the repo") + + _, err = execRepoProtection(t, "remove", testProtectionRepoULID, "HEAD") + require.NoError(t, err) + assert.Zero(t, fake.repoGets, "add/remove render their own result and never need the provider") + + _, err = execRepoProtection(t, "list", testProtectionRepoULID) + require.NoError(t, err) + assert.Equal(t, 1, fake.repoGets, "the empty rendering is the one branch that needs it") +} + +func TestRepoProtection_ListShowsLevels(t *testing.T) { + newProtectionFixture(t, + coreapi.BranchRule{Ref: "HEAD", ServerSideMergeOnly: coreapi.NewOptBool(true)}, + coreapi.BranchRule{Ref: "refs/heads/release/*"}, + ) + out, err := execRepoProtection(t, "list", testProtectionRepoULID) + require.NoError(t, err) + assert.Contains(t, out, "BRANCH") + assert.Contains(t, out, "HEAD") + assert.Contains(t, out, protectionLevelMergeOnly) + assert.Contains(t, out, "refs/heads/release/*") + assert.Contains(t, out, protectionLevelProtected) + + out, err = execRepoProtection(t, "list", testProtectionRepoULID, "--json") + require.NoError(t, err) + var got []branchRule + require.NoError(t, json.Unmarshal([]byte(out), &got)) + assert.Equal(t, []branchRule{{Ref: "HEAD", ServerSideMergeOnly: true}, {Ref: "refs/heads/release/*"}}, got) +} + +// add expands a short branch name and sends a level only when the flag was +// given, so re-adding a branch without it can never lower the rule; remove +// sends only removeRefs. Each verb prints the resulting rules. +func TestRepoProtection_AddAndRemove(t *testing.T) { + fake := newProtectionFixture(t, coreapi.BranchRule{Ref: "HEAD"}) + + out, err := execRepoProtection(t, "add", testProtectionRepoULID, "release/*") + require.NoError(t, err) + require.Len(t, fake.patches, 1) + assert.Equal(t, []branchRule{{Ref: "refs/heads/release/*"}}, rulesView(fake.patches[0].AddRules)) + assert.False(t, fake.patches[0].AddRules[0].ServerSideMergeOnly.IsSet(), "no flag, no level on the wire") + assert.Empty(t, fake.patches[0].RemoveRefs) + assert.Contains(t, out, "refs/heads/release/*") + + out, err = execRepoProtection(t, "add", testProtectionRepoULID, "HEAD", "--server-side-merge-only") + require.NoError(t, err) + require.Len(t, fake.patches, 2) + assert.Equal(t, []branchRule{{Ref: "HEAD", ServerSideMergeOnly: true}}, rulesView(fake.patches[1].AddRules)) + assert.True(t, fake.patches[1].AddRules[0].ServerSideMergeOnly.IsSet()) + assert.Contains(t, out, protectionLevelMergeOnly) + assert.Equal(t, []branchRule{ + {Ref: "HEAD", ServerSideMergeOnly: true}, + {Ref: "refs/heads/release/*"}, + }, rulesView(fake.rules), "re-adding HEAD raised its level in place") + + // The regression the CLI review caught: an add that only names the branch + // must not lower it. Lowering takes the flag set to false. + _, err = execRepoProtection(t, "add", testProtectionRepoULID, "HEAD") + require.NoError(t, err) + require.Len(t, fake.patches, 3) + assert.False(t, fake.patches[2].AddRules[0].ServerSideMergeOnly.IsSet()) + assert.True(t, rulesView(fake.rules)[0].ServerSideMergeOnly, "HEAD stays merge-only") + + out, err = execRepoProtection(t, "add", testProtectionRepoULID, "HEAD", "--server-side-merge-only=false") + require.NoError(t, err) + require.Len(t, fake.patches, 4) + assert.Equal(t, coreapi.NewOptBool(false), fake.patches[3].AddRules[0].ServerSideMergeOnly, "an explicit false is sent") + assert.False(t, rulesView(fake.rules)[0].ServerSideMergeOnly, "and lowers HEAD") + assert.NotContains(t, out, protectionLevelMergeOnly) + + out, err = execRepoProtection(t, "remove", testProtectionRepoULID, "HEAD") + require.NoError(t, err) + require.Len(t, fake.patches, 5) + assert.Equal(t, []string{"HEAD"}, fake.patches[4].RemoveRefs) + assert.Empty(t, fake.patches[4].AddRules) + assert.NotContains(t, out, "HEAD") + assert.Contains(t, out, "refs/heads/release/*") + + out, err = execRepoProtection(t, "remove", testProtectionRepoULID, "refs/heads/release/*") + require.NoError(t, err) + assert.Equal(t, protectionEmpty+"\n", out) +} + +func TestRepoProtection_NameNeedsProject(t *testing.T) { + fake := newProtectionFixture(t) + _, err := execRepoProtection(t, "list", "web") + require.Error(t, err) + assert.Contains(t, err.Error(), "--project") + assert.Empty(t, fake.patches) +} + +// "Nothing is protected yet." asserts that nothing protects this repository, +// so it needs the provider to be positively "entire". `provider` is optional +// (an older core omits it) and open (its enum is stripped, so a forge added +// later decodes verbatim), and the lookup can fail outright — each of those +// is "we could not find out", and answering it with the native sentence is +// how an empty list would come to hide a mirror's upstream rules. +func TestRepoProtection_EmptyListNeedsAPositivelyNativeProvider(t *testing.T) { + for _, tc := range []struct { + name string + provider string + getFails bool + want string + }{ + {name: "native", provider: repoProviderEntire, want: protectionEmpty}, + {name: "mirror", provider: repoProviderGitHub, want: protectionMirrorNote}, + {name: "absent", provider: "", want: protectionUnknownNote}, + {name: "unknown forge", provider: "gitlab", want: protectionUnknownNote}, + {name: "lookup failed", provider: repoProviderEntire, getFails: true, want: ""}, + } { + t.Run(tc.name, func(t *testing.T) { + fake := newProtectionFixture(t) + fake.provider = tc.provider + fake.repoGetFails = tc.getFails + + out, err := execRepoProtection(t, "list", testProtectionRepoULID) + if tc.getFails { + // The note is load-bearing for the human rendering, so a + // provider we could not read is an error rather than a + // sentence that might be wrong. + require.Error(t, err, "a failed lookup must not be rendered as an answer") + } else { + require.NoError(t, err) + assert.Equal(t, tc.want+"\n", out) + } + + // --json keeps stdout a bare array either way, and puts whatever + // qualification applies on stderr — including the reason the + // provider is unknown, rather than a silent []. + out, errOut, err := execRepoProtectionBothStreams(t, "list", testProtectionRepoULID, "--json") + require.NoError(t, err, "a script must not lose its array to a secondary lookup") + assert.Equal(t, "[]", strings.TrimSpace(out)) + switch { + case tc.getFails: + assert.Contains(t, errOut, protectionUnknownNote) + assert.Contains(t, errOut, "looking it up failed", "the reason is reported, not just the doubt") + case tc.want == protectionEmpty: + assert.Empty(t, errOut, "a positively native repo needs no qualification") + default: + assert.Equal(t, tc.want+"\n", errOut) + } + }) + } +} diff --git a/cmd/entire/cli/resolveref.go b/cmd/entire/cli/resolveref.go index 199d1245f3..f8cb3e7205 100644 --- a/cmd/entire/cli/resolveref.go +++ b/cmd/entire/cli/resolveref.go @@ -23,10 +23,28 @@ import ( // providerGitHub is the identity-provider slug for GitHub-backed accounts, the // provider half of a qualified grantee handle like "github:alice". GitHub is the // only provider with backing accounts today; other slugs resolve once they exist -// server-side. (Distinct from setup.go's checkpointProviderGitHub, which names -// the checkpoint hosting provider — same string, unrelated concern.) +// server-side. +// +// Three unrelated concerns spell GitHub the same way, and each keeps its own +// constant so a rename upstream moves one of them rather than all three: this +// one (which account provider backs a grantee), repoProviderGitHub below (which +// forge backs a repository), and setup.go's checkpointProviderGitHub (which +// service hosts a repo's checkpoints). const providerGitHub = "github" +// repoProviderGitHub and repoProviderEntire are the values of a repository's +// `provider` field; the wire enum is "github" | "entire". They answer "which +// forge backs this repo", which is a different question from providerGitHub's +// "which provider backs this account" — see the note there. +// +// The field is optional and open on the client (normalize.go drops it from +// `required` and strips its enum), so a caller must test for the value it +// wants and treat everything else as unknown rather than as the other one. +const ( + repoProviderGitHub = "github" + repoProviderEntire = "entire" +) + // projectRefClient and repoRefClient are the narrow control-plane surfaces the // name resolvers need. Keeping the helpers on interfaces lets repo-scoped // callers resolve a native /et// identity with the same client diff --git a/cmd/entire/cli/review/configure_test.go b/cmd/entire/cli/review/configure_test.go index 990ee5d714..630bd1b99d 100644 --- a/cmd/entire/cli/review/configure_test.go +++ b/cmd/entire/cli/review/configure_test.go @@ -325,7 +325,7 @@ func TestBuildConfiguredProfile_PreservesExistingTask(t *testing.T) { func TestSelectReviewProfile_LegacyReviewFallback(t *testing.T) { t.Parallel() s := &settings.EntireSettings{ - Review: map[string]settings.ReviewConfig{ + Review: map[string]settings.ReviewConfig{ //nolint:staticcheck // exercises the legacy pre-profile fallback on purpose tAgentClaude: {Skills: []string{"/review"}, Model: tModelSonnet}, }, } @@ -353,7 +353,7 @@ func TestSelectReviewProfile_ConfiguredProfilesOverrideLegacyReview(t *testing.T t.Parallel() const securityProfile = "security" s := &settings.EntireSettings{ - Review: map[string]settings.ReviewConfig{ + Review: map[string]settings.ReviewConfig{ //nolint:staticcheck // exercises the legacy pre-profile fallback on purpose tAgentClaude: {Skills: []string{"/legacy"}}, }, ReviewProfiles: map[string]settings.ReviewProfileConfig{ diff --git a/cmd/entire/cli/review/picker.go b/cmd/entire/cli/review/picker.go index 1c4ee6dbfc..026ecca90b 100644 --- a/cmd/entire/cli/review/picker.go +++ b/cmd/entire/cli/review/picker.go @@ -204,8 +204,8 @@ func promptForReviewFocus(ctx context.Context, current string) (string, string, picked := DefaultProfileName presets := []struct{ label, value string }{ {"General - correctness, regressions, tests", DefaultProfileName}, - {"Security - auth, injection, secrets", "security"}, - {"Accessibility - keyboard, screen readers, contrast", "accessibility"}, + {"Security - auth, injection, secrets", SecurityProfileName}, + {"Accessibility - keyboard, screen readers, contrast", AccessibilityProfileName}, } options := make([]huh.Option[string], 0, len(presets)+1) for _, p := range presets { diff --git a/cmd/entire/cli/review/profile.go b/cmd/entire/cli/review/profile.go index 7728bf415c..aab75945d8 100644 --- a/cmd/entire/cli/review/profile.go +++ b/cmd/entire/cli/review/profile.go @@ -16,7 +16,14 @@ import ( "github.com/entireio/cli/cmd/entire/cli/tuiutil" ) -const DefaultProfileName = "general" +// Built-in profile names. DefaultProfileName is the profile used when none is +// named; the other two are the presets the picker offers and profileTask knows +// a built-in task for. +const ( + DefaultProfileName = "general" + SecurityProfileName = "security" + AccessibilityProfileName = "accessibility" +) // Review output destinations. ReviewOutputLocal prints the verdict and writes // the local review manifest; ReviewOutputTrail additionally posts the verdict @@ -63,9 +70,9 @@ func profileTask(name string, cfg settings.ReviewProfileConfig) string { switch strings.ToLower(name) { case "", DefaultProfileName: return defaultGeneralTask - case "security": + case SecurityProfileName: return defaultSecurityTask - case "accessibility", "a11y": + case AccessibilityProfileName, "a11y": return defaultAccessibilityTask default: return defaultGeneralTask @@ -398,7 +405,7 @@ func defaultReviewAgentConfig(profileName, agentName string) settings.ReviewConf focus := defaultProfileFocus(profileName) switch agentName { case string(agent.AgentNameClaudeCode): - if strings.EqualFold(profileName, "security") { + if strings.EqualFold(profileName, SecurityProfileName) { return settings.ReviewConfig{Skills: []string{"/security-review"}} } return settings.ReviewConfig{Skills: []string{"/review"}, Prompt: focus} @@ -415,9 +422,9 @@ func defaultReviewAgentConfig(profileName, agentName string) settings.ReviewConf func defaultProfileFocus(profileName string) string { switch strings.ToLower(strings.TrimSpace(profileName)) { - case "security": + case SecurityProfileName: return "Focus specifically on security issues." - case "accessibility", "a11y": + case AccessibilityProfileName, "a11y": return "Focus specifically on accessibility issues." default: return "" diff --git a/cmd/entire/cli/review/tui_sink.go b/cmd/entire/cli/review/tui_sink.go index 16d19ce0c6..b6b62022cc 100644 --- a/cmd/entire/cli/review/tui_sink.go +++ b/cmd/entire/cli/review/tui_sink.go @@ -120,7 +120,7 @@ func terminalMeasurer(output io.Writer) func() (int, int, bool) { return nil } return func() (int, int, bool) { - width, height, err := term.GetSize(int(f.Fd())) //nolint:gosec // fd values fit in int on supported platforms + width, height, err := term.GetSize(int(f.Fd())) if err != nil || width <= 0 || height <= 0 { return 0, 0, false } diff --git a/cmd/entire/cli/runner_gather.go b/cmd/entire/cli/runner_gather.go index a822b009bb..bd1844f4e6 100644 --- a/cmd/entire/cli/runner_gather.go +++ b/cmd/entire/cli/runner_gather.go @@ -28,6 +28,7 @@ const ( ) const ( + sourceRepo = "repo" sourceCheckpoint = "checkpoint" sourceCheckpoints = "checkpoints" sourceTrail = "trail" @@ -57,7 +58,7 @@ func parseTuneSources(list []string) (tuneSources, error) { continue case "all": return allTuneSources(), nil - case "repo": + case sourceRepo: s.repo = true case "pr", "prs", "issue", "issues": s.prs = true diff --git a/cmd/entire/cli/search/search.go b/cmd/entire/cli/search/search.go index 0822272940..f5291e1e11 100644 --- a/cmd/entire/cli/search/search.go +++ b/cmd/entire/cli/search/search.go @@ -33,10 +33,8 @@ const v4ServicePath = "/api/v1/semantic-search/search/v1/search" var ErrCellUnavailable = errors.New("semantic search is not available in this cell") // ErrRepoFilterUnmatched reports that query-serve answered (the route exists) -// but the explicit repo filter matched nothing the caller can search — the -// repo isn't indexed yet, or its owner org isn't enabled on the -// semantic-search feature flag (entire-search fails closed with a JSON 404, -// existence not disclosed). A typo'd repo can't produce this from the CLI: +// but the explicit repo filter matched nothing the caller can search. The +// repository might not be indexed yet. A typo'd repo can't produce this from the CLI: // the slug was already resolved against the control-plane index before any // cell was contacted. Distinct from ErrCellUnavailable so fan-out callers // don't misreport a repo-level miss as a region without query-serve. @@ -768,9 +766,8 @@ func CellV4(ctx context.Context, client *api.Client, cfg Config, repoIDs []strin if resp.StatusCode == http.StatusNotFound { // Two distinct 404s share this status. A JSON error body is // query-serve answering through the gateway: the route exists but the - // repo filter matched nothing the caller may search (not indexed, or - // the owner org isn't flag-enabled — entire-search fails closed, - // existence not disclosed). A plain "404 page not found" is the + // repo filter matched nothing because query-serve has not indexed it. + // A plain "404 page not found" is the // gateway itself: no semantic-search route, query-serve not deployed // in this cell. Deployed cells answer unfiltered searches of unknown // repos with an empty 200, so the split is unambiguous. The diff --git a/cmd/entire/cli/search_v4.go b/cmd/entire/cli/search_v4.go index 49e4435e22..318860eb2a 100644 --- a/cmd/entire/cli/search_v4.go +++ b/cmd/entire/cli/search_v4.go @@ -434,10 +434,9 @@ func classifySemanticCells(ctx context.Context, results []cellCallResult[*search // errNoRepoAvailable is returned when at least one cell answered but none // matched the repo filter. A typo'd name or missing access cannot reach this -// point — resolveScope already validated the slug against the control-plane -// repo index — so the message names only the causes that survive: query-serve -// hasn't indexed the repo, or its owner org isn't enabled for semantic search. -var errNoRepoAvailable = errors.New("semantic search cannot search this repo yet — it may not be indexed, or semantic search may not be enabled for its owner") +// point because resolveScope already validated the slug against the control-plane +// repo index. The remaining cause is that query-serve has not indexed the repo. +var errNoRepoAvailable = errors.New("semantic search cannot search this repo yet — it may not be indexed") // errNoRegionAvailable is returned when every queried cell lacks query-serve. var errNoRegionAvailable = errors.New("semantic search is not yet available in the region(s) hosting this search") diff --git a/cmd/entire/cli/search_v4_test.go b/cmd/entire/cli/search_v4_test.go index 3845e7beb2..cb5ab4bfc6 100644 --- a/cmd/entire/cli/search_v4_test.go +++ b/cmd/entire/cli/search_v4_test.go @@ -765,11 +765,9 @@ func TestNewSemanticSearcher_RejectsMultipleRepoFilters(t *testing.T) { } // TestMergeSemanticV4Responses_AllCellsRepoUnmatched verifies the error when -// every queried cell answered but none matched the repo filter (not indexed, -// or the owner org isn't flag-enabled — a typo can't reach this point, the -// slug already resolved). The old behavior lumped this in with undeployed -// cells and told the user their REGION lacked semantic search — a -// misdiagnosis that sent a flag-enrollment gap to the wrong team. +// every queried cell answered but none has indexed the repo. A typo cannot +// reach this point because the slug already resolved. The error must not +// misdiagnose the miss as a region without semantic search. func TestMergeSemanticV4Responses_AllCellsRepoUnmatched(t *testing.T) { t.Parallel() @@ -783,8 +781,8 @@ func TestMergeSemanticV4Responses_AllCellsRepoUnmatched(t *testing.T) { if strings.Contains(err.Error(), "region") { t.Errorf("error = %q, must not blame the region for a repo-filter miss", err.Error()) } - if !strings.Contains(err.Error(), "repo") || !strings.Contains(err.Error(), "enabled") { - t.Errorf("error = %q, want it to point at the repo name, access, or semantic-search enablement", err.Error()) + if !strings.Contains(err.Error(), "repo") || !strings.Contains(err.Error(), "indexed") { + t.Errorf("error = %q, want it to identify the repository as not indexed", err.Error()) } } diff --git a/cmd/entire/cli/session/state.go b/cmd/entire/cli/session/state.go index 4268dd1189..25c8d7ad75 100644 --- a/cmd/entire/cli/session/state.go +++ b/cmd/entire/cli/session/state.go @@ -331,6 +331,9 @@ type State struct { // cumulative total on every checkpoint. SubagentTokensBaseline *agent.TokenUsage `json:"subagent_tokens_baseline,omitempty"` + // SubagentTokensBaselineComplete records whether the baseline is exact. + SubagentTokensBaselineComplete *bool `json:"subagent_tokens_baseline_complete,omitempty"` + // SkillEvents records explicit native skill signals observed during this session. // Stored as sidecar metadata so consumers can collapse skill-related transcript events // without mutating the raw agent transcript. @@ -423,6 +426,22 @@ type State struct { // TaskRecords tracks subagents dispatched by this session — the durable // pointer ledger for subagent work. See TaskRecord. TaskRecords []TaskRecord `json:"task_records,omitempty"` + + // SubagentInventory retains Codex child identities independently of task + // records so follow-up turns remain discoverable after materialization. + SubagentInventory []SubagentInventoryEntry `json:"subagent_inventory,omitempty"` + // SubagentLedgerVersion advances on a new child identity or non-empty turn. + SubagentLedgerVersion uint64 `json:"subagent_ledger_version,omitempty"` + // SubagentInventoryComplete distinguishes exact empty from legacy unknown. + SubagentInventoryComplete *bool `json:"subagent_inventory_complete,omitempty"` +} + +type SubagentInventoryEntry struct { + AgentID string `json:"agent_id"` + DeclaredTranscriptPath string `json:"declared_transcript_path,omitempty"` + ResolvedTranscriptPath string `json:"resolved_transcript_path,omitempty"` + ObservedTurnIDs []string `json:"observed_turn_ids,omitempty"` + FinalizedTurnIDs []string `json:"finalized_turn_ids,omitempty"` } // TaskRecord is the durable pointer ledger entry for a subagent dispatched by @@ -506,6 +525,128 @@ func (s *State) AddTaskRecord(task TaskRecord) { s.TaskRecords = append(s.TaskRecords, task) } +// EnsureTaskRecord adds a follow-up record only after an earlier completed +// record was materialized and removed. Existing unmaterialized content wins, +// but missing launch metadata is enriched for stop-before-start delivery. +func (s *State) EnsureTaskRecord(task TaskRecord) bool { + if task.ToolUseID == "" { + return false + } + if existing := s.FindTaskRecord(task.ToolUseID); existing != nil { + if existing.AgentID == "" { + existing.AgentID = task.AgentID + } + if existing.StartedAt.IsZero() { + existing.StartedAt = task.StartedAt + } + if existing.SubagentType == "" { + existing.SubagentType = task.SubagentType + } + if existing.TaskDescription == "" { + existing.TaskDescription = task.TaskDescription + } + if existing.DeclaredTranscriptPath == "" { + existing.DeclaredTranscriptPath = task.DeclaredTranscriptPath + } + return false + } + s.AddTaskRecord(task) + return true +} + +// FindSubagentInventory returns an entry that aliases state. Callers must use +// it only inside their current MutateSessionState closure. +func (s *State) FindSubagentInventory(agentID string) *SubagentInventoryEntry { + for i := range s.SubagentInventory { + if s.SubagentInventory[i].AgentID == agentID { + return &s.SubagentInventory[i] + } + } + return nil +} + +// RegisterSubagent observes a stable child identity and optionally one child +// turn. Only a first child or first non-empty turn invalidates cached totals. +func (s *State) RegisterSubagent(agentID, turnID string) bool { + if agentID == "" { + return false + } + entry := s.FindSubagentInventory(agentID) + newObservation := false + if entry == nil { + s.SubagentInventory = append(s.SubagentInventory, SubagentInventoryEntry{AgentID: agentID}) + entry = &s.SubagentInventory[len(s.SubagentInventory)-1] + newObservation = true + } + if turnID != "" && !containsString(entry.ObservedTurnIDs, turnID) { + entry.ObservedTurnIDs = append(entry.ObservedTurnIDs, turnID) + newObservation = true + } + if newObservation { + s.invalidateSubagentTokenUsage() + } + return newObservation +} + +// RecordSubagentStop records a provisional stop. Stops can arrive before +// starts, so the same mutation also preserves a pending task record. A late +// start enriches that placeholder through EnsureTaskRecord. +func (s *State) RecordSubagentStop(agentID, turnID string) bool { + newObservation := s.RegisterSubagent(agentID, turnID) + if newObservation { + s.EnsureTaskRecord(TaskRecord{ToolUseID: agentID, AgentID: agentID}) + } + return newObservation +} + +// UpdateSubagentTranscriptPaths enriches an already-observed child's path +// metadata. Resolution is not an inventory observation, so it deliberately +// does not advance SubagentLedgerVersion or invalidate token coverage. +func (s *State) UpdateSubagentTranscriptPaths(agentID, declaredPath, resolvedPath string) bool { + entry := s.FindSubagentInventory(agentID) + if entry == nil { + return false + } + changed := false + if declaredPath != "" && entry.DeclaredTranscriptPath != declaredPath { + entry.DeclaredTranscriptPath = declaredPath + changed = true + } + if resolvedPath != "" && entry.ResolvedTranscriptPath != resolvedPath { + entry.ResolvedTranscriptPath = resolvedPath + changed = true + } + return changed +} + +// FinalizeSubagentTurn marks an observed turn finalized exactly once. +func (s *State) FinalizeSubagentTurn(agentID, turnID string) bool { + if agentID == "" || turnID == "" { + return false + } + entry := s.FindSubagentInventory(agentID) + if entry == nil || !containsString(entry.ObservedTurnIDs, turnID) || containsString(entry.FinalizedTurnIDs, turnID) { + return false + } + entry.FinalizedTurnIDs = append(entry.FinalizedTurnIDs, turnID) + return true +} + +func (s *State) invalidateSubagentTokenUsage() { + s.SubagentLedgerVersion++ + s.TokenUsage = types.WithClearedSubagentTokens(s.TokenUsage, false) + s.CheckpointTokenUsage = types.WithClearedSubagentTokens(s.CheckpointTokenUsage, false) +} + +func containsString(values []string, value string) bool { + for _, existing := range values { + if existing == value { + return true + } + } + return false +} + // RemoveTaskRecord clears the record for toolUseID, if present. No-op when no // record matches. Retained for tests and any caller that genuinely wants to // discard a record outright — ordinary completion should use @@ -661,6 +802,27 @@ func (s *State) NormalizeAfterLoad(ctx context.Context) { if s.DivergenceNoticeShown && s.AttributionBaseCommit == s.BaseCommit { s.DivergenceNoticeShown = false } + + // Codex states saved before the authoritative child ledger cannot claim an + // exact child aggregate. Keep any exact task-record IDs as discovery hints, + // but make their coverage conservative and invalidate old totals. + if s.AgentType == agent.AgentTypeCodex { + if s.SubagentInventoryComplete == nil { + incomplete := false + s.SubagentInventoryComplete = &incomplete + for _, record := range s.TaskRecords { + if record.AgentID != "" && s.FindSubagentInventory(record.AgentID) == nil { + s.SubagentInventory = append(s.SubagentInventory, SubagentInventoryEntry{AgentID: record.AgentID}) + } + } + s.TokenUsage = types.WithClearedSubagentTokens(s.TokenUsage, false) + s.CheckpointTokenUsage = types.WithClearedSubagentTokens(s.CheckpointTokenUsage, false) + } + if s.SubagentTokensBaselineComplete == nil { + incomplete := false + s.SubagentTokensBaselineComplete = &incomplete + } + } } // ClearLegacyTranscriptOffsets clears deprecated transcript offset fields so @@ -714,9 +876,22 @@ func (s *State) ClearCondensationAttempt() { // helper (resetCheckpointWindow) and cross-repo session adoption, which likewise // opens a fresh target-local window. Sharing this here keeps the two in step. func (s *State) RebaselineSubagentTokens() { - if s.TokenUsage != nil { - s.SubagentTokensBaseline = s.TokenUsage.SubagentTokens + // Legacy agents without a snapshot retain their existing window baseline. + if s.TokenUsage == nil && s.AgentType != agent.AgentTypeCodex { + return + } + if s.TokenUsage == nil || (s.TokenUsage.SubagentTokensComplete != nil && !*s.TokenUsage.SubagentTokensComplete) { + incomplete := false + s.SubagentTokensBaseline = nil + s.SubagentTokensBaselineComplete = &incomplete + return } + // A nil marker retains the historic behaviour: the implicit initial + // baseline is exact zero. An explicit complete marker can intentionally + // snapshot a nil aggregate for an authoritative empty inventory. + complete := true + s.SubagentTokensBaseline = s.TokenUsage.SubagentTokens + s.SubagentTokensBaselineComplete = &complete } // RealignAttributionBase sets AttributionBaseCommit to newBase and clears any diff --git a/cmd/entire/cli/session/state_test.go b/cmd/entire/cli/session/state_test.go index cb78e677ba..f947981665 100644 --- a/cmd/entire/cli/session/state_test.go +++ b/cmd/entire/cli/session/state_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" "github.com/entireio/cli/cmd/entire/cli/osroot" "github.com/entireio/cli/cmd/entire/cli/testutil" @@ -1053,3 +1054,201 @@ func TestState_LiveTaskRecords(t *testing.T) { assert.Empty(t, (&State{}).LiveTaskRecords()) } + +func TestState_SubagentInventoryLedger(t *testing.T) { + t.Parallel() + + stopFirst := &State{} + if !stopFirst.RecordSubagentStop("child-stop-first", "turn-stop-first") { + t.Fatal("stop-before-start observation must be recorded") + } + record := stopFirst.FindTaskRecord("child-stop-first") + require.NotNil(t, record, "a stop-before-start observation must preserve pending task content") + assert.Equal(t, "child-stop-first", record.AgentID) + assert.True(t, stopFirst.HasTaskContent()) + startedAt := time.Now().UTC() + assert.False(t, stopFirst.EnsureTaskRecord(TaskRecord{ + ToolUseID: "child-stop-first", + AgentID: "child-stop-first", + StartedAt: startedAt, + SubagentType: "default", + TaskDescription: "late start metadata", + }), "the late start must enrich, not replace, the pending record") + assert.Equal(t, startedAt, record.StartedAt) + assert.Equal(t, "default", record.SubagentType) + assert.Equal(t, "late start metadata", record.TaskDescription) + + complete := true + state := &State{ + TokenUsage: &agent.TokenUsage{InputTokens: 5, SubagentTokens: &agent.TokenUsage{InputTokens: 3}, SubagentTokensComplete: &complete}, + CheckpointTokenUsage: &agent.TokenUsage{OutputTokens: 2, SubagentTokens: &agent.TokenUsage{OutputTokens: 1}, SubagentTokensComplete: &complete}, + } + if !state.RegisterSubagent("child-1", "turn-1") { + t.Fatal("first child observation must be recorded") + } + assert.Equal(t, uint64(1), state.SubagentLedgerVersion) + assertIncompleteSubagentUsage(t, state) + + // A later exact extraction may have refreshed both aggregates. Duplicate + // observations and path-only enrichment must preserve that fresh coverage. + refreshedComplete := true + state.TokenUsage.SubagentTokens = &agent.TokenUsage{InputTokens: 21} + state.TokenUsage.SubagentTokensComplete = &refreshedComplete + state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 13} + state.CheckpointTokenUsage.SubagentTokensComplete = &refreshedComplete + versionBeforeDuplicate := state.SubagentLedgerVersion + if state.RegisterSubagent("child-1", "turn-1") { + t.Fatal("duplicate agent/turn observation must be a true no-op") + } + assert.Equal(t, versionBeforeDuplicate, state.SubagentLedgerVersion) + assertCompleteSubagentUsage(t, state, 21, 13) + versionBeforePathEnrichment := state.SubagentLedgerVersion + assert.True(t, state.UpdateSubagentTranscriptPaths("child-1", "/tmp/declared.jsonl", "/tmp/resolved.jsonl")) + assert.Equal(t, versionBeforePathEnrichment, state.SubagentLedgerVersion, "path enrichment must not churn the ledger generation") + assertCompleteSubagentUsage(t, state, 21, 13) + + if !state.RecordSubagentStop("child-1", "turn-2") { + t.Fatal("stop-first new turn must be recorded") + } + assert.Equal(t, uint64(2), state.SubagentLedgerVersion) + assertIncompleteSubagentUsage(t, state) + entry := state.FindSubagentInventory("child-1") + require.NotNil(t, entry) + require.Contains(t, entry.ObservedTurnIDs, "turn-2") + require.NotContains(t, entry.FinalizedTurnIDs, "turn-2") + + // A stop retry must leave already-calculated token coverage intact. + stopRefreshComplete := true + state.TokenUsage.SubagentTokens = &agent.TokenUsage{InputTokens: 34} + state.TokenUsage.SubagentTokensComplete = &stopRefreshComplete + state.CheckpointTokenUsage.SubagentTokens = &agent.TokenUsage{OutputTokens: 21} + state.CheckpointTokenUsage.SubagentTokensComplete = &stopRefreshComplete + versionBeforeStopRefresh := state.SubagentLedgerVersion + assert.False(t, state.RecordSubagentStop("child-1", "turn-2"), "duplicate stop must be a no-op") + assert.Equal(t, versionBeforeStopRefresh, state.SubagentLedgerVersion) + assertCompleteSubagentUsage(t, state, 34, 21) + + state.RecordSubagentStop("child-1", "turn-3") + require.Contains(t, entry.ObservedTurnIDs, "turn-3", "several pending turns must coexist") + if !state.FinalizeSubagentTurn("child-1", "turn-2") { + t.Fatal("pending turn must finalize") + } + assert.Contains(t, entry.FinalizedTurnIDs, "turn-2") + if state.FinalizeSubagentTurn("child-1", "turn-2") { + t.Fatal("finalized turn must be exactly once") + } +} + +func assertIncompleteSubagentUsage(t *testing.T, state *State) { + t.Helper() + for _, usage := range []*agent.TokenUsage{state.TokenUsage, state.CheckpointTokenUsage} { + require.NotNil(t, usage) + assert.Nil(t, usage.SubagentTokens) + require.NotNil(t, usage.SubagentTokensComplete) + assert.False(t, *usage.SubagentTokensComplete) + } +} + +func assertCompleteSubagentUsage(t *testing.T, state *State, sessionInput, checkpointOutput int) { + t.Helper() + assert.Equal(t, &agent.TokenUsage{InputTokens: sessionInput}, state.TokenUsage.SubagentTokens) + assert.Equal(t, &agent.TokenUsage{OutputTokens: checkpointOutput}, state.CheckpointTokenUsage.SubagentTokens) + assert.True(t, *state.TokenUsage.SubagentTokensComplete) + assert.True(t, *state.CheckpointTokenUsage.SubagentTokensComplete) +} + +func TestState_SubagentInventoryRoundTripAndTaskRecordRecovery(t *testing.T) { + t.Parallel() + now := time.Now().UTC().Truncate(time.Second) + complete := true + state := State{ + AgentType: agent.AgentTypeCodex, + SubagentInventoryComplete: &complete, + SubagentTokensBaselineComplete: &complete, + SubagentLedgerVersion: 7, + SubagentInventory: []SubagentInventoryEntry{{ + AgentID: "child-1", + DeclaredTranscriptPath: "/tmp/child.jsonl", + ResolvedTranscriptPath: "/tmp/resolved.jsonl", + ObservedTurnIDs: []string{"turn-1"}, + FinalizedTurnIDs: []string{"turn-0"}, + }}, + } + data, err := json.Marshal(state) + require.NoError(t, err) + var got State + require.NoError(t, json.Unmarshal(data, &got)) + require.NotNil(t, got.SubagentInventoryComplete) + assert.True(t, *got.SubagentInventoryComplete) + require.NotNil(t, got.SubagentTokensBaselineComplete) + assert.True(t, *got.SubagentTokensBaselineComplete) + assert.Equal(t, uint64(7), got.SubagentLedgerVersion) + assert.Equal(t, state.SubagentInventory, got.SubagentInventory) + + materialized := TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now, CompletedAt: now} + got.AddTaskRecord(materialized) + assert.False(t, got.EnsureTaskRecord(TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now.Add(time.Minute)}), "unmaterialized record must not be replaced") + assert.True(t, got.TaskRecords[0].CompletedAt.Equal(now)) + got.RemoveTaskRecord("child-1") + assert.True(t, got.EnsureTaskRecord(TaskRecord{ToolUseID: "child-1", AgentID: "child-1", StartedAt: now.Add(time.Minute)}), "follow-up must recreate a materialized record") +} + +func TestState_NormalizeAfterLoad_CodexInventoryMigration(t *testing.T) { + t.Parallel() + legacy := &State{ + AgentType: agent.AgentTypeCodex, + TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 4}}, + CheckpointTokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 2}}, + TaskRecords: []TaskRecord{{AgentID: "child-1"}}, + } + legacy.NormalizeAfterLoad(context.Background()) + require.NotNil(t, legacy.SubagentInventoryComplete) + assert.False(t, *legacy.SubagentInventoryComplete) + require.NotNil(t, legacy.SubagentTokensBaselineComplete) + assert.False(t, *legacy.SubagentTokensBaselineComplete) + assertIncompleteSubagentUsage(t, legacy) + require.Len(t, legacy.SubagentInventory, 1) + assert.Equal(t, "child-1", legacy.SubagentInventory[0].AgentID) + + nonCodex := &State{AgentType: agent.AgentTypeClaudeCode} + nonCodex.NormalizeAfterLoad(context.Background()) + assert.Nil(t, nonCodex.SubagentInventoryComplete) + assert.Nil(t, nonCodex.SubagentTokensBaselineComplete) + + explicitComplete := true + explicit := &State{AgentType: agent.AgentTypeCodex, SubagentInventoryComplete: &explicitComplete, SubagentTokensBaselineComplete: &explicitComplete, TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}}} + explicit.NormalizeAfterLoad(context.Background()) + assert.True(t, *explicit.SubagentInventoryComplete) + assert.NotNil(t, explicit.TokenUsage.SubagentTokens, "an explicit state must not be migrated again") +} + +func TestState_RebaselineSubagentTokensPreservesTriState(t *testing.T) { + t.Parallel() + complete := true + incomplete := false + + exactEmpty := &State{TokenUsage: &agent.TokenUsage{SubagentTokensComplete: &complete}} + exactEmpty.RebaselineSubagentTokens() + require.NotNil(t, exactEmpty.SubagentTokensBaselineComplete) + assert.True(t, *exactEmpty.SubagentTokensBaselineComplete) + assert.Nil(t, exactEmpty.SubagentTokensBaseline) + + unknown := &State{TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}, SubagentTokensComplete: &incomplete}} + unknown.RebaselineSubagentTokens() + require.NotNil(t, unknown.SubagentTokensBaselineComplete) + assert.False(t, *unknown.SubagentTokensBaselineComplete) + assert.Nil(t, unknown.SubagentTokensBaseline) +} + +func TestState_RebaselineSubagentTokensPreservesLegacyNilUsage(t *testing.T) { + t.Parallel() + for _, agentType := range []types.AgentType{agent.AgentTypeClaudeCode, agent.AgentTypeFactoryAIDroid} { + t.Run(string(agentType), func(t *testing.T) { + t.Parallel() + state := &State{AgentType: agentType, SubagentTokensBaseline: &agent.TokenUsage{InputTokens: 7}} + state.RebaselineSubagentTokens() + require.Equal(t, &agent.TokenUsage{InputTokens: 7}, state.SubagentTokensBaseline) + require.Nil(t, state.SubagentTokensBaselineComplete) + }) + } +} diff --git a/cmd/entire/cli/session_adopt_test.go b/cmd/entire/cli/session_adopt_test.go index 281dc22dd4..7fd0a6b2ed 100644 --- a/cmd/entire/cli/session_adopt_test.go +++ b/cmd/entire/cli/session_adopt_test.go @@ -961,8 +961,8 @@ func TestSessionAdopt_ResetsSourceCheckpointWindow(t *testing.T) { ContextWindowSize: 200_000, CheckpointTranscriptStart: 2, CheckpointTranscriptSize: 1234, - CondensedTranscriptLines: 2, - TranscriptLinesAtStart: 2, + CondensedTranscriptLines: 2, //nolint:staticcheck // legacy field, asserted so migration keeps working + TranscriptLinesAtStart: 2, //nolint:staticcheck // legacy field, asserted so migration keeps working TranscriptIdentifierAtStart: "source-assistant", TurnID: "source-turn", TurnCheckpointIDs: []string{"abc123def456"}, @@ -1086,8 +1086,8 @@ func TestSessionAdopt_ClearsLegacyTranscriptOffsets(t *testing.T) { BaseCommit: "source-head", WorktreePath: "/source/repo", CheckpointTranscriptStart: 9, - CondensedTranscriptLines: 9, - TranscriptLinesAtStart: 9, + CondensedTranscriptLines: 9, //nolint:staticcheck // legacy field, asserted so migration keeps working + TranscriptLinesAtStart: 9, //nolint:staticcheck // legacy field, asserted so migration keeps working }) if err != nil { t.Fatalf("buildAdoptedSessionState failed: %v", err) diff --git a/cmd/entire/cli/session_tokens.go b/cmd/entire/cli/session_tokens.go index 97a68316c4..688a1d104f 100644 --- a/cmd/entire/cli/session_tokens.go +++ b/cmd/entire/cli/session_tokens.go @@ -13,6 +13,26 @@ import ( "github.com/spf13/cobra" ) +// The token-report vocabulary, shared by `session tokens`, `checkpoint tokens` +// and `tokens profile`: these strings are part of those commands' --json +// contract, so they are named once rather than spelled per call site. +const ( + tokensKindSubagents = "subagents" + tokensKindContextPressure = "context_pressure" + + tokensConfidenceReported = "reported" + + tokensSignalSubagentTokens = "subagent_tokens" + tokensSignalMissingUsage = "missing_token_usage" + tokensSignalContextTokens = "context_tokens" + tokensSignalCacheReadTokens = "cache_read_tokens" + tokensSignalAPICallCount = "api_call_count" + + tokensSeverityLow = "low" + tokensSeverityMedium = "medium" + tokensSeverityHigh = "high" +) + type sessionTokensReport struct { SessionID string `json:"session_id"` Agent string `json:"agent"` @@ -209,31 +229,31 @@ func buildSessionTokensReport(state *strategy.SessionState, status string) sessi report.Tokens = tokens if tokens.SubagentTotal > 0 { report.Contributors = append(report.Contributors, sessionTokensContributor{ - Kind: "subagents", + Kind: tokensKindSubagents, Label: "Subagents", Tokens: tokens.SubagentTotal, - Confidence: "reported", - Signals: []string{"subagent_tokens"}, + Confidence: tokensConfidenceReported, + Signals: []string{tokensSignalSubagentTokens}, }) } } else { report.Limitations = append(report.Limitations, "No token usage recorded for this session.") report.Recommendations = append(report.Recommendations, sessionTokensRecommendation{ ID: "no-token-data", - Severity: "low", + Severity: tokensSeverityLow, Message: "Token usage is unavailable for this session; the agent may not expose token data yet, or no checkpoint has captured it.", - Signals: []string{"missing_token_usage"}, + Signals: []string{tokensSignalMissingUsage}, }) } if contextInfo := buildSessionTokensContext(state.ContextTokens, state.ContextWindowSize); contextInfo != nil { report.Context = contextInfo report.Contributors = append(report.Contributors, sessionTokensContributor{ - Kind: "context_pressure", + Kind: tokensKindContextPressure, Label: "Context pressure", Percent: contextInfo.Percent, - Confidence: "reported", - Signals: []string{"context_tokens"}, + Confidence: tokensConfidenceReported, + Signals: []string{tokensSignalContextTokens}, }) } @@ -241,7 +261,7 @@ func buildSessionTokensReport(state *strategy.SessionState, status string) sessi report.Contributors = append(report.Contributors, sessionTokensContributor{ Kind: "skills", Label: "Skills/slash commands: " + strings.Join(labels, ", "), - Confidence: "reported", + Confidence: tokensConfidenceReported, Signals: []string{"skill_events"}, }) } @@ -328,12 +348,12 @@ func recommendationRules(signals tokenRecommendationSignals) []sessionTokensReco cacheReadHotspot = true recs = append(recs, sessionTokensRecommendation{ ID: "context-replay-hotspot", - Severity: "high", + Severity: tokensSeverityHigh, Message: fmt.Sprintf( "Cache/context replay is %s of token volume; reduce unnecessary follow-up calls in this large-context session.", formatPercent(cacheReadPercent), ), - Signals: []string{"cache_read_tokens"}, + Signals: []string{tokensSignalCacheReadTokens}, }) } } @@ -344,24 +364,24 @@ func recommendationRules(signals tokenRecommendationSignals) []sessionTokensReco } recs = append(recs, sessionTokensRecommendation{ ID: "api-call-amplification", - Severity: "medium", + Severity: tokensSeverityMedium, Message: message, - Signals: []string{"api_call_count"}, + Signals: []string{tokensSignalAPICallCount}, }) } if signals.Tokens != nil && tokenShareAtLeastOneTenth(signals.Tokens.SubagentTotal, signals.Tokens.Total) { recs = append(recs, sessionTokensRecommendation{ ID: "subagent-heavy", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Scope subagent tasks tightly; give each subagent a narrow objective and expected output.", - Signals: []string{"subagent_tokens"}, + Signals: []string{tokensSignalSubagentTokens}, }) } if signals.Tokens != nil && signals.Tokens.Total > 0 && tokenClassPressure(signals.Tokens.CacheWrite, signals.Tokens.Total, 5000, 10, 50_000) { recs = append(recs, sessionTokensRecommendation{ ID: "cache-write-pressure", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Cache write is elevated; avoid broad new context and narrow the next read before continuing.", Signals: []string{"cache_write_tokens"}, }) @@ -370,7 +390,7 @@ func recommendationRules(signals tokenRecommendationSignals) []sessionTokensReco tokenClassPressure(signals.Tokens.Output, signals.Tokens.Total, 3000, 2, 10_000) { recs = append(recs, sessionTokensRecommendation{ ID: "output-pressure", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Output tokens are elevated; keep the next answer tight and avoid restating evidence.", Signals: []string{"output_tokens"}, }) @@ -378,23 +398,23 @@ func recommendationRules(signals tokenRecommendationSignals) []sessionTokensReco if signals.Context != nil && signals.Context.Percent >= recommendationHighContextPercent { recs = append(recs, sessionTokensRecommendation{ ID: "high-context-pressure", - Severity: "medium", + Severity: tokensSeverityMedium, Message: fmt.Sprintf("Context pressure is %d%% of the window; preserve only relevant context before continuing.", signals.Context.Percent), - Signals: []string{"context_tokens"}, + Signals: []string{tokensSignalContextTokens}, }) } if cacheReadHotspot && signals.Tokens != nil && signals.Tokens.APICalls >= recommendationHighAPICalls { recs = append(recs, sessionTokensRecommendation{ ID: "summarize-before-boundary", - Severity: "low", + Severity: tokensSeverityLow, Message: "Compact or restart after summarizing this investigation; do not discard useful findings just because cache read is high.", - Signals: []string{"cache_read_tokens", "api_call_count"}, + Signals: []string{tokensSignalCacheReadTokens, tokensSignalAPICallCount}, }) } if signals.TurnCount >= recommendationLongSessionTurns || signals.CheckpointCount >= recommendationLongSessionCheckpoints { recs = append(recs, sessionTokensRecommendation{ ID: "long-session", - Severity: "low", + Severity: tokensSeverityLow, Message: "Compact or restart after summarizing the useful findings if older context is no longer needed.", Signals: []string{"turn_count", "checkpoint_count"}, }) @@ -637,9 +657,9 @@ func writeTokenContributors(w io.Writer, contributors []sessionTokensContributor fmt.Fprintln(w, "Likely contributors") for _, contributor := range contributors { switch contributor.Kind { - case "subagents": + case tokensKindSubagents: fmt.Fprintf(w, "- %s: %s tokens\n", contributor.Label, formatTokenCount(contributor.Tokens)) - case "context_pressure": + case tokensKindContextPressure: if contextInfo != nil { fmt.Fprintf(w, "- %s: %d%% of %s tokens\n", contributor.Label, contextInfo.Percent, formatTokenCount(contextInfo.WindowSize)) } diff --git a/cmd/entire/cli/sessions.go b/cmd/entire/cli/sessions.go index f773476f70..84305de73d 100644 --- a/cmd/entire/cli/sessions.go +++ b/cmd/entire/cli/sessions.go @@ -156,8 +156,8 @@ func writeWholeDocumentJSONTranscript(ctx context.Context, w io.Writer, r io.Rea func newSessionsCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "session", - Aliases: []string{"sessions"}, + Use: cmdSession, + Aliases: []string{cmdSessionsAlias}, Short: "Manage agent sessions tracked by Entire", Long: `View and manage agent sessions tracked by Entire. @@ -325,7 +325,7 @@ func newListCmd() *cobra.Command { var jsonFlag bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List all sessions", Long: `List all sessions tracked by Entire, including ended sessions. diff --git a/cmd/entire/cli/settings/settings_test.go b/cmd/entire/cli/settings/settings_test.go index 499e2573f2..f2a8792247 100644 --- a/cmd/entire/cli/settings/settings_test.go +++ b/cmd/entire/cli/settings/settings_test.go @@ -167,7 +167,7 @@ func TestLoad_AcceptsValidKeys(t *testing.T) { if settings.SummaryGeneration.Provider != "claude-code" { t.Errorf("expected summary_generation.provider 'claude-code', got %q", settings.SummaryGeneration.Provider) } - if settings.SummaryGeneration.Model != "sonnet" { //nolint:goconst // test literal + if settings.SummaryGeneration.Model != "sonnet" { t.Errorf("expected summary_generation.model 'sonnet', got %q", settings.SummaryGeneration.Model) } if settings.Redaction == nil { diff --git a/cmd/entire/cli/setup.go b/cmd/entire/cli/setup.go index f354f9693e..8d41dd9af2 100644 --- a/cmd/entire/cli/setup.go +++ b/cmd/entire/cli/setup.go @@ -2293,7 +2293,7 @@ func isCompletionConfigured(rcFile string) bool { } // appendShellCompletion adds the completion line to the rc file. -func appendShellCompletion(rcFile, completionLine string) error { +func appendShellCompletion(rcFile, completionLine string) (err error) { if err := os.MkdirAll(filepath.Dir(rcFile), 0o700); err != nil { return fmt.Errorf("creating directory: %w", err) } @@ -2302,10 +2302,17 @@ func appendShellCompletion(rcFile, completionLine string) error { if err != nil { return fmt.Errorf("opening file: %w", err) } - defer f.Close() + // Close reports a failed flush on a writable handle, so discarding it would + // drop the append while this function returned nil — the caller then tells + // the user completion is installed when the rc file never received the line. + // A write error already in flight is the more specific one, so it wins. + defer func() { + if cerr := f.Close(); cerr != nil && err == nil { + err = fmt.Errorf("closing file: %w", cerr) + } + }() - _, err = f.WriteString("\n" + shellCompletionComment + "\n" + completionLine + "\n") - if err != nil { + if _, err := f.WriteString("\n" + shellCompletionComment + "\n" + completionLine + "\n"); err != nil { return fmt.Errorf("writing completion: %w", err) } return nil diff --git a/cmd/entire/cli/status.go b/cmd/entire/cli/status.go index 5150d2bee0..889b257b29 100644 --- a/cmd/entire/cli/status.go +++ b/cmd/entire/cli/status.go @@ -38,7 +38,7 @@ func newStatusCmd() *cobra.Command { var jsonFlag bool cmd := &cobra.Command{ - Use: "status", + Use: cmdStatus, Short: "Show Entire status", Long: "Show whether Entire is currently enabled or disabled", RunE: func(cmd *cobra.Command, _ []string) error { @@ -332,15 +332,56 @@ const checkpointSyncSourceDedicated = "dedicated" // drift. Everything here reads local state only (settings, .git/config, local // refs, the push queue) — status must stay network-free. type checkpointSyncInfo struct { + // PushDisabled reflects the explicit automatic-push setting, not every + // possible reason checkpoint sync might fail. + // + // It suppresses nothing else in this struct, because nothing else is a + // push promise: reads keep working (push_sessions gates only the pre-push + // hook), the unpushed count is the only signal that checkpoint data is + // not reaching the elected destination, and the remote-configuration + // diagnostics explain read behavior too. + // + // What it does instead is switch the QUESTION these fields answer, from + // "where would checkpoints go" to "where do they come from" — so it + // changes more than phrasing. With a checkpoint_remote configured, the + // verdict behind Remote and Source is then the FETCH side's rather than + // the push side's, and in a repo whose two URLs have different owners + // that is a different VALUE, not a different wording. (With none + // configured both answer the elected remote, and only the wording + // differs.) ReadFallback and ReadSourceUnknown exist only while it is + // set. + PushDisabled bool // Remote is the elected git remote name, or the org/repo slug in - // dedicated checkpoint_remote mode. Empty when nothing resolved (no - // remotes configured, or the fail-closed case). + // dedicated checkpoint_remote mode. Empty when nothing resolved: no + // remotes configured, or the fail-closed case — except that with pushing + // disabled a failed election can still leave the dedicated slug here, + // since reads fail open and the store may serve them with nothing + // elected. Remote string // Source is config|observed|default|sole|first (resolver values) or // "dedicated". Source string // Err is the fail-closed misconfiguration message from the resolver. Err string + // ReadSourceUnknown reports that the fetch-side probe failed, so nothing + // is known about where reads resolve and no read source is named. Set + // only while pushing is disabled, where that probe is consulted at all. + ReadSourceUnknown bool + // ReadFallback is the remote checkpoint READS fall open to when the + // election failed, so Err is set and nothing was elected. Deliberately + // not folded into Remote: that field means "the elected remote", and on + // this path there is none — reporting a fallback there would misstate + // checkpoint_sync_remote to JSON consumers. + // + // TWO preconditions, and its absence means whichever did not hold. + // Pushing must be disabled — with pushing enabled the headline is the + // broken setting and the user's next move is to fix it, so that output is + // left as it was. And the dedicated store must not be what serves reads: + // a configured checkpoint_remote the fetch side confirms is reported + // through Remote/Source instead (this field names a git remote), and a + // failed probe through ReadSourceUnknown. So empty does NOT mean "reads + // fall open to nothing". + ReadFallback string // Unpushed approximates checkpoints not yet on the sync destination; 0 // when none, when counting failed, or when the count would be a lie // (dedicated URL mode on the git-branch backend). @@ -353,7 +394,49 @@ type checkpointSyncInfo struct { IgnoredReason string } +// resolveDedicatedReadSource records where checkpoint READS land when the +// configured checkpoint_remote is what serves them. Used by both paths that +// name a read source, so the two cannot answer the question differently — the +// asymmetry between them is what this function exists to remove. +// +// lead is the read candidate whose FETCH url joins origin in the ownership +// vote: the elected remote, or "" when the election failed and reads fall +// open to origin alone. +// +// Reports whether it settled the answer — the dedicated store serves reads +// (Remote/Source), or the probe failed so nothing is known +// (ReadSourceUnknown). False means reads resolve to the caller's own +// candidate, which the caller names. +func resolveDedicatedReadSource(ctx context.Context, s *EntireSettings, lead string, info *checkpointSyncInfo) bool { + cr := s.GetCheckpointRemote() + if cr == nil { + return false + } + authoritative, err := checkpointremote.ReadsDedicatedStore(ctx, lead) + switch { + case err != nil: + // Not the same as false. False means reads resolve somewhere else, + // so the caller's candidate is the answer; an error means no read URL + // resolves at all — reachable with a configured checkpoint_remote and + // no remote named origin, since the dedicated derivation is from + // origin. Naming a candidate there would report a working read source + // for a repo whose checkpoint reads fail. + logging.Debug(ctx, "checkpoint read source probe failed; status omits the read source", + slog.String("error", err.Error())) + info.ReadSourceUnknown = true + return true + case authoritative: + info.Remote = cr.Repo + info.Source = checkpointSyncSourceDedicated + return true + default: + return false + } +} + func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpointSyncInfo { + info := checkpointSyncInfo{PushDisabled: s.IsPushSessionsDisabled()} + elected, err := strategy.ResolveCheckpointSyncRemote(ctx) if err != nil { // Fail-closed: checkpoint_push_remote names a remote that does not @@ -363,23 +446,59 @@ func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpoin // configured, the gate's dedicated exemption may still sync checkpoint // data even while this fail-closed warning is shown, since there is no // elected remote left to probe PushURL against here. - return checkpointSyncInfo{Err: err.Error()} + info.Err = err.Error() + // Reads fail OPEN where the election failed closed (see + // strategy.CheckpointReadRemotes), so something is probably still + // serving them: the dedicated store when one is configured and the + // fetch side owns it, otherwise the fail-open candidate. Each is + // asked of its own resolver rather than reproduced here. Both re-run + // the election, which is why this is on the error path only, and both + // stay local-only like the rest of status. + if info.PushDisabled && !resolveDedicatedReadSource(ctx, s, "", &info) { + info.ReadFallback = strategy.LeadCheckpointReadRemote(ctx) + } + return info } if elected.Name == "" { - return checkpointSyncInfo{} // no remotes configured: show nothing + return info // no remotes configured: only report disabled pushing, if set } - // Dedicated checkpoint_remote mode is reported only when PushURL derives - // an eligible URL for the elected remote, mirroring the pre-push - // exemption (ps.hasCheckpointURL); otherwise the gate applies normal - // single-remote sync, so status reports that instead. PushURL is - // local-only; never call resolvePushSettings here — its follow-up - // metadata fetch dials, and status must stay network-free. + // Dedicated checkpoint_remote mode is reported only when the direction + // this line describes actually resolves to it; otherwise the gate applies + // normal single-remote sync, so status reports that instead. + // + // Which direction that is depends on push_sessions, and the two are + // separate questions. With pushing enabled the line names a push + // destination, so PushURL decides, mirroring the pre-push exemption + // (ps.hasCheckpointURL). With pushing disabled it names a READ source, + // which is the fetch side's call over a different ownership identity set + // — origin plus the candidate's FETCH url, not its push urls — so a + // remote whose two urls have different owners is eligible on one side + // only, and asking the wrong side reports a store reads do not use. + // + // Both probes are local-only; never call resolvePushSettings here — its + // follow-up metadata fetch dials, and status must stay network-free. // Accepted divergence: a real push to a different named remote may derive // PushURL differently than this elected-remote probe does. if cr := s.GetCheckpointRemote(); cr != nil { - if _, enabled, purlErr := checkpointremote.PushURL(ctx, elected.Name); purlErr == nil && enabled { - info := checkpointSyncInfo{Remote: cr.Repo, Source: checkpointSyncSourceDedicated} + dedicated := false + if info.PushDisabled { + if resolveDedicatedReadSource(ctx, s, elected.Name, &info) { + if info.ReadSourceUnknown { + return info + } + dedicated = true + } + } else if _, enabled, purlErr := checkpointremote.PushURL(ctx, elected.Name); purlErr == nil { + // The push side may fall soft to the elected remote because + // resolvePushSettings degrades the same way; the read side may + // not, which is why the helper above distinguishes error from + // false and this branch does not need to. + dedicated = enabled + } + if dedicated { + info.Remote = cr.Repo + info.Source = checkpointSyncSourceDedicated // The unpushed counter is meaningful here only on the git-refs // backend (push-queue length is local and accurate). The // git-branch comparison is omitted: pushes to a raw URL update @@ -392,11 +511,9 @@ func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpoin } } - info := checkpointSyncInfo{ - Remote: elected.Name, - Source: string(elected.Source), - Unpushed: countUnpushedCheckpointsForStatus(ctx, elected.Name), - } + info.Remote = elected.Name + info.Source = string(elected.Source) + info.Unpushed = countUnpushedCheckpointsForStatus(ctx, elected.Name) // A configured checkpoint_remote that did not enable above is being // ignored. When the ownership check is what rejected it, say so: this is // the one trust-gate rejection a user otherwise experiences only as @@ -405,10 +522,23 @@ func computeCheckpointSyncInfo(ctx context.Context, s *EntireSettings) checkpoin // (origin + push URLs of the elected remote), while a fetch votes with its // read candidate, so a push-only owner mismatch shows "not in use" here // even though a lead-less fetch still resolves the checkpoint remote. - if s.GetCheckpointRemote() != nil { + if cr := s.GetCheckpointRemote(); cr != nil { if repo, reason, inherited := checkpointremote.InheritedCheckpointRemote(ctx, s, elected.Name); inherited { info.IgnoredRemote = repo info.IgnoredReason = reason + } else if info.PushDisabled { + // That verdict votes with the push identity set, so it accepts a + // store the fetch side declined — and with pushing disabled the + // fetch side is the one that decided the line above. Without this + // the configured store is reported by nothing at all, which is + // the silent-ignore the warning exists to prevent. + // + // No reason is given: the fetch side returns a verdict and not a + // cause, and its false covers ownership, an unparseable origin + // URL and an unmappable protocol alike, so naming one would be a + // guess. The causes are logged where they are decided. + info.IgnoredRemote = cr.Repo + info.IgnoredReason = "checkpoint reads do not resolve to it (see .entire/logs for the reason)" } } return info @@ -426,30 +556,68 @@ func countUnpushedCheckpointsForStatus(ctx context.Context, remoteName string) i return n } -// writeCheckpointSyncLines appends the checkpoint sync destination line (and -// the unpushed counter, when non-zero) to the enabled status block. Rendered -// whenever something resolved: an elected remote, a dedicated store, or the -// fail-closed misconfiguration. No remotes configured -> no lines. +// writeCheckpointSyncLines reports the checkpoint sync destination (and the +// unpushed counter, when non-zero) in the enabled status block, prefixed by the +// disabled-pushing line when automatic pushing is off. No remotes configured +// means no destination line either way. +// +// Every phrase that promises a push is conditioned on info.PushDisabled: with +// pushing off the elected remote is still the read source and the counter still +// reports local-only data, so the lines are reworded rather than dropped — +// status is the only surface that names either. func writeCheckpointSyncLines(ctx context.Context, b *strings.Builder, s *EntireSettings, sty statusStyles) { info := computeCheckpointSyncInfo(ctx, s) + destination := "\n Checkpoints sync to: " + if info.PushDisabled { + b.WriteString("\n Automatic checkpoint pushing: disabled") + b.WriteString(sty.render(sty.dim, " (push_sessions=false)")) + // Names the remote reads resolve to FIRST, not the whole chain: + // CheckpointReadRemotes also appends origin as a legacy tier when it + // is configured and is not already the elected remote. Rendering the + // chain would mean either restating its rule here (which then drifts + // from the resolver) or a second election call, and the label does + // not claim exclusivity — "read from", never "only". + destination = "\n Checkpoints read from: " + } + // The misconfiguration warning is emitted independently of the read + // source below, not as one arm of the same switch: with pushing disabled + // a failed election does not stop reads, so the two can both have + // something to say and one must not shadow the other. + if info.Err != "" { + b.WriteString("\n") + // A fail-closed election is not a push failure when nothing is being + // pushed: name the misconfiguration without claiming a lost sync. + if info.PushDisabled { + b.WriteString(sty.render(sty.yellow, " ! Checkpoint remote configuration: "+info.Err)) + } else { + b.WriteString(sty.render(sty.yellow, " ! Checkpoints NOT syncing: "+info.Err)) + } + } switch { - case info.Err != "": + case info.ReadSourceUnknown: b.WriteString("\n") - b.WriteString(sty.render(sty.yellow, " ! Checkpoints NOT syncing: "+info.Err)) - case info.Remote == "": - return + b.WriteString(sty.render(sty.yellow, " ! Could not determine where checkpoints are read from")) case info.Source == checkpointSyncSourceDedicated: - b.WriteString("\n Checkpoints sync to: ") + b.WriteString(destination) b.WriteString(sty.render(sty.cyan, "dedicated checkpoint remote ("+info.Remote+")")) - default: - b.WriteString("\n Checkpoints sync to: ") + case info.Remote != "": + b.WriteString(destination) b.WriteString(sty.render(sty.cyan, info.Remote)) + // Both suffixes describe how the remote was ELECTED, which is what + // picks the read source too, so they hold with pushing disabled. switch info.Source { case string(strategy.SyncRemoteSourceConfig): b.WriteString(sty.render(sty.dim, " (set by checkpoint_push_remote)")) case string(strategy.SyncRemoteSourceObserved): b.WriteString(sty.render(sty.dim, " (follows your branch's push destination)")) } + case info.ReadFallback != "": + // Same label as a resolved read source — to the reader it is one + // question, "where do checkpoints come from" — with the suffix + // saying this one was not chosen, it was fallen back to. + b.WriteString(destination) + b.WriteString(sty.render(sty.cyan, info.ReadFallback)) + b.WriteString(sty.render(sty.dim, " (fallback; nothing was elected)")) } if info.IgnoredRemote != "" { b.WriteString("\n") @@ -466,13 +634,30 @@ func writeCheckpointSyncLines(ctx context.Context, b *strings.Builder, s *Entire // formatUnpushedCheckpointsLine phrases the unpushed counter. Dedicated URL // mode has no git remote to name (and only reaches here on the git-refs // backend), so it drops the remote-name phrasing. +// +// With pushing disabled the count is not pending anything, so the future tense +// goes — but the phrasing must not overclaim in the other direction either. +// Unpushed is measured against the ELECTED destination only (a tracking-ref +// comparison on git-branch, the push queue on git-refs), which says nothing +// about whether these checkpoints reached some other remote earlier; the read +// chain's legacy origin tier is exactly that case, and stale tracking state +// over-reports too. So it says what the number supports — not on that one +// destination — and never that the data exists nowhere else. Getting this +// backwards would falsely reassure someone asking whether checkpoint data has +// left the machine. func formatUnpushedCheckpointsLine(info checkpointSyncInfo) string { - noun := "checkpoints" + noun := nounCheckpoints pronoun := "they sync" if info.Unpushed == 1 { - noun = "checkpoint" + noun = nounCheckpoint pronoun = "it syncs" } + if info.PushDisabled { + if info.Source == checkpointSyncSourceDedicated { + return fmt.Sprintf("%d %s not pushed to the checkpoint remote", info.Unpushed, noun) + } + return fmt.Sprintf("%d %s not on %s", info.Unpushed, noun, info.Remote) + } if info.Source == checkpointSyncSourceDedicated { return fmt.Sprintf("%d %s not yet pushed", info.Unpushed, noun) } @@ -851,13 +1036,38 @@ type statusJSON struct { // CodexHooks reports effective discovery/trust warnings separately from // current-checkout installation and freshness semantics. CodexHooks *codexHooksStatusJSON `json:"codex_hooks,omitempty"` + // CheckpointPushDisabled is emitted only when Entire is enabled and the + // effective push_sessions setting is false. Its absence does not guarantee + // that a push can succeed. + // + // No other field is omitted or suppressed when it is set: read it as + // requalifying the fields below rather than removing them. + // CheckpointSyncRemote is then the remote checkpoints are READ from, and + // the error and ignored-remote diagnostics apply to reads as well. + // UnpushedCheckpoints keeps its own meaning either way: checkpoints not + // present on THAT destination, which is not a claim that they exist + // nowhere else. + CheckpointPushDisabled bool `json:"checkpoint_push_disabled,omitempty"` // CheckpointSyncRemote is the elected checkpoint sync remote name, or the // org/repo slug in dedicated checkpoint_remote mode. Deliberately not named // checkpoint_remote, which is the existing GitHub-coupled setting. CheckpointSyncRemote string `json:"checkpoint_sync_remote,omitempty"` CheckpointSyncRemoteSource string `json:"checkpoint_sync_remote_source,omitempty"` // config|observed|default|sole|first|dedicated CheckpointSyncError string `json:"checkpoint_sync_error,omitempty"` // fail-closed message - UnpushedCheckpoints int `json:"unpushed_checkpoints,omitempty"` + // CheckpointReadSourceUnknown reports that the read-source probe failed, + // so no read source could be determined. Emitted only alongside + // checkpoint_push_disabled, and checkpoint_sync_remote is then absent + // rather than guessed at. + CheckpointReadSourceUnknown bool `json:"checkpoint_read_source_unknown,omitempty"` + // CheckpointReadFallback is the remote reads fall open to when the + // election failed, so checkpoint_sync_error is set. Emitted only + // alongside checkpoint_push_disabled, and only when the dedicated store + // is not what serves reads — if it is, checkpoint_sync_remote carries it + // (with source "dedicated") even though nothing was elected, and a failed + // probe sets checkpoint_read_source_unknown instead. Absence here does + // not mean reads fall open to nothing. + CheckpointReadFallback string `json:"checkpoint_read_fallback,omitempty"` + UnpushedCheckpoints int `json:"unpushed_checkpoints,omitempty"` // CheckpointRemoteIgnored/-Reason report a configured checkpoint_remote the // ownership check rejected as inherited with the clone (reads and pushes // fall back to the elected remote). Mirrors the text path's warning line. @@ -947,9 +1157,12 @@ func runStatusJSON(ctx context.Context, w io.Writer) error { // Same computation as the text path (writeCheckpointSyncLines); // empty fields drop out via omitempty when nothing resolved. syncInfo := computeCheckpointSyncInfo(ctx, s) + result.CheckpointPushDisabled = syncInfo.PushDisabled result.CheckpointSyncRemote = syncInfo.Remote result.CheckpointSyncRemoteSource = syncInfo.Source result.CheckpointSyncError = syncInfo.Err + result.CheckpointReadFallback = syncInfo.ReadFallback + result.CheckpointReadSourceUnknown = syncInfo.ReadSourceUnknown result.UnpushedCheckpoints = syncInfo.Unpushed result.CheckpointRemoteIgnored = syncInfo.IgnoredRemote result.CheckpointRemoteIgnoredReason = syncInfo.IgnoredReason diff --git a/cmd/entire/cli/status_style.go b/cmd/entire/cli/status_style.go index 732f87d989..795a637f68 100644 --- a/cmd/entire/cli/status_style.go +++ b/cmd/entire/cli/status_style.go @@ -76,7 +76,7 @@ func shouldUseColor(w io.Writer) bool { func getTerminalWidth(w io.Writer) int { // Try the output writer first if f, ok := w.(*os.File); ok { - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return min(width, 80) } } @@ -86,7 +86,7 @@ func getTerminalWidth(w io.Writer) int { if f == nil { continue } - if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { //nolint:gosec // G115: uintptr->int is safe for fd + if width, _, err := term.GetSize(int(f.Fd())); err == nil && width > 0 { return min(width, 80) } } @@ -119,6 +119,16 @@ func totalTokens(tu *agent.TokenUsage) int { return total } +// Row labels reused across the explain output's diagnostic tables. +const ( + explainLabelCause = "cause" + explainLabelCheckpoints = "checkpoints" + explainLabelMessage = "message" + explainLabelSession = "session" + explainLabelStderr = "stderr" + explainLabelTry = "try" +) + // explainRow is one entry in a metadata block: dim label + plain value. type explainRow struct { Label string diff --git a/cmd/entire/cli/status_test.go b/cmd/entire/cli/status_test.go index 8500458185..773e5d205a 100644 --- a/cmd/entire/cli/status_test.go +++ b/cmd/entire/cli/status_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "runtime" "slices" + "strconv" "strings" "testing" "time" @@ -26,6 +27,7 @@ import ( "github.com/entireio/cli/redact" "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing" "github.com/go-git/go-git/v6/plumbing/object" ) @@ -2288,6 +2290,466 @@ func TestRunStatus_PrintsBothReviewAndInvestigation(t *testing.T) { // --- Checkpoint sync visibility (single-remote gate observability) --- +// originRemoteName is the primary remote these fixtures create. Named rather +// than repeated: it is also the remote the checkpoint election defaults to and +// the read chain's legacy tier, so the string carries meaning here. +const originRemoteName = "origin" + +func TestRunStatus_CheckpointPushDisabled(t *testing.T) { + testCheckpointPushDisabledFork(t, false) +} + +func TestRunStatusJSON_CheckpointPushDisabled(t *testing.T) { + testCheckpointPushDisabledFork(t, true) +} + +func testCheckpointPushDisabledFork(t *testing.T, jsonOutput bool) { + t.Helper() + // setupTestRepo changes CWD and git-config isolation changes process env. + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, `{"enabled":true,"strategy_options":{"push_sessions":false,"checkpoint_push_remote":"fork"}}`) + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/org/repo.git") + testutil.AddRemote(t, ".", "fork", "https://github.com/user/repo.git") + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + assertCheckpointPushDisabledStatus(t, jsonOutput, false, "fork", "") +} + +// assertCheckpointPushDisabledStatus asserts the disabled-pushing report: +// wantRemote is the elected read destination status must still name, wantFallback +// the remote reads fall open to when the election failed instead (at most one is +// set; both "" means nothing resolved), and no phrasing may survive that +// promises a push. +func assertCheckpointPushDisabledStatus(t *testing.T, jsonOutput, detailed bool, wantRemote, wantFallback string) { + t.Helper() + var stdout bytes.Buffer + if err := runStatus(context.Background(), &stdout, detailed, jsonOutput); err != nil { + t.Fatalf("runStatus() error = %v", err) + } + t.Logf("status output:\n%s", stdout.String()) + if jsonOutput { + var result map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + for _, key := range []string{"enabled", "checkpoint_push_disabled"} { + raw, exists := result[key] + if !exists { + t.Errorf("missing %s", key) + continue + } + var value bool + if err := json.Unmarshal(raw, &value); err != nil || !value { + t.Errorf("%s = %s, want true (decode error: %v)", key, raw, err) + } + } + // The elected remote is still the read source, so the destination + // field stays populated; only the phrasing around it changes. A + // fail-open read source is reported separately, because nothing was + // elected — checkpoint_sync_remote must stay absent for it. + wantJSON := func(key, want string) { + if want != "" { + want = `"` + want + `"` + } + if got := string(result[key]); got != want { + t.Errorf("%s = %s, want %s: %s", key, got, want, stdout.String()) + } + } + wantJSON("checkpoint_sync_remote", wantRemote) + wantJSON("checkpoint_read_fallback", wantFallback) + return + } + if !strings.Contains(stdout.String(), "Automatic checkpoint pushing: disabled (push_sessions=false)") { + t.Error("missing automatic checkpoint pushing disabled message") + } + for _, unwanted := range []string{"Checkpoints sync to:", "Checkpoints NOT syncing:", "not yet", "next 'git push"} { + if strings.Contains(stdout.String(), unwanted) { + t.Errorf("disabled pushing must not show %q", unwanted) + } + } + named := wantRemote + wantFallback + if named != "" && !strings.Contains(stdout.String(), "Checkpoints read from: ") { + t.Errorf("disabled pushing must still name the read source %q: %s", named, stdout.String()) + } + if named == "" && strings.Contains(stdout.String(), "Checkpoints read from: ") { + t.Errorf("nothing resolved, so no read source may be named: %s", stdout.String()) + } + if (wantFallback != "") != strings.Contains(stdout.String(), "(fallback; nothing was elected)") { + t.Errorf("fallback suffix must appear only for a failed election (want fallback %q): %s", wantFallback, stdout.String()) + } +} + +// formatUnpushedCheckpointsLine is pure, so its four branches are pinned here +// rather than through a repo fixture. What the counter must never say with +// pushing disabled is that the data is local-only: Unpushed compares against +// the elected destination alone, so it cannot establish that checkpoints exist +// nowhere else, and claiming otherwise would falsely reassure someone asking +// whether checkpoint data has left their machine. +func TestFormatUnpushedCheckpointsLine(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + info checkpointSyncInfo + want string + }{ + {"pushing_enabled", checkpointSyncInfo{Remote: "origin", Source: "default", Unpushed: 2}, + "2 checkpoints not yet on origin — they sync with your next 'git push origin'"}, + {"pushing_enabled_dedicated", checkpointSyncInfo{Remote: "org/cp", Source: checkpointSyncSourceDedicated, Unpushed: 2}, + "2 checkpoints not yet pushed"}, + {"pushing_disabled", checkpointSyncInfo{PushDisabled: true, Remote: "origin", Source: "default", Unpushed: 1}, + "1 checkpoint not on origin"}, + {"pushing_disabled_dedicated", checkpointSyncInfo{PushDisabled: true, Remote: "org/cp", Source: checkpointSyncSourceDedicated, Unpushed: 2}, + "2 checkpoints not pushed to the checkpoint remote"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + if got := formatUnpushedCheckpointsLine(tc.info); got != tc.want { + t.Errorf("formatUnpushedCheckpointsLine() = %q, want %q", got, tc.want) + } + }) + } +} + +func TestRunStatus_CheckpointPushDisabledDestinations(t *testing.T) { + // These subtests mutate CWD and environment and cannot run in parallel. + for _, backend := range []string{"git-branch", "git-refs"} { + for _, tc := range []struct { + name string + options string + origin string + // wantRemote is the read destination status must still report with + // pushing disabled; wantSource is its provenance ("" when nothing + // resolved). Populating them is the point: the elected remote stays + // the checkpoint read source when push_sessions is false. + wantRemote string + wantSource string + // wantFallback is the remote reads fall OPEN to when the election + // failed closed, reported instead of (never alongside) wantRemote. + wantFallback string + // fork is the fetch URL of a second remote named "fork" ("" = none); + // forkPush overrides its push URL, so the two can have different + // owners and the push- and fetch-side ownership votes disagree. + fork string + forkPush string + // originName renames the primary remote ("" = "origin"). A + // configured checkpoint_remote with no origin makes the read + // probe fail outright, which is not the same as it resolving + // elsewhere. + originName string + // wantReadUnknown pins that the probe failure is what suppressed + // the read source, rather than the row passing because nothing + // resolved for some other reason. + wantReadUnknown bool + // wantErr is the fail-closed election, and wantIgnored the + // "checkpoint_remote not in use" warning. Stated per row rather + // than matched on tc.name: several rows now reach each, and name + // matching silently mis-expects every row added afterwards. + wantErr bool + wantIgnored bool + }{ + {name: "origin", origin: "https://github.com/org/repo.git", wantRemote: "origin", wantSource: "default"}, + { + name: "explicit_fork", options: `,"checkpoint_push_remote":"fork"`, + origin: "https://github.com/org/repo.git", fork: "https://github.com/user/repo.git", + wantRemote: "fork", wantSource: "config", + }, + { + name: "dedicated", options: `,"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/org/repo.git", + wantRemote: "org/checkpoints", wantSource: checkpointSyncSourceDedicated, + }, + { + name: "inherited_dedicated_rejected", options: `,"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/other/repo.git", + wantRemote: "origin", wantSource: "default", wantIgnored: true, + }, + {name: "no_remotes"}, + { + name: "missing_configured_remote", options: `,"checkpoint_push_remote":"gone"`, + origin: "https://github.com/org/repo.git", wantFallback: "origin", wantErr: true, + }, + // Push- and fetch-side ownership disagree. Both require EVERY + // identity to be owned by the checkpoint repo's owner, but over + // different sets: origin plus fork's PUSH urls (all "org", so the + // push side certifies the dedicated store) versus origin plus + // fork's FETCH url ("other", so the fetch side rejects it). With + // pushing disabled the line names a read source, so the fetch + // side decides and the elected remote is reported; deciding it + // with PushURL named a store reads never use. + { + name: "dedicated_rejected_by_fetch_owner", + options: `,"checkpoint_push_remote":"fork","checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/org/repo.git", + fork: "https://github.com/other/fork.git", forkPush: "https://github.com/org/fork.git", + wantRemote: "fork", wantSource: "config", wantIgnored: true, + }, + // A failed election does not stop reads: they fail open, so the + // dedicated store still serves them when the fetch side owns it, + // and status reports it rather than nothing. The elected-remote + // fields carry it — nothing was elected, but "dedicated" was + // never an election — so wantFallback stays empty. + { + name: "missing_configured_remote_with_dedicated", + options: `,"checkpoint_push_remote":"gone","checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/org/repo.git", + wantRemote: "org/checkpoints", wantSource: checkpointSyncSourceDedicated, wantErr: true, + }, + // Same, but the fetch side does not own the store, so reads land + // on the fail-open candidate and that is what is reported. + { + name: "missing_configured_remote_with_disowned_dedicated", + options: `,"checkpoint_push_remote":"gone","checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/other/repo.git", + wantFallback: "origin", wantErr: true, + }, + // The read probe cannot resolve any URL (a configured + // checkpoint_remote derives from origin, and there is none), so + // no read source is named rather than the sole remote being + // reported as one while reads fail. + { + name: "dedicated_without_origin", options: `,"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, + origin: "https://github.com/org/repo.git", originName: "upstream", + wantReadUnknown: true, + }, + } { + t.Run(backend+"/"+tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, `{"enabled":true,"strategy_options":{"push_sessions":false`+tc.options+`},"checkpoints":{"primary":{"type":"`+backend+`"}}}`) + if tc.origin != "" { + name := tc.originName + if name == "" { + name = originRemoteName + } + testutil.AddRemote(t, ".", name, tc.origin) + } + if tc.fork != "" { + testutil.AddRemote(t, ".", "fork", tc.fork) + if tc.forkPush != "" { + testutil.RunGit(t, ".", "remote", "set-url", "--push", "fork", tc.forkPush) + } + } + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + queue := checkpoint.NewPushQueue(filepath.Join(cwd, ".git")) + if backend == "git-refs" { + for _, ref := range []string{"refs/entire/checkpoints/aa/bb0000000001", "refs/entire/checkpoints/aa/bb0000000002"} { + if err := queue.Enqueue(plumbing.ReferenceName(ref)); err != nil { + t.Fatal(err) + } + } + } + before, err := queue.Peek() + if err != nil { + t.Fatal(err) + } + s, err := LoadEntireSettings(t.Context()) + if err != nil { + t.Fatal(err) + } + info := computeCheckpointSyncInfo(t.Context(), s) + // The two "not in use" rows reach that warning by different + // routes: the origin-owner row through the push-side verdict, + // the fetch-rejected row through the read-side branch that + // exists because that verdict ACCEPTS what the fetch side + // declines. Without the second, a configured store serving no + // reads is reported by nothing. (Not the divergence + // documented as accepted on InheritedCheckpointRemote — that + // one is the opposite direction.) + if (info.Err != "") != tc.wantErr || (info.IgnoredRemote != "") != tc.wantIgnored { + t.Errorf("unexpected remote diagnostics: %+v", info) + } + if !info.PushDisabled || info.Remote != tc.wantRemote || info.Source != tc.wantSource { + t.Errorf("disabled pushing must still resolve the read destination %q/%q: %+v", tc.wantRemote, tc.wantSource, info) + } + // Reads fail open where the election failed closed, so the + // fallback is reported — but never through Remote, which + // means "elected" and has nothing to name here. + if info.ReadFallback != tc.wantFallback { + t.Errorf("read fallback = %q, want %q: %+v", info.ReadFallback, tc.wantFallback, info) + } + if info.ReadSourceUnknown != tc.wantReadUnknown { + t.Errorf("read source unknown = %v, want %v: %+v", info.ReadSourceUnknown, tc.wantReadUnknown, info) + } + // The counter is the only signal that checkpoint data is not + // reaching the destination, so it survives disabled pushing + // wherever it is meaningful at all. Dedicated URL mode on + // git-branch has no tracking ref to compare against and stays + // uncounted, as it does with pushing enabled. + // The fail-closed path returns before counting: with no + // election there is no destination to count against, even + // when the dedicated store still serves reads. + wantCount := !tc.wantErr && tc.wantRemote != "" && + (tc.wantSource != checkpointSyncSourceDedicated || backend != "git-branch") + if (info.Unpushed > 0) != wantCount { + t.Errorf("unpushed count = %d, want counted: %v (%+v)", info.Unpushed, wantCount, info) + } + for _, jsonOutput := range []bool{false, true} { + assertCheckpointPushDisabledStatus(t, jsonOutput, false, tc.wantRemote, tc.wantFallback) + after, err := queue.Peek() + if err != nil { + t.Fatal(err) + } + if !slices.Equal(before, after) { + t.Errorf("status changed pending queue: before=%v after=%v", before, after) + } + } + }) + } + } +} + +// Not parallel: setupTestRepo changes CWD and isolates process environment. +func TestRunStatus_CheckpointDiagnosticsWithPushDisabled(t *testing.T) { + for _, disabled := range []bool{false, true} { + for _, tc := range []struct { + name, options, key, text string + }{ + {"inherited", `"checkpoint_remote":{"provider":"github","repo":"org/checkpoints"}`, "checkpoint_remote_ignored", "org/checkpoints"}, + {"missing", `"checkpoint_push_remote":"gone"`, "checkpoint_sync_error", `checkpoint_push_remote "gone"`}, + } { + label := "pushing_enabled/" + tc.name + pushSetting := strconv.FormatBool(!disabled) + if disabled { + label = "pushing_disabled/" + tc.name + } + t.Run(label, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, `{"enabled":true,"strategy_options":{"push_sessions":`+pushSetting+`,`+tc.options+`}}`) + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/other/repo.git") + for _, mode := range []struct { + name string + detailed, json bool + }{{"text", false, false}, {"detailed", true, false}, {"json", false, true}} { + t.Run(mode.name, func(t *testing.T) { + var out bytes.Buffer + if err := runStatus(t.Context(), &out, mode.detailed, mode.json); err != nil { + t.Fatal(err) + } + if mode.json { + var result map[string]json.RawMessage + if err := json.Unmarshal(out.Bytes(), &result); err != nil { + t.Fatal(err) + } + var diagnostic string + if err := json.Unmarshal(result[tc.key], &diagnostic); err != nil || !strings.Contains(diagnostic, tc.text) { + t.Errorf("missing %s diagnostic: %s", tc.key, out.String()) + } + if tc.name == "inherited" && !strings.Contains(string(result["checkpoint_remote_ignored_reason"]), "differs from checkpoint owner") { + t.Errorf("missing rejection reason: %s", out.String()) + } + if disabled { + var pushDisabled bool + if err := json.Unmarshal(result["checkpoint_push_disabled"], &pushDisabled); err != nil || !pushDisabled { + t.Errorf("missing disabled flag: %s", out.String()) + } + } + return + } + if !strings.Contains(out.String(), tc.text) || (tc.name == "inherited" && !strings.Contains(out.String(), "is not in use:")) { + t.Errorf("missing remote diagnostic: %s", out.String()) + } + if disabled && (!strings.Contains(out.String(), "Automatic checkpoint pushing: disabled") || strings.Contains(out.String(), "Checkpoints NOT syncing:")) { + t.Errorf("diagnostic must coexist with disabled pushing, not claim a push failure: %s", out.String()) + } + }) + } + }) + } + } +} + +func TestRunStatus_CheckpointPushDisabledSettingsPrecedence(t *testing.T) { + for _, tc := range []struct { + name string + shared string + local string + disabled bool + }{ + {"local_false_overrides_shared_true", `{"enabled":true,"strategy_options":{"push_sessions":true}}`, `{"strategy_options":{"push_sessions":false}}`, true}, + {"local_true_overrides_shared_false", `{"enabled":true,"strategy_options":{"push_sessions":false}}`, `{"strategy_options":{"push_sessions":true}}`, false}, + {"absent", `{"enabled":true}`, "", false}, + {"explicit_true", `{"enabled":true,"strategy_options":{"push_sessions":true}}`, "", false}, + } { + t.Run(tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + writeSettings(t, tc.shared) + if tc.local != "" { + testutil.WriteFile(t, ".", ".entire/settings.local.json", tc.local) + } + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/org/repo.git") + head := checkpointSyncTestCommit(t, "a.txt", "one") + testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, head) + for _, jsonOutput := range []bool{false, true} { + if tc.disabled { + // --detailed and --json are mutually exclusive: only text + // exercises the detailed settings view. + assertCheckpointPushDisabledStatus(t, jsonOutput, !jsonOutput, "origin", "") + continue + } + var stdout bytes.Buffer + if err := runStatus(context.Background(), &stdout, false, jsonOutput); err != nil { + t.Fatal(err) + } + if jsonOutput { + var result map[string]json.RawMessage + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if _, exists := result["checkpoint_push_disabled"]; exists { + t.Errorf("enabled pushing must omit checkpoint_push_disabled: %s", stdout.String()) + } + if string(result["checkpoint_sync_remote"]) != `"origin"` || string(result["checkpoint_sync_remote_source"]) != `"default"` || string(result["unpushed_checkpoints"]) != "1" { + t.Errorf("enabled pushing lost existing destination/counter fields: %s", stdout.String()) + } + } else if strings.Contains(stdout.String(), "Automatic checkpoint pushing:") || !strings.Contains(stdout.String(), "Checkpoints sync to: origin") || !strings.Contains(stdout.String(), "next 'git push origin'") { + t.Errorf("enabled pushing changed existing output: %s", stdout.String()) + } + } + }) + } +} + +func TestRunStatus_CheckpointPushDisabledAbsentWithoutEnabledEntire(t *testing.T) { + for _, tc := range []struct { + name string + settings string + }{ + {"entire_disabled", `{"enabled":false,"strategy_options":{"push_sessions":false}}`}, + {"not_set_up", ""}, + {"invalid_settings", `{"enabled":true,"strategy_options":{"push_sessions":false},`}, + } { + t.Run(tc.name, func(t *testing.T) { + testutil.IsolateGitConfigEnv(t) + setupTestRepo(t) + if tc.settings != "" { + writeSettings(t, tc.settings) + } + for _, jsonOutput := range []bool{false, true} { + var stdout bytes.Buffer + err := runStatus(context.Background(), &stdout, false, jsonOutput) + if tc.name == "invalid_settings" && !jsonOutput { + if err == nil || !strings.Contains(err.Error(), "failed to load settings") { + t.Fatalf("invalid text settings error = %v", err) + } + } else if err != nil { + t.Fatal(err) + } + if strings.Contains(stdout.String(), "checkpoint_push_disabled") || strings.Contains(stdout.String(), "Automatic checkpoint pushing:") { + t.Errorf("inactive Entire must omit disabled-pushing status: %s", stdout.String()) + } + } + }) + } +} + // checkpointSyncTestCommit creates a commit in the cwd test repo and returns // its hash. setupTestRepo leaves the repo without commits, and both the v1 // counter and ref updates need at least one. @@ -2469,7 +2931,7 @@ func TestRunStatus_CheckpointSyncDedicated_GitBranch_NoCounter(t *testing.T) { writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`) // Same owner ("org") as checkpoint_remote and a parseable GitHub URL, so // PushURL derivation succeeds locally and dedicated mode is verified. - testutil.AddRemote(t, ".", "origin", "https://github.com/org/repo.git") + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/org/repo.git") head := checkpointSyncTestCommit(t, "a.txt", "one") // A local v1 branch exists, but in dedicated URL mode on the git-branch // backend the tracking-ref comparison would permanently read "all @@ -2494,7 +2956,7 @@ func TestRunStatus_CheckpointSyncDedicated_GitRefs_QueueCounter(t *testing.T) { testutil.IsolateGitConfigEnv(t) setupTestRepo(t) writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}, "checkpoints": {"primary": {"type": "git-refs"}}}`) - testutil.AddRemote(t, ".", "origin", "https://github.com/org/repo.git") + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/org/repo.git") checkpointSyncTestCommit(t, "a.txt", "one") cwd, err := os.Getwd() @@ -2589,7 +3051,7 @@ func TestRunStatusJSON_CheckpointSync_Dedicated(t *testing.T) { testutil.IsolateGitConfigEnv(t) setupTestRepo(t) writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`) - testutil.AddRemote(t, ".", "origin", "https://github.com/org/repo.git") + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/org/repo.git") var stdout bytes.Buffer if err := runStatus(context.Background(), &stdout, false, true); err != nil { @@ -2621,7 +3083,7 @@ func TestRunStatus_CheckpointSyncDedicated_IneligibleFallsBackToElected(t *testi writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`) // Remote owner "other" != checkpoint_remote owner "org": fork detection // rejects the dedicated store at push time. - testutil.AddRemote(t, ".", "origin", "https://github.com/other/repo.git") + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/other/repo.git") checkpointSyncTestCommit(t, "a.txt", "one") second := checkpointSyncTestCommit(t, "b.txt", "two") testutil.GitUpdateRef(t, ".", "refs/heads/"+paths.MetadataBranchName, second) @@ -2649,7 +3111,7 @@ func TestRunStatusJSON_CheckpointSync_DedicatedIneligible(t *testing.T) { testutil.IsolateGitConfigEnv(t) setupTestRepo(t) writeSettings(t, `{"enabled": true, "strategy_options": {"checkpoint_remote": {"provider": "github", "repo": "org/checkpoints"}}}`) - testutil.AddRemote(t, ".", "origin", "https://github.com/other/repo.git") + testutil.AddRemote(t, ".", originRemoteName, "https://github.com/other/repo.git") var stdout bytes.Buffer if err := runStatus(context.Background(), &stdout, false, true); err != nil { diff --git a/cmd/entire/cli/strategy/agent_resolution_test.go b/cmd/entire/cli/strategy/agent_resolution_test.go index 7086377b0f..e129b7b815 100644 --- a/cmd/entire/cli/strategy/agent_resolution_test.go +++ b/cmd/entire/cli/strategy/agent_resolution_test.go @@ -7,9 +7,12 @@ import ( "testing" "github.com/entireio/cli/cmd/entire/cli/agent" + "github.com/entireio/cli/cmd/entire/cli/agent/types" + "github.com/entireio/cli/cmd/entire/cli/session" // Register agents so AgentForTranscriptPath can resolve them. _ "github.com/entireio/cli/cmd/entire/cli/agent/claudecode" + _ "github.com/entireio/cli/cmd/entire/cli/agent/codex" _ "github.com/entireio/cli/cmd/entire/cli/agent/cursor" "github.com/stretchr/testify/assert" @@ -33,6 +36,148 @@ func withClaudeSessionDir(t *testing.T) string { return filepath.Join(sessionDir, "abc-123.jsonl") } +func withCodexSessionDir(t *testing.T) string { + t.Helper() + sessionDir := filepath.Join(t.TempDir(), "codex-sessions") + t.Setenv("ENTIRE_TEST_CODEX_SESSION_DIR", sessionDir) + return filepath.Join(sessionDir, "2026", "09", "02", "rollout.jsonl") +} + +func TestInitializeSession_CodexCorrection_CleanOwners(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + codexTranscript := withCodexSessionDir(t) + ctx := context.Background() + s := &ManualCommitStrategy{} + for _, owner := range []struct { + name string + agentType types.AgentType + }{ + {name: "known", agentType: agent.AgentTypeClaudeCode}, + {name: "unknown"}, + } { + sessionID := "codex-clean-" + owner.name + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + state.AgentType = owner.agentType + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, codexTranscript, "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + assertCleanCodexCorrection(t, state) + } +} + +func TestHasPriorSubagentEvidence(t *testing.T) { + t.Parallel() + + incomplete := false + tests := []struct { + name string + set func(*SessionState) + }{ + {"inventory", func(s *SessionState) { s.SubagentInventory = []session.SubagentInventoryEntry{{AgentID: "child"}} }}, + {"task record", func(s *SessionState) { + s.TaskRecords = []session.TaskRecord{{ToolUseID: "task", AgentID: "child-from-task"}} + }}, + {"ledger", func(s *SessionState) { s.SubagentLedgerVersion = 4 }}, + {"session child total", func(s *SessionState) { s.TokenUsage = &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{}} }}, + {"checkpoint child total", func(s *SessionState) { s.CheckpointTokenUsage = &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{}} }}, + {"baseline", func(s *SessionState) { s.SubagentTokensBaseline = &agent.TokenUsage{} }}, + {"inventory incomplete", func(s *SessionState) { s.SubagentInventoryComplete = &incomplete }}, + {"baseline incomplete", func(s *SessionState) { s.SubagentTokensBaselineComplete = &incomplete }}, + {"session usage incomplete", func(s *SessionState) { s.TokenUsage = &agent.TokenUsage{SubagentTokensComplete: &incomplete} }}, + {"checkpoint usage incomplete", func(s *SessionState) { s.CheckpointTokenUsage = &agent.TokenUsage{SubagentTokensComplete: &incomplete} }}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + state := &SessionState{} + tt.set(state) + require.True(t, hasPriorSubagentEvidence(state)) + }) + } + require.False(t, hasPriorSubagentEvidence(&SessionState{})) +} + +func TestTransitionSessionToCodex_PreservesDirtyEvidence(t *testing.T) { + t.Parallel() + + incomplete := false + state := &SessionState{ + TaskRecords: []session.TaskRecord{{ToolUseID: "task", AgentID: "child"}}, + SubagentLedgerVersion: 4, + SubagentTokensBaseline: &agent.TokenUsage{InputTokens: 3}, + TokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 1}}, + CheckpointTokenUsage: &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 2}}, + SubagentInventoryComplete: &incomplete, + SubagentTokensBaselineComplete: &incomplete, + } + + transitionSessionToCodex(state) + require.Equal(t, uint64(4), state.SubagentLedgerVersion) + require.NotNil(t, state.FindSubagentInventory("child")) + require.Nil(t, state.SubagentTokensBaseline) + assertIncompleteUsage(t, state.TokenUsage) + assertIncompleteUsage(t, state.CheckpointTokenUsage) + require.False(t, *state.SubagentInventoryComplete) + require.False(t, *state.SubagentTokensBaselineComplete) +} + +func assertIncompleteUsage(t *testing.T, usage *agent.TokenUsage) { + t.Helper() + require.NotNil(t, usage) + require.Nil(t, usage.SubagentTokens) + require.NotNil(t, usage.SubagentTokensComplete) + require.False(t, *usage.SubagentTokensComplete) +} + +func TestInitializeSession_CodexCallerWithoutTranscriptDoesNotMigrateAccounting(t *testing.T) { + dir := setupGitRepo(t) + t.Chdir(dir) + + ctx := context.Background() + sessionID := "codex-unproven-owner" + s := &ManualCommitStrategy{} + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeClaudeCode, "", "first", "")) + state, err := s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + state.AgentType = "" + state.SubagentTokensBaseline = &agent.TokenUsage{InputTokens: 7} + require.NoError(t, s.saveSessionState(ctx, state)) + + require.NoError(t, s.InitializeSession(ctx, sessionID, agent.AgentTypeCodex, "", "second", "")) + state, err = s.loadSessionState(ctx, sessionID) + require.NoError(t, err) + require.Equal(t, agent.AgentTypeCodex, state.AgentType) + require.NotNil(t, state.SubagentTokensBaseline) + require.Equal(t, 7, state.SubagentTokensBaseline.InputTokens, + "the firing hook alone must not rewrite legacy accounting evidence") +} + +func assertCleanCodexCorrection(t *testing.T, state *SessionState) { + t.Helper() + require.Equal(t, agent.AgentTypeCodex, state.AgentType) + require.Empty(t, state.SubagentInventory) + require.Zero(t, state.SubagentLedgerVersion) + require.Nil(t, state.SubagentTokensBaseline) + require.NotNil(t, state.SubagentInventoryComplete) + require.True(t, *state.SubagentInventoryComplete) + require.NotNil(t, state.SubagentTokensBaselineComplete) + require.True(t, *state.SubagentTokensBaselineComplete) + require.NotNil(t, state.TokenUsage) + require.Nil(t, state.TokenUsage.SubagentTokens) + require.NotNil(t, state.TokenUsage.SubagentTokensComplete) + require.True(t, *state.TokenUsage.SubagentTokensComplete) + require.NotNil(t, state.CheckpointTokenUsage) + require.Nil(t, state.CheckpointTokenUsage.SubagentTokens) + require.NotNil(t, state.CheckpointTokenUsage.SubagentTokensComplete) + require.True(t, *state.CheckpointTokenUsage.SubagentTokensComplete) +} + func TestResolveSessionAgentType_TranscriptPathBeatsHook(t *testing.T) { dir := setupGitRepo(t) t.Chdir(dir) diff --git a/cmd/entire/cli/strategy/cleanup.go b/cmd/entire/cli/strategy/cleanup.go index 47cace09c8..8ebeef8abc 100644 --- a/cmd/entire/cli/strategy/cleanup.go +++ b/cmd/entire/cli/strategy/cleanup.go @@ -35,6 +35,10 @@ const ( CleanupTypeRedactCache CleanupType = "redact-cache" ) +// cleanAllReason marks an item discovered by the unfiltered sweep, as opposed +// to one selected by an orphan or staleness rule. +const cleanAllReason = "clean all" + // CleanupItem represents an item that can be cleaned up. type CleanupItem struct { Type CleanupType @@ -501,7 +505,7 @@ func ListAllItems(ctx context.Context) ([]CleanupItem, error) { cleanupItems = append(cleanupItems, CleanupItem{ Type: CleanupTypeShadowBranch, ID: branch, - Reason: "clean all", + Reason: cleanAllReason, }) } @@ -520,7 +524,7 @@ func ListAllItems(ctx context.Context) ([]CleanupItem, error) { cleanupItems = append(cleanupItems, CleanupItem{ Type: CleanupTypeSessionState, ID: state.SessionID, - Reason: "clean all", + Reason: cleanAllReason, }) } @@ -531,7 +535,7 @@ func ListAllItems(ctx context.Context) ([]CleanupItem, error) { cleanupItems = append(cleanupItems, CleanupItem{ Type: CleanupTypeRedactCache, ID: checkpoint.RedactCacheDirName, - Reason: "clean all", + Reason: cleanAllReason, }) } } diff --git a/cmd/entire/cli/strategy/hook_managers_test.go b/cmd/entire/cli/strategy/hook_managers_test.go index e92e448355..d359eb8ded 100644 --- a/cmd/entire/cli/strategy/hook_managers_test.go +++ b/cmd/entire/cli/strategy/hook_managers_test.go @@ -55,7 +55,7 @@ func TestDetectHookManagers_Lefthook(t *testing.T) { if len(managers) != 1 { t.Fatalf("expected 1 manager, got %d", len(managers)) } - if managers[0].Name != "Lefthook" { //nolint:goconst // test assertion, not a magic string + if managers[0].Name != "Lefthook" { t.Errorf("expected Lefthook, got %s", managers[0].Name) } if managers[0].ConfigPath != "lefthook.yml" { diff --git a/cmd/entire/cli/strategy/hooks.go b/cmd/entire/cli/strategy/hooks.go index c5b5dd6def..2c6ba7acba 100644 --- a/cmd/entire/cli/strategy/hooks.go +++ b/cmd/entire/cli/strategy/hooks.go @@ -34,8 +34,12 @@ const goosWindows = "windows" const chainComment = "# Chain: run pre-existing hook" const missingEntireGitHookWarning = "[entire] Entire CLI is enabled but not installed or not on PATH. Skipping Entire Git hook; continuing. Installation guide: https://docs.entire.io/cli/installation#installation-methods" +// postRewriteHook is named on its own because the rewrite hook is the one +// Entire branches on by name (see below). +const postRewriteHook = "post-rewrite" + // gitHookNames are the git hooks managed by Entire CLI -var gitHookNames = []string{"prepare-commit-msg", "commit-msg", "post-commit", "post-rewrite", "pre-push"} +var gitHookNames = []string{"prepare-commit-msg", "commit-msg", "post-commit", postRewriteHook, "pre-push"} // ManagedGitHookNames returns the list of git hooks managed by Entire CLI. // This is useful for tests that need to manipulate hooks. @@ -595,7 +599,7 @@ func buildHookSpecs(cmdPrefix string) []hookSpec { `, entireHookMarker, postCommitCmd), }, { - name: "post-rewrite", + name: postRewriteHook, content: fmt.Sprintf(`#!/bin/sh # %s # Post-rewrite hook: remap session linkage after amend/rebase rewrites @@ -858,7 +862,7 @@ func RemoveGitHook(ctx context.Context) (int, error) { // generateChainedContent appends a chain call to the base hook content, // so the pre-existing hook (backed up to .pre-entire) is called after our hook. func generateChainedContent(baseContent, hookName string) string { - if hookName == "post-rewrite" { + if hookName == postRewriteHook { return generatePostRewriteChainedContent(baseContent) } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation.go b/cmd/entire/cli/strategy/manual_commit_condensation.go index 771b6d6443..92e52a00f1 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation.go @@ -1125,30 +1125,20 @@ func hasTokenUsageData(usage *agent.TokenUsage) bool { return hasTokenUsageData(usage.SubagentTokens) } -// fillMissingSubagentTokensFrom fills destination's SubagentTokens from source -// only when destination has none, returning a copy. It exists because the -// transcript recompute runs with subagentsDir="" and so always yields nil -// SubagentTokens (see extractSessionData), which would otherwise replace a total -// already computed. -// -// Condensation sources the fill from state.CheckpointTokenUsage, which SaveStep -// already rescoped to this window, so committed checkpoints stay summable rather -// than each re-reporting the session total. It copies rather than mutating a -// value that session state may also reference. +// fillMissingSubagentTokensFrom fills absent child coverage from this checkpoint +// window. Explicit completeness (including incomplete coverage) is authoritative. func fillMissingSubagentTokensFrom(destination, source *agent.TokenUsage) *agent.TokenUsage { - if destination == nil || destination.SubagentTokens != nil || source == nil || source.SubagentTokens == nil { + if destination == nil || destination.SubagentTokens != nil || destination.SubagentTokensComplete != nil { return destination } - filled := *destination - filled.SubagentTokens = source.SubagentTokens - return &filled + return replaceSubagentTokensFrom(destination, source) } -// replaceSubagentTokensFrom replaces destination's nested total with source's. -// Session state needs the latest cumulative snapshot even when checkpoint usage -// carries a baseline-scoped delta; checkpoint metadata keeps the delta. +// replaceSubagentTokensFrom replaces child coverage with the source snapshot. +// Session state needs cumulative coverage while checkpoint metadata keeps its +// window delta. Copying preserves both values when they share a pointer. func replaceSubagentTokensFrom(destination, source *agent.TokenUsage) *agent.TokenUsage { - if source == nil || source.SubagentTokens == nil { + if source == nil || (source.SubagentTokens == nil && source.SubagentTokensComplete == nil) { return destination } if destination == nil { @@ -1156,6 +1146,14 @@ func replaceSubagentTokensFrom(destination, source *agent.TokenUsage) *agent.Tok } filled := *destination filled.SubagentTokens = source.SubagentTokens + filled.SubagentTokensComplete = nil + if source.SubagentTokensComplete != nil { + complete := *source.SubagentTokensComplete + filled.SubagentTokensComplete = &complete + if !complete { + filled.SubagentTokens = nil + } + } return &filled } @@ -1180,7 +1178,11 @@ func applyBackfilledSessionTokenUsage(ctx context.Context, ag agent.Agent, state // sessionStateBackfillTokenUsage returns the best session-level token usage to // persist in session state after condensation. func sessionStateBackfillTokenUsage(ctx context.Context, ag agent.Agent, agentType types.AgentType, transcript []byte, checkpointUsage *agent.TokenUsage) *agent.TokenUsage { - if agentType == agent.AgentTypeCopilotCLI && len(transcript) > 0 { + if agentType != agent.AgentTypeCopilotCLI { + return nil + } + + if len(transcript) > 0 { fullSessionUsage := agent.CalculateTokenUsage(ctx, ag, transcript, 0, "") if hasTokenUsageData(fullSessionUsage) { return fullSessionUsage @@ -1188,11 +1190,7 @@ func sessionStateBackfillTokenUsage(ctx context.Context, ag agent.Agent, agentTy logging.Debug(ctx, "copilot-cli: full-session token read produced no data, falling back to checkpoint usage") } - if agentType == agent.AgentTypeCopilotCLI && hasTokenUsageData(checkpointUsage) { - return checkpointUsage - } - - if checkpointUsage != nil && checkpointUsage.InputTokens > 0 { + if hasTokenUsageData(checkpointUsage) { return checkpointUsage } diff --git a/cmd/entire/cli/strategy/manual_commit_condensation_test.go b/cmd/entire/cli/strategy/manual_commit_condensation_test.go index 7915b10af7..f3308fed72 100644 --- a/cmd/entire/cli/strategy/manual_commit_condensation_test.go +++ b/cmd/entire/cli/strategy/manual_commit_condensation_test.go @@ -258,6 +258,97 @@ func TestCountTranscriptItems_CursorEmpty(t *testing.T) { } } +func TestNonCopilotCondensationPreservesSessionTokenUsage(t *testing.T) { + t.Parallel() + + sessionUsage := &agent.TokenUsage{ + InputTokens: 10_000, + OutputTokens: 999, + CacheReadTokens: 2_000, + CacheCreationTokens: 500, + APICallCount: 42, + } + state := &SessionState{ + SessionID: "s1", + AgentType: agent.AgentTypeClaudeCode, + TokenUsage: sessionUsage, + } + checkpointUsage := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 10, + CacheReadTokens: 20, + CacheCreationTokens: 5, + APICallCount: 1, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Equal(t, sessionUsage, state.TokenUsage) +} + +func TestNonCopilotCondensationDoesNotPromoteCheckpointUsage(t *testing.T) { + t.Parallel() + + state := &SessionState{ + SessionID: "s1", + AgentType: agent.AgentTypeClaudeCode, + } + checkpointUsage := &agent.TokenUsage{ + InputTokens: 100, + OutputTokens: 10, + APICallCount: 1, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Nil(t, state.TokenUsage) +} + +func TestCondenseSessionByID_NonCopilotPreservesSessionTokenUsage(t *testing.T) { //nolint:paralleltest // uses t.Chdir + dir := setupGitRepo(t) + t.Chdir(dir) + + s := &ManualCommitStrategy{} + sessionID := "non-copilot-token-usage" + metadataDir := paths.SessionMetadataDirFromSessionID(sessionID) + + transcript := strings.Join([]string{ + `{"type":"human","uuid":"u1","message":{"content":"hello"}}`, + `{"type":"assistant","uuid":"u2","message":{"id":"msg_001","usage":{"input_tokens":100,"output_tokens":10}}}`, + }, "\n") + "\n" + testutil.WriteFile(t, dir, filepath.Join(metadataDir, paths.TranscriptFileName), transcript) + testutil.WriteFile(t, dir, "test.txt", "agent content") + + sessionUsage := &agent.TokenUsage{ + InputTokens: 10_000, + OutputTokens: 999, + CacheReadTokens: 2_000, + CacheCreationTokens: 500, + APICallCount: 42, + } + require.NoError(t, s.SaveStep(t.Context(), StepContext{ + SessionID: sessionID, + ModifiedFiles: []string{"test.txt"}, + MetadataDir: metadataDir, + CommitMessage: "Checkpoint 1", + AuthorName: "Test", + AuthorEmail: "test@test.com", + AgentType: agent.AgentTypeClaudeCode, + TokenUsage: sessionUsage, + })) + + state, err := s.loadSessionState(t.Context(), sessionID) + require.NoError(t, err) + require.Equal(t, sessionUsage, state.TokenUsage) + + require.NoError(t, s.CondenseSessionByID(t.Context(), sessionID)) + + state, err = s.loadSessionState(t.Context(), sessionID) + require.NoError(t, err) + require.Equal(t, sessionUsage, state.TokenUsage) + require.Nil(t, state.CheckpointTokenUsage) +} + func TestSessionStateBackfillTokenUsage_CopilotUsesZeroInputSessionAggregate(t *testing.T) { t.Parallel() @@ -284,6 +375,52 @@ func TestSessionStateBackfillTokenUsage_CopilotUsesZeroInputSessionAggregate(t * require.Equal(t, 3, backfillUsage.APICallCount) } +func TestSessionStateBackfillTokenUsage_CopilotFallsBackToCheckpointUsage(t *testing.T) { + t.Parallel() + + checkpointUsage := &agent.TokenUsage{ + OutputTokens: 25, + APICallCount: 1, + } + + backfillUsage := sessionStateBackfillTokenUsage( + t.Context(), nil, agent.AgentTypeCopilotCLI, nil, checkpointUsage, + ) + + require.Same(t, checkpointUsage, backfillUsage) +} + +func TestApplyBackfilledSessionTokenUsage_CopilotPreservesSubagentTotal(t *testing.T) { + t.Parallel() + + checkpointUsage := &agent.TokenUsage{ + OutputTokens: 25, + APICallCount: 1, + } + state := &SessionState{ + AgentType: agent.AgentTypeCopilotCLI, + TokenUsage: &agent.TokenUsage{ + InputTokens: 1_000, + SubagentTokens: &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 50, + APICallCount: 2, + }, + }, + } + + applyBackfilledSessionTokenUsage(t.Context(), nil, state, nil, checkpointUsage) + + require.Equal(t, 25, state.TokenUsage.OutputTokens) + require.Equal(t, 1, state.TokenUsage.APICallCount) + require.Equal(t, &agent.TokenUsage{ + InputTokens: 200, + OutputTokens: 50, + APICallCount: 2, + }, state.TokenUsage.SubagentTokens) + require.Nil(t, checkpointUsage.SubagentTokens) +} + func TestSessionStateBackfillModel_PiReadsModelFromTranscript(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/strategy/manual_commit_git.go b/cmd/entire/cli/strategy/manual_commit_git.go index 4aa41d7cda..dbd68101a5 100644 --- a/cmd/entire/cli/strategy/manual_commit_git.go +++ b/cmd/entire/cli/strategy/manual_commit_git.go @@ -46,6 +46,7 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e } mutErr := MutateSessionState(ctx, sessionID, func(state *SessionState) error { + invalidateStaleSubagentSnapshot(&step, state) _, migrateSpan := perf.Start(ctx, "migrate_shadow_branch") if _, _, err := s.migrateShadowBranchIfNeeded(ctx, repo, state); err != nil { migrateSpan.RecordError(err) @@ -147,9 +148,23 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e // that would double-subtract and (via clampSubtract) shrink or zero a // real subagent total. Recomputing from the session-wide cumulative // is idempotent regardless of whether this step carried a snapshot. - if state.CheckpointTokenUsage != nil { - state.CheckpointTokenUsage.SubagentTokens = types.SubtractTokenUsage( - state.TokenUsage.SubagentTokens, state.SubagentTokensBaseline) + if state.CheckpointTokenUsage != nil && state.TokenUsage != nil { + complete := state.TokenUsage.SubagentTokensComplete + switch { + case complete != nil && !*complete: + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, false) + case state.SubagentTokensBaselineComplete != nil && !*state.SubagentTokensBaselineComplete: + // A known-incomplete baseline cannot yield an exact delta, even + // when the current inventory has become complete again. + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, false) + default: + state.CheckpointTokenUsage.SubagentTokens = types.SubtractTokenUsage( + state.TokenUsage.SubagentTokens, state.SubagentTokensBaseline) + if complete != nil { + value := *complete + state.CheckpointTokenUsage.SubagentTokensComplete = &value + } + } } } @@ -180,6 +195,16 @@ func (s *ManualCommitStrategy) SaveStep(ctx context.Context, step StepContext) e return mutErr } +func invalidateStaleSubagentSnapshot(step *StepContext, state *SessionState) { + if step.SubagentLedgerVersion == nil || step.TokenUsage == nil || + state.SubagentLedgerVersion == *step.SubagentLedgerVersion { + return + } + // Keep valid main-agent deltas but never persist a child aggregate + // computed against an older authoritative inventory. + step.TokenUsage = types.WithClearedSubagentTokens(step.TokenUsage, false) +} + // ensureSessionInitialized creates the session state file if it doesn't yet // exist (or has empty BaseCommit). Idempotent: the existence check and the // create both happen inside initializeSession's session gate so a concurrent @@ -449,7 +474,7 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag } if existing == nil { // Return a copy to avoid sharing the pointer - return &agent.TokenUsage{ + result := &agent.TokenUsage{ InputTokens: incoming.InputTokens, CacheCreationTokens: incoming.CacheCreationTokens, CacheReadTokens: incoming.CacheReadTokens, @@ -457,6 +482,14 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag APICallCount: incoming.APICallCount, SubagentTokens: incoming.SubagentTokens, } + if incoming.SubagentTokensComplete != nil { + complete := *incoming.SubagentTokensComplete + result.SubagentTokensComplete = &complete + if !complete { + result.SubagentTokens = nil + } + } + return result } // Accumulate values @@ -469,7 +502,13 @@ func accumulateTokenUsage(existing, incoming *agent.TokenUsage) *agent.TokenUsag // Replace (not add) subagent tokens: incoming.SubagentTokens is already // the cumulative total as of this step, so the latest snapshot supersedes // whatever was recorded before rather than stacking on top of it. - if incoming.SubagentTokens != nil { + if incoming.SubagentTokensComplete != nil { + complete := *incoming.SubagentTokensComplete + existing.SubagentTokensComplete = &complete + // An explicit inventory result is authoritative, including exact empty + // (complete with nil) and unavailable (incomplete with nil). + existing.SubagentTokens = incoming.SubagentTokens + } else if incoming.SubagentTokens != nil { existing.SubagentTokens = incoming.SubagentTokens } diff --git a/cmd/entire/cli/strategy/manual_commit_hooks.go b/cmd/entire/cli/strategy/manual_commit_hooks.go index 7c7c9990ab..b4a0b4bbdd 100644 --- a/cmd/entire/cli/strategy/manual_commit_hooks.go +++ b/cmd/entire/cli/strategy/manual_commit_hooks.go @@ -2529,6 +2529,52 @@ func correctSessionAgentType(ctx context.Context, currentType types.AgentType, t return owner.Type(), true } +// transitionSessionToCodex initializes Codex child-accounting state when a +// transcript path proves that an existing session is Codex-owned. The +// transition deliberately happens in the same session-state mutation as the +// AgentType correction so readers can never observe a Codex session with +// legacy, ambiguous child-coverage markers. +func transitionSessionToCodex(state *SessionState) { + dirty := hasPriorSubagentEvidence(state) + complete := !dirty + + // Task records predate the durable Codex inventory. Preserve their child + // identities without calling RegisterSubagent: this is a migration of known + // evidence, not a new observation, so it must not advance the ledger again. + for _, record := range state.TaskRecords { + if record.AgentID == "" || state.FindSubagentInventory(record.AgentID) != nil { + continue + } + state.SubagentInventory = append(state.SubagentInventory, session.SubagentInventoryEntry{ + AgentID: record.AgentID, + DeclaredTranscriptPath: record.DeclaredTranscriptPath, + }) + } + + state.TokenUsage = types.WithClearedSubagentTokens(state.TokenUsage, complete) + state.CheckpointTokenUsage = types.WithClearedSubagentTokens(state.CheckpointTokenUsage, complete) + state.SubagentTokensBaseline = nil + state.SubagentInventoryComplete = &complete + state.SubagentTokensBaselineComplete = &complete +} + +func hasPriorSubagentEvidence(state *SessionState) bool { + if len(state.SubagentInventory) > 0 || len(state.TaskRecords) > 0 || state.SubagentLedgerVersion != 0 || state.SubagentTokensBaseline != nil { + return true + } + if state.TokenUsage != nil && (state.TokenUsage.SubagentTokens != nil || explicitlyIncomplete(state.TokenUsage.SubagentTokensComplete)) { + return true + } + if state.CheckpointTokenUsage != nil && (state.CheckpointTokenUsage.SubagentTokens != nil || explicitlyIncomplete(state.CheckpointTokenUsage.SubagentTokensComplete)) { + return true + } + return explicitlyIncomplete(state.SubagentInventoryComplete) || explicitlyIncomplete(state.SubagentTokensBaselineComplete) +} + +func explicitlyIncomplete(complete *bool) bool { + return complete != nil && !*complete +} + // InitializeSession creates session state for a new session or updates an existing one. // This implements the optional SessionInitializer interface. // Called during UserPromptSubmit to allow git hooks to detect active sessions. @@ -2586,17 +2632,22 @@ func (s *ManualCommitStrategy) InitializeSession(ctx context.Context, sessionID } state.TurnID = turnID.String() - // Update AgentType when it isn't set yet, or when the transcript path - // proves we're a different agent than the one stored. - if state.AgentType == "" && resolvedAgentType != "" { - state.AgentType = resolvedAgentType - } else if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed { + // A transcript path is stronger evidence than both a stored owner and + // the current hook. Apply transcript-proven corrections first, including + // the empty-owner case, so a Codex correction can initialize all of its + // child-accounting markers atomically. + if corrected, changed := correctSessionAgentType(ctx, state.AgentType, transcriptPath); changed { logging.Info(logging.WithComponent(ctx, "hooks"), "corrected session agent type from transcript path", slog.String("session_id", sessionID), slog.String("from", string(state.AgentType)), slog.String("to", string(corrected)), slog.String("transcript_path", transcriptPath)) + if corrected == agent.AgentTypeCodex && state.AgentType != agent.AgentTypeCodex { + transitionSessionToCodex(state) + } state.AgentType = corrected + } else if state.AgentType == "" && resolvedAgentType != "" { + state.AgentType = resolvedAgentType } if model != "" { state.ModelName = model diff --git a/cmd/entire/cli/strategy/manual_commit_session.go b/cmd/entire/cli/strategy/manual_commit_session.go index cc6188a6c9..366a256584 100644 --- a/cmd/entire/cli/strategy/manual_commit_session.go +++ b/cmd/entire/cli/strategy/manual_commit_session.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/entireio/cli/cmd/entire/cli/agent" "github.com/entireio/cli/cmd/entire/cli/agent/types" "github.com/entireio/cli/cmd/entire/cli/checkpoint" "github.com/entireio/cli/cmd/entire/cli/checkpoint/id" @@ -682,6 +683,11 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. TranscriptPath: transcriptPath, LastPrompt: truncatePromptForStorage(userPrompt), } + if agentType == agent.AgentTypeCodex { + complete := true + state.SubagentInventoryComplete = &complete + state.SubagentTokensBaselineComplete = &complete + } // Take the gate, then re-check under lock. Without this re-check a // concurrent turn-start hook that wrote a richer state in the gap @@ -700,6 +706,41 @@ func (s *ManualCommitStrategy) initializeSession(ctx context.Context, repo *git. if existing != nil && existing.BaseCommit != "" { return nil } + if existing != nil && agentType == agent.AgentTypeCodex { + // Repair the partial state in place. A child hook can have recorded task + // content and accounting before the parent session initializes, so a + // fresh replacement would silently discard durable child state. + state = existing + state.CLIVersion = versioninfo.Version + state.BaseCommit = headHash + state.AttributionBaseCommit = headHash + state.WorktreePath = worktreePath + state.WorktreeID = worktreeID + if state.StartedAt.IsZero() { + state.StartedAt = now + } + state.LastInteractionTime = &now + state.TurnID = turnID.String() + state.AgentType = agentType + if model != "" { + state.ModelName = model + } + if transcriptPath != "" { + state.TranscriptPath = transcriptPath + } + if userPrompt != "" { + state.LastPrompt = truncatePromptForStorage(userPrompt) + } + if state.UntrackedFilesAtStart == nil { + state.UntrackedFilesAtStart = untrackedFiles + } + + // This is a repair, not an authoritative SessionStart inventory. Keep + // the ledger and token data but retain conservative coverage markers. + incomplete := false + state.SubagentInventoryComplete = &incomplete + state.SubagentTokensBaselineComplete = &incomplete + } return s.saveSessionState(ctx, state) } diff --git a/cmd/entire/cli/strategy/manual_commit_test.go b/cmd/entire/cli/strategy/manual_commit_test.go index 744e7b63bb..46d02e68c1 100644 --- a/cmd/entire/cli/strategy/manual_commit_test.go +++ b/cmd/entire/cli/strategy/manual_commit_test.go @@ -28,6 +28,67 @@ import ( const testTrailerCheckpointID id.CheckpointID = "a1b2c3d4e5f6" +func TestCodexInventoryInitialization(t *testing.T) { + dir := t.TempDir() + testutil.InitRepo(t, dir) + testutil.WriteFile(t, dir, "initial.txt", "initial\n") + testutil.GitAdd(t, dir, "initial.txt") + testutil.GitCommit(t, dir, "initial") + t.Chdir(dir) + + s := NewManualCommitStrategy() + repo, err := OpenRepository(context.Background()) + require.NoError(t, err) + defer repo.Close() + require.NoError(t, s.initializeSession(context.Background(), repo, "codex-inventory-new", agent.AgentTypeCodex, "", "", "")) + newState, err := s.loadSessionState(context.Background(), "codex-inventory-new") + require.NoError(t, err) + require.NotNil(t, newState.SubagentInventoryComplete) + assert.True(t, *newState.SubagentInventoryComplete) + require.NotNil(t, newState.SubagentTokensBaselineComplete) + assert.True(t, *newState.SubagentTokensBaselineComplete) + + incomplete := false + pendingAt := time.Now().UTC().Truncate(time.Second) + partialInventory := []session.SubagentInventoryEntry{{ + AgentID: "child-observed-before-parent", + ObservedTurnIDs: []string{"turn-pending", "turn-finalized"}, + FinalizedTurnIDs: []string{"turn-finalized"}, + }} + partialTokenUsage := &agent.TokenUsage{InputTokens: 100, SubagentTokens: &agent.TokenUsage{InputTokens: 60}, SubagentTokensComplete: &incomplete} + partialCheckpointUsage := &agent.TokenUsage{OutputTokens: 50, SubagentTokens: &agent.TokenUsage{OutputTokens: 30}, SubagentTokensComplete: &incomplete} + partialBaseline := &agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 40}, SubagentTokensComplete: &incomplete} + partialRecords := []session.TaskRecord{ + {ToolUseID: "child-live", AgentID: "child-observed-before-parent", StartedAt: pendingAt}, + {ToolUseID: "child-completed", AgentID: "child-observed-before-parent", StartedAt: pendingAt, CompletedAt: pendingAt.Add(time.Second)}, + } + require.NoError(t, s.saveSessionState(context.Background(), &SessionState{ + SessionID: "codex-inventory-partial", + StartedAt: time.Now(), + AgentType: agent.AgentTypeCodex, + SubagentInventory: partialInventory, + SubagentLedgerVersion: 9, + SubagentInventoryComplete: &incomplete, + SubagentTokensBaselineComplete: &incomplete, + TokenUsage: partialTokenUsage, + CheckpointTokenUsage: partialCheckpointUsage, + SubagentTokensBaseline: partialBaseline, + TaskRecords: partialRecords, + })) + require.NoError(t, s.initializeSession(context.Background(), repo, "codex-inventory-partial", agent.AgentTypeCodex, "", "", "")) + partial, err := s.loadSessionState(context.Background(), "codex-inventory-partial") + require.NoError(t, err) + assert.False(t, *partial.SubagentInventoryComplete, "partial-state repair must not promote unknown inventory coverage") + assert.False(t, *partial.SubagentTokensBaselineComplete) + assert.Equal(t, uint64(9), partial.SubagentLedgerVersion) + assert.Equal(t, partialInventory, partial.SubagentInventory) + assert.True(t, partial.HasTaskContent(), "repair must retain both live and completed-unmaterialized task content") + assert.Equal(t, partialRecords, partial.TaskRecords) + assert.Equal(t, partialTokenUsage, partial.TokenUsage) + assert.Equal(t, partialCheckpointUsage, partial.CheckpointTokenUsage) + assert.Equal(t, partialBaseline, partial.SubagentTokensBaseline) +} + // testTranscriptPromptResponse is a minimal transcript used across strategy tests. const testTranscriptPromptResponse = "{\"type\":\"human\",\"message\":{\"content\":\"test prompt\"}}\n{\"type\":\"assistant\",\"message\":{\"content\":\"test response\"}}\n" @@ -871,7 +932,7 @@ func TestShadowStrategy_PrepareCommitMsg_SkipsSessionWhenContentCheckFails(t *te func TestAddCheckpointTrailer_NoComment(t *testing.T) { // Test that addCheckpointTrailer adds trailer without any comment lines - message := "Test commit message\n" //nolint:goconst // already present in codebase + message := "Test commit message\n" result := addCheckpointTrailer(message, testTrailerCheckpointID) diff --git a/cmd/entire/cli/strategy/strategy.go b/cmd/entire/cli/strategy/strategy.go index d569722851..da9760389f 100644 --- a/cmd/entire/cli/strategy/strategy.go +++ b/cmd/entire/cli/strategy/strategy.go @@ -161,6 +161,11 @@ type StepContext struct { // TokenUsage contains the token usage for this checkpoint TokenUsage *agent.TokenUsage + + // SubagentLedgerVersion is the authoritative inventory version observed + // while token evidence was extracted. nil means no inventory snapshot; + // a pointer to zero is a valid snapshot before the first child is observed. + SubagentLedgerVersion *uint64 } // TaskStepContext contains all information needed for saving a task step checkpoint. diff --git a/cmd/entire/cli/strategy/subagent_tokens_test.go b/cmd/entire/cli/strategy/subagent_tokens_test.go index 27e20be8a3..98559f1024 100644 --- a/cmd/entire/cli/strategy/subagent_tokens_test.go +++ b/cmd/entire/cli/strategy/subagent_tokens_test.go @@ -52,6 +52,50 @@ func TestAccumulateTokenUsage_SubagentTokensReplacedNotSummed(t *testing.T) { require.Equal(t, 250, existing.SubagentTokens.OutputTokens, "SubagentTokens must be replaced, not summed") } +func TestAccumulateTokenUsage_ExplicitIncompleteClearsPriorChildTotal(t *testing.T) { + t.Parallel() + complete := true + incomplete := false + existing := &agent.TokenUsage{InputTokens: 3, SubagentTokens: &agent.TokenUsage{InputTokens: 9}, SubagentTokensComplete: &complete} + got := accumulateTokenUsage(existing, &agent.TokenUsage{OutputTokens: 4, SubagentTokensComplete: &incomplete}) + require.Nil(t, got.SubagentTokens) + require.NotNil(t, got.SubagentTokensComplete) + require.False(t, *got.SubagentTokensComplete) + require.Equal(t, 3, got.InputTokens) + require.Equal(t, 4, got.OutputTokens) +} + +func TestAccumulateTokenUsage_ExplicitEmptyReplacesPriorChildTotal(t *testing.T) { + t.Parallel() + complete := true + got := accumulateTokenUsage(&agent.TokenUsage{SubagentTokens: &agent.TokenUsage{InputTokens: 9}}, &agent.TokenUsage{SubagentTokensComplete: &complete}) + require.Nil(t, got.SubagentTokens) + require.NotNil(t, got.SubagentTokensComplete) + require.True(t, *got.SubagentTokensComplete) +} + +func TestInvalidateStaleSubagentSnapshot_ZeroVersion(t *testing.T) { + t.Parallel() + complete := true + zero := uint64(0) + step := StepContext{ + SubagentLedgerVersion: &zero, + TokenUsage: &agent.TokenUsage{ + InputTokens: 11, + SubagentTokens: &agent.TokenUsage{InputTokens: 7}, + SubagentTokensComplete: &complete, + }, + } + + invalidateStaleSubagentSnapshot(&step, &SessionState{SubagentLedgerVersion: 1}) + + require.NotNil(t, step.TokenUsage) + require.Equal(t, 11, step.TokenUsage.InputTokens, "main-agent evidence must survive invalidation") + require.Nil(t, step.TokenUsage.SubagentTokens) + require.NotNil(t, step.TokenUsage.SubagentTokensComplete) + require.False(t, *step.TokenUsage.SubagentTokensComplete) +} + // TestSaveStep_SubagentTokensNotDoubleCountedAcrossCheckpoints exercises the // real SaveStep path for both Claude Code and Factory AI Droid (the two // agents whose CalculateTotalTokenUsage implementations discover subagent IDs @@ -474,9 +518,6 @@ func TestCalculateLiveTranscriptTokenUsage_RescopesSubagentCumulativeTotal(t *te require.Equal(t, 200, state.TokenUsage.SubagentTokens.InputTokens, "session state must retain the cumulative snapshot for the next baseline") - applyBackfilledSessionTokenUsage(t.Context(), ag, state, mainTranscript, usage) - require.Equal(t, 200, state.TokenUsage.SubagentTokens.InputTokens, - "main-token backfill must not replace the cumulative snapshot with the checkpoint delta") state.RebaselineSubagentTokens() require.NoError(t, os.WriteFile(subagentPath, []byte(`{"type":"assistant","uuid":"a-sub","message":{"id":"msg_sub","type":"message","role":"assistant","content":[{"type":"text","text":"done"}],"usage":{"input_tokens":260,"output_tokens":35}}} `), 0o644)) @@ -583,15 +624,8 @@ func TestCondenseSessionByID_CapturesSubagentBaselineViaRealResetPath(t *testing metadataDir := ".entire/metadata/" + sessionID metadataDirAbs := filepath.Join(dir, metadataDir) require.NoError(t, os.MkdirAll(metadataDirAbs, 0o755)) - // The assistant line carries real usage data (message.id + usage). Real - // Claude Code transcripts always do, which makes sessionStateBackfillTokenUsage - // fire during condensation (its InputTokens > 0 branch) and overwrite - // state.TokenUsage with the transcript-recomputed value — which is computed - // with subagentsDir="" and therefore drops SubagentTokens. This is what makes - // this test guard the REAL condensation path: without preserving the - // cumulative subagent total across the backfill, resetCheckpointWindow would - // snapshot a nil baseline and the next checkpoint would re-report the full - // cumulative subagent total (finding 019f5ebf-a57e). + // Checkpoint-scoped transcript usage must not replace the cumulative session + // total here, so the reset can retain the subagent baseline. transcript := `{"type":"human","message":{"content":"do the thing"}} {"type":"assistant","uuid":"a1","message":{"id":"m1","usage":{"input_tokens":300,"output_tokens":150}}} ` @@ -671,11 +705,24 @@ func TestCondenseSessionByID_CapturesSubagentBaselineViaRealResetPath(t *testing require.Equal(t, 60, summary.TokenUsage.SubagentTokens.OutputTokens) } -// TestWithSubagentTokensFrom_DoesNotMutateInput guards the copy semantics directly. -// The condensation tests cannot: applyBackfilledSessionTokenUsage already hands back -// a copy on that path, so a mutate-in-place implementation passes them. Mutating -// would overwrite the session-wide cumulative with a window delta and make -// resetCheckpointWindow snapshot a too-small baseline for the next window. +// Session and checkpoint token snapshots can share pointers, so replacement must +// not mutate either input. +func TestSubagentCoverageSurvivesBackfill(t *testing.T) { + t.Parallel() + incomplete := false + destination := &agent.TokenUsage{InputTokens: 1, SubagentTokens: &agent.TokenUsage{InputTokens: 7}} + source := &agent.TokenUsage{SubagentTokensComplete: &incomplete} + got := replaceSubagentTokensFrom(destination, source) + require.Nil(t, got.SubagentTokens) + require.NotNil(t, got.SubagentTokensComplete) + require.False(t, *got.SubagentTokensComplete) + require.Equal(t, 7, destination.SubagentTokens.InputTokens) + require.Same(t, got, fillMissingSubagentTokensFrom(got, destination), "explicit incomplete coverage must not be filled from an older total") + filled := fillMissingSubagentTokensFrom(&agent.TokenUsage{InputTokens: 2}, source) + require.NotNil(t, filled.SubagentTokensComplete) + require.False(t, *filled.SubagentTokensComplete) +} + func TestFillMissingSubagentTokensFrom_DoesNotMutateInput(t *testing.T) { t.Parallel() diff --git a/cmd/entire/cli/telemetry/search_outcome.go b/cmd/entire/cli/telemetry/search_outcome.go index 0b9e2f9409..b3628841f6 100644 --- a/cmd/entire/cli/telemetry/search_outcome.go +++ b/cmd/entire/cli/telemetry/search_outcome.go @@ -26,8 +26,7 @@ const ( // has no query-serve route (search.ErrCellUnavailable). Distinct from // SearchErrClassCellSkip — these are the two "region" failure variants. SearchErrClassRegionUnavailable = "region_unavailable" - // SearchErrClassRepoUnavailable: cells answered but the repo is not - // searchable (not indexed, or not enabled for semantic search). + // SearchErrClassRepoUnavailable: cells answered but the repo is not indexed. SearchErrClassRepoUnavailable = "repo_unavailable" // SearchErrClassNetwork: network failure or timeout. SearchErrClassNetwork = "network" diff --git a/cmd/entire/cli/tokens_profile.go b/cmd/entire/cli/tokens_profile.go index b2aea714e5..e22b6bb8eb 100644 --- a/cmd/entire/cli/tokens_profile.go +++ b/cmd/entire/cli/tokens_profile.go @@ -51,7 +51,7 @@ const tokensProfileUsageScopeCheckpointObserved = "checkpoint_observed" func newTokensGroupCmd() *cobra.Command { cmd := &cobra.Command{ - Use: "tokens", + Use: cmdTokens, Short: "Analyze token usage across sessions and checkpoints", Hidden: true, Long: `Analyze token usage across sessions and checkpoints. @@ -276,7 +276,7 @@ func tokensProfileRecommendations(report tokensProfileReport) []sessionTokensRec if report.CheckpointsAnalyzed == 0 { return []sessionTokensRecommendation{{ ID: "no-checkpoints", - Severity: "low", + Severity: tokensSeverityLow, Message: "Create checkpoints first; token profiling needs committed checkpoint metadata to identify patterns.", Signals: []string{"empty_checkpoint_history"}, }} @@ -286,48 +286,48 @@ func tokensProfileRecommendations(report tokensProfileReport) []sessionTokensRec tokensProfileSignalCount(report.Signals, "api-call-amplification") > 0 { recs = append(recs, sessionTokensRecommendation{ ID: "search-before-reinvestigation", - Severity: "high", + Severity: tokensSeverityHigh, Message: "Use `entire search` for prior decisions/checkpoints before broad re-investigation.", - Signals: []string{"cache_read_tokens", "api_call_count"}, + Signals: []string{tokensSignalCacheReadTokens, tokensSignalAPICallCount}, }) } if tokensProfileSignalCount(report.Signals, "api-call-amplification") > 0 { recs = append(recs, sessionTokensRecommendation{ ID: "batch-diagnostics", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Batch diagnostic reads around one narrowed hypothesis when API call amplification repeats.", - Signals: []string{"api_call_count"}, + Signals: []string{tokensSignalAPICallCount}, }) } if tokensProfileSignalCount(report.Signals, "context-replay-hotspot") > 0 { recs = append(recs, sessionTokensRecommendation{ ID: "preserve-then-compact", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Summarize useful findings before continuing large-context work; compact or restart only after preserving relevant context.", - Signals: []string{"cache_read_tokens"}, + Signals: []string{tokensSignalCacheReadTokens}, }) } if tokensProfileSignalCount(report.Signals, "subagent-heavy") > 0 { recs = append(recs, sessionTokensRecommendation{ ID: "scope-subagents", - Severity: "medium", + Severity: tokensSeverityMedium, Message: "Scope subagent tasks tightly with a narrow objective and expected output.", - Signals: []string{"subagent_tokens"}, + Signals: []string{tokensSignalSubagentTokens}, }) } if report.MissingTokenData > 0 { recs = append(recs, sessionTokensRecommendation{ ID: "improve-token-coverage", - Severity: "low", + Severity: tokensSeverityLow, Message: "Increase token coverage by using agents and checkpoints that report token usage.", - Signals: []string{"missing_token_usage"}, + Signals: []string{tokensSignalMissingUsage}, }) } if len(recs) == 0 { recs = append(recs, sessionTokensRecommendation{ ID: "no-repeated-hotspots", - Severity: "low", + Severity: tokensSeverityLow, Message: "No repeated token hotspots were visible in committed checkpoint metadata.", Signals: []string{"checkpoint_token_metadata"}, }) diff --git a/cmd/entire/cli/trail_cmd.go b/cmd/entire/cli/trail_cmd.go index eea9d6f9d8..5358f61bc3 100644 --- a/cmd/entire/cli/trail_cmd.go +++ b/cmd/entire/cli/trail_cmd.go @@ -49,7 +49,7 @@ func newTrailCmd() *cobra.Command { var repoOverride string cmd := &cobra.Command{ - Use: "trail", + Use: cmdTrail, Short: "Manage trails for your branches", Hidden: true, // Hidden from root help while the surface matures, but advertised to @@ -470,7 +470,7 @@ func newTrailListCmd() *cobra.Command { var opts trailListOptions cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List recent trails", RunE: func(cmd *cobra.Command, _ []string) error { opts.InsecureHTTP = trailInsecureHTTP(cmd) diff --git a/cmd/entire/cli/trail_cmd_test.go b/cmd/entire/cli/trail_cmd_test.go index 3fb7f180ed..4431479b61 100644 --- a/cmd/entire/cli/trail_cmd_test.go +++ b/cmd/entire/cli/trail_cmd_test.go @@ -2297,7 +2297,7 @@ func TestTrailUpdateRequestCountsEveryFieldAsMetadata(t *testing.T) { // Fail rather than skip: a skipped subtest passes quietly, so a // non-pointer field would silently retire the guarantee this test // exists to provide, exactly when it starts mattering. - require.Equalf(t, reflect.Ptr, field.Type.Kind(), + require.Equalf(t, reflect.Pointer, field.Type.Kind(), "field %s is not a pointer; decide its zero/set semantics and extend this test before adding it", field.Name) var req api.TrailUpdateRequest // A pointer to the zero value is still "provided" — that is how a diff --git a/cmd/entire/cli/trail_comment_cmd.go b/cmd/entire/cli/trail_comment_cmd.go index 9000786bfe..9d7a31b208 100644 --- a/cmd/entire/cli/trail_comment_cmd.go +++ b/cmd/entire/cli/trail_comment_cmd.go @@ -99,7 +99,7 @@ replies. Code-review comments are managed separately under 'entire trail finding func newTrailCommentListCmd() *cobra.Command { var jsonOut, all bool cmd := &cobra.Command{ - Use: "list", + Use: cmdList, Short: "List discussion threads on a trail", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { diff --git a/cmd/entire/cli/trail_resume_cmd.go b/cmd/entire/cli/trail_resume_cmd.go index f39bd7a216..d89a7e032e 100644 --- a/cmd/entire/cli/trail_resume_cmd.go +++ b/cmd/entire/cli/trail_resume_cmd.go @@ -831,9 +831,9 @@ func printTrailResumeSkippedSessions(w io.Writer, skipped int) { if skipped == 0 { return } - label := "session" + label := nounSession if skipped != 1 { - label = "sessions" + label = nounSessions } fmt.Fprintf(w, " skipped %d checkpoint %s due to read errors\n", skipped, label) } @@ -1000,7 +1000,7 @@ func trailRestoredSessionChoiceLabel(session strategy.RestoredSession, isDefault func trailRestoredSessionKindLabel(kind string) string { switch sessionpkg.Kind(kind) { case sessionpkg.KindAgentReview: - return "review" + return sessionKindLabelReview case sessionpkg.KindAgentInvestigate: return "investigation" case sessionpkg.KindImported: diff --git a/cmd/entire/cli/transcript/compact/codex.go b/cmd/entire/cli/transcript/compact/codex.go index 2d7e66be0d..3fe1539539 100644 --- a/cmd/entire/cli/transcript/compact/codex.go +++ b/cmd/entire/cli/transcript/compact/codex.go @@ -107,7 +107,7 @@ func compactCodex(content []byte, opts MetadataFields) ([]byte, error) { } switch { - case p.Type == transcriptTypeMessage && p.Role == "user": + case p.Type == transcriptTypeMessage && p.Role == transcript.TypeUser: text := codexUserText(p.Content) if text == "" { continue @@ -122,7 +122,7 @@ func compactCodex(content []byte, opts MetadataFields) ([]byte, error) { line.Content = contentJSON appendLine(&result, line) - case p.Type == transcriptTypeMessage && p.Role == "assistant": + case p.Type == transcriptTypeMessage && p.Role == transcript.TypeAssistant: text := codexAssistantText(p.Content) if text == "" { continue diff --git a/cmd/entire/cli/transcript/compact/compact.go b/cmd/entire/cli/transcript/compact/compact.go index 782884d0d0..9316b23c85 100644 --- a/cmd/entire/cli/transcript/compact/compact.go +++ b/cmd/entire/cli/transcript/compact/compact.go @@ -46,7 +46,10 @@ func newTranscriptLine(opts MetadataFields) transcriptLine { } } -const toolResultStatusError = "error" +const ( + toolResultStatusSuccess = "success" + toolResultStatusError = "error" +) // toolResultJSON is the compact result object inlined into tool_use blocks. type toolResultJSON struct { @@ -488,7 +491,7 @@ func inlineToolResults(assistant, user parsedEntry) parsedEntry { func buildToolResult(tr toolResultEntry) json.RawMessage { r := toolResultJSON{ Output: tr.output, - Status: "success", + Status: toolResultStatusSuccess, MatchCount: tr.matchCount, } if tr.isError { diff --git a/cmd/entire/cli/transcript/compact/gemini.go b/cmd/entire/cli/transcript/compact/gemini.go index 3098c726e9..0d669a590a 100644 --- a/cmd/entire/cli/transcript/compact/gemini.go +++ b/cmd/entire/cli/transcript/compact/gemini.go @@ -253,9 +253,9 @@ func geminiToolResultCompact(tc geminiToolCall) json.RawMessage { r := toolResultJSON{ Output: output, - Status: "success", + Status: toolResultStatusSuccess, } - if tc.Status != "" && tc.Status != "success" { + if tc.Status != "" && tc.Status != toolResultStatusSuccess { r.Status = toolResultStatusError } b, err := json.Marshal(r) diff --git a/cmd/entire/cli/transcript/compact/opencode.go b/cmd/entire/cli/transcript/compact/opencode.go index d8dd3efb88..cccee8fbbb 100644 --- a/cmd/entire/cli/transcript/compact/opencode.go +++ b/cmd/entire/cli/transcript/compact/opencode.go @@ -194,7 +194,7 @@ func emitOpenCodeAssistant(result *[]byte, base transcriptLine, msg openCodeMess func openCodeToolResult(state map[string]json.RawMessage) json.RawMessage { r := toolResultJSON{ Output: unquote(state["output"]), - Status: "success", + Status: toolResultStatusSuccess, } if s := unquote(state["status"]); s != "" && s != "completed" { r.Status = toolResultStatusError diff --git a/cmd/entire/cli/transcript/compact/parse.go b/cmd/entire/cli/transcript/compact/parse.go index 383b83b9b0..660455a2a0 100644 --- a/cmd/entire/cli/transcript/compact/parse.go +++ b/cmd/entire/cli/transcript/compact/parse.go @@ -5,6 +5,8 @@ import ( "errors" "fmt" "strings" + + "github.com/entireio/cli/cmd/entire/cli/transcript" ) type CondensedEntry struct { @@ -59,7 +61,7 @@ func BuildCondensedEntries(content []byte) ([]CondensedEntry, error) { } switch line.Type { - case "user": + case transcript.TypeUser: var parts []string for _, block := range blocks { var text string @@ -68,10 +70,10 @@ func BuildCondensedEntries(content []byte) ([]CondensedEntry, error) { } } if len(parts) > 0 { - entries = append(entries, CondensedEntry{Type: "user", Content: strings.Join(parts, "\n")}) + entries = append(entries, CondensedEntry{Type: transcript.TypeUser, Content: strings.Join(parts, "\n")}) } - case "assistant": + case transcript.TypeAssistant: for _, block := range blocks { var blockType string if err := json.Unmarshal(block["type"], &blockType); err != nil { @@ -82,7 +84,7 @@ func BuildCondensedEntries(content []byte) ([]CondensedEntry, error) { case "text": var text string if err := json.Unmarshal(block["text"], &text); err == nil && text != "" { - entries = append(entries, CondensedEntry{Type: "assistant", Content: text}) + entries = append(entries, CondensedEntry{Type: transcript.TypeAssistant, Content: text}) } case "tool_use": var toolName string diff --git a/cmd/entire/cli/transcript/compact/pi.go b/cmd/entire/cli/transcript/compact/pi.go index c64e53d27d..4aad44e830 100644 --- a/cmd/entire/cli/transcript/compact/pi.go +++ b/cmd/entire/cli/transcript/compact/pi.go @@ -25,11 +25,6 @@ import ( // SkipLines, NewScanner) are shared with the pi agent package via // cmd/entire/cli/agent/pi/pijsonl so a fix applied here also lands there. -const ( - piToolResultStatusOK = "success" - piToolResultStatusErr = "error" -) - // piToolNameMap normalises Pi's lowercase tool names to the title-cased names // used elsewhere in Entire's compact format (matching Claude's "Read"/"Write"/"Edit"). var piToolNameMap = map[string]string{ @@ -299,9 +294,9 @@ func piDecodeResultOutput(raw json.RawMessage) string { func piResultStatus(isError bool) string { if isError { - return piToolResultStatusErr + return toolResultStatusError } - return piToolResultStatusOK + return toolResultStatusSuccess } func piTimestampJSON(ts string) json.RawMessage { diff --git a/cmd/entire/cli/uiform/prompt_terminal_test.go b/cmd/entire/cli/uiform/prompt_terminal_test.go new file mode 100644 index 0000000000..4ea0622f22 --- /dev/null +++ b/cmd/entire/cli/uiform/prompt_terminal_test.go @@ -0,0 +1,84 @@ +//go:build !windows + +package uiform + +import ( + "context" + "io" + "regexp" + "strings" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "charm.land/huh/v2" + "github.com/creack/pty" +) + +// clearedFromFirstRow matches a cursor-up over the rendered form followed by +// an erase to the end of the display — CUU then ED0, the pair that removes an +// answered prompt. The row count is left open on purpose; see the assertion. +var clearedFromFirstRow = regexp.MustCompile(`\x1b\[[0-9]+A\x1b\[J`) + +// An empty Form.View is not enough: the renderer must move back over the +// question before erasing it. Exercise the actual terminal output, because +// accessible-mode tests bypass the renderer that left answered prompts behind. +func TestConfirmationClearsPromptAfterAnswer(t *testing.T) { + t.Parallel() + terminal, input, err := pty.Open() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = terminal.Close() }) + t.Cleanup(func() { _ = input.Close() }) + if err := pty.Setsize(terminal, &pty.Winsize{Rows: 24, Cols: 100}); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + const question = "Install the entire-graph plugin?" + output := make(chan string, 1) + go func() { + var transcript strings.Builder + answered := false + buf := make([]byte, 4096) + for { + n, readErr := terminal.Read(buf) + transcript.Write(buf[:n]) + if !answered && strings.Contains(transcript.String(), question) { + answered = true + if _, writeErr := io.WriteString(terminal, "\r"); writeErr != nil { + cancel() + } + } + if readErr != nil { + output <- transcript.String() + return + } + } + }() + answer := true + form := New(huh.NewGroup(huh.NewConfirm().Title(question).Value(&answer))). + WithProgramOptions(tea.WithEnvironment([]string{"TERM=xterm-256color"})). + WithAccessible(false).WithInput(input).WithOutput(input) + err = form.RunWithContext(ctx) + _ = input.Close() // End the reader after the final render has been flushed. + if err != nil { + t.Fatal(err) + } + transcript := <-output + // The form ends with the cursor on its help row, so erasing from there + // alone leaves the question and choices visible: the renderer has to move + // back up over the form first, then erase to the end of the display. + // + // The number of rows is deliberately not pinned. It is a property of the + // form's height, which a field, theme or terminal-width change moves, and + // a literal "\x1b[4A\x1b[J" fails such a change with a message about + // prompt clearing — which is not what broke. + if !clearedFromFirstRow.MatchString(transcript) { + t.Fatalf("completed prompt was not erased from its first row: %q", transcript) + } + if !answer { + t.Fatal("Enter did not retain the default Yes answer") + } +} diff --git a/cmd/entire/main.go b/cmd/entire/main.go index 4c48b34887..b3bcdd53f0 100644 --- a/cmd/entire/main.go +++ b/cmd/entire/main.go @@ -80,8 +80,46 @@ func main() { // inherits the prepended PATH so it can spawn sibling managed plugins. restorePATH := cli.PrependPluginBinDirToPATH(ctx) - if handled, code := cli.MaybeRunPlugin(ctx, rootCmd, os.Args[1:]); handled { + if handled, code, killedBy := cli.MaybeRunPlugin(ctx, rootCmd, os.Args[1:]); handled { cancel() + if code == cli.ExitPluginSignalled { + // The plugin was terminated by a signal, or a signal interrupted + // the on-demand install before it ran. Re-raise so the shell sees + // WIFSIGNALED: an enclosing loop breaks on one Ctrl-C, and the + // conventional 128+signum reaches whoever ran us. + // + // Gated on the plugin's own outcome rather than on a signal + // having fired somewhere in this process: Ctrl-C reaches the whole + // foreground process group, so a plugin that handles it itself and + // exits with a meaningful code (a TUI quitting on Ctrl-C exits 0) + // must keep that code instead of being reported as killed. + switch { + case procsignal.Load() != nil: + // A signal we received outranks the child's, because when we + // were signalled the child's signal is usually OUR signal + // laundered — and laundered lossily. Cancelling the context + // makes runPlugin's cmd.Cancel send the child SIGINT whatever + // we got, so a supervisor's SIGTERM comes back as a SIGINT + // child and would report 130 for a shutdown that must report + // 143. That is the exact confusion dieFromSignal exists to + // prevent. + dieFromSignal(terminatingSignal()) + case killedBy != nil: + // We were not signalled, so the child's signal is genuinely + // its own: `kill -TERM` aimed at the plugin still exits 143, + // and a SIGPIPE from `entire graph | head -1` still exits + // 141. Nothing laundered it, so it is the outcome to + // propagate. + dieFromSignal(killedBy) + default: + // -1 with no signal on either side. Windows reports a killed + // child as an ordinary exit code, so it never lands here; + // anything that does is unaccounted for, and -1 is not an + // exit status (os.Exit would truncate it to 255), so report a + // plain failure rather than inventing a signal. + os.Exit(1) + } + } os.Exit(code) } restorePATH() diff --git a/cmd/git-remote-entire/main.go b/cmd/git-remote-entire/main.go index f0f86b833d..6d6b48c709 100644 --- a/cmd/git-remote-entire/main.go +++ b/cmd/git-remote-entire/main.go @@ -34,12 +34,13 @@ import ( "syscall" "time" + "github.com/entireio/auth-go/sts" + "github.com/entireio/cli/cmd/entire/cli/auth" "github.com/entireio/cli/cmd/entire/cli/gitremote" "github.com/entireio/cli/cmd/entire/cli/versioninfo" "github.com/entireio/cli/internal/entireclient/clusterdiscovery" "github.com/entireio/cli/internal/entireclient/httpclient" - "github.com/entireio/cli/internal/entireclient/httputil" "github.com/entireio/cli/internal/entireclient/userdirs" "github.com/entireio/cli/internal/remotehelper" "github.com/entireio/cli/internal/remotehelper/debuglog" @@ -242,7 +243,7 @@ var wrongClusterRe = regexp.MustCompile(`lives on "([^"]+)"`) // naming the correct host (and the corrected entire:// URL). Everything else // falls back to the verbatim error. func fatalMessage(err error, parsedURL *url.URL) string { - var oe *httputil.OAuthError + var oe *sts.ExchangeError if errors.As(err, &oe) && oe.Code == "invalid_target" { if m := wrongClusterRe.FindStringSubmatch(oe.Description); m != nil { host := m[1] diff --git a/cmd/git-remote-entire/main_test.go b/cmd/git-remote-entire/main_test.go index 22de1f4e77..c9a0d0fcd0 100644 --- a/cmd/git-remote-entire/main_test.go +++ b/cmd/git-remote-entire/main_test.go @@ -13,8 +13,8 @@ import ( "strings" "testing" + "github.com/entireio/auth-go/sts" "github.com/entireio/cli/cmd/entire/cli/auth" - "github.com/entireio/cli/internal/entireclient/httputil" ) func TestInfoFlagText(t *testing.T) { @@ -121,11 +121,10 @@ func TestGitActionFromRequest(t *testing.T) { func TestFatalMessage(t *testing.T) { t.Parallel() parsedURL := &url.URL{Scheme: "entire", Host: "aws-us-east-2.entire.io", Path: "/et/paul/dogbark"} - wrongCluster := &httputil.OAuthError{ - Status: http.StatusBadRequest, + wrongCluster := &sts.ExchangeError{ + StatusCode: http.StatusBadRequest, Code: "invalid_target", Description: `audience host "aws-us-east-2.entire.io" does not host this repo; it lives on "aws-eu-central-1.entire.io" — re-target the request there`, - Body: "{...}", } tests := []struct { name string @@ -135,7 +134,7 @@ func TestFatalMessage(t *testing.T) { }{ { name: "wrong cluster names correct host and URL", - // Wrapped to mirror production: the OAuthError surfaces buried under + // Wrapped to mirror production: the ExchangeError surfaces buried under // several fmt.Errorf layers, so errors.As must dig it out. err: fmt.Errorf("stateless-connect v2 info/refs: fetching info/refs from entry domain: repo-scoped token exchange: oauth token exchange: %w", wrongCluster), contains: []string{ @@ -146,7 +145,7 @@ func TestFatalMessage(t *testing.T) { }, { name: "invalid_target without lives-on hint falls back", - err: &httputil.OAuthError{Status: http.StatusBadRequest, Code: "invalid_target", Description: "no servable mirror", Body: "HTTP 400: no servable mirror"}, + err: &sts.ExchangeError{StatusCode: http.StatusBadRequest, Code: "invalid_target", Description: "no servable mirror"}, contains: []string{"fatal:", "no servable mirror"}, }, { diff --git a/docs/architecture/external-commands.md b/docs/architecture/external-commands.md index 1ca23b77d4..a2745da42a 100644 --- a/docs/architecture/external-commands.md +++ b/docs/architecture/external-commands.md @@ -16,6 +16,11 @@ Rules, in order: 2. **Reserved names are skipped.** Names beginning with `agent-` are reserved for the [agent protocol](external-agent-protocol.md). The resolver refuses to invoke them as external commands. 3. **Path-traversal candidates are rejected.** Names containing `/` or `\` never resolve. 4. **Found-but-not-executable surfaces as a launch error.** If `entire-` exists on `$PATH` but lacks the executable bit, the resolver reports `Failed to run plugin entire-` with exit code 1, rather than falling through to Cobra's "unknown command" path. +5. **Missing Graph offers installation.** When `entire-graph` is absent, `entire graph ` resolves `graph` in the configured plugin index and asks `Install the entire-graph plugin from ?` with Yes selected by default. The repository is named because this prompt is the only human checkpoint on the path — an index-listed install never prompts inside `runRemoteInstall`, since the catalog is the trust decision. A name the index does not carry is reported instead of offered, rather than prompting and then failing on a request that could never be honored. Accepting installs `graph` through the normal managed installer, then executes the installed binary with all remaining arguments unchanged. This also works for bare `entire graph` and `entire graph --help`. Installation output goes to stderr, and the run that follows is announced as `Running entire-graph` — the binary's name only, never the arguments, which are the user's own command line and could carry a token, a newline or a terminal escape into stderr and anything capturing it. Declining or a failed installation exits nonzero without running the command; a cancelled one terminates by signal, so a single Ctrl-C escapes an enclosing shell loop. Non-interactive sessions receive an `entire plugin install graph` hint instead of a prompt. Other missing plugin names still fall through to Cobra. + + A `graph` that is already in the [managed install directory](#managed-install-directory) but unreachable through `$PATH` — a managed dir that could not be prepended at startup — is **executed**, not offered for installation. Installing over it cannot work: an existing install needs `--force`, which the on-demand path deliberately does not pass, so prompting would spend the user's Yes and several network round-trips on a guaranteed "already installed; use --force to replace". + + A managed entry that exists but **cannot be run** — a local-dev symlink whose target moved, or a directory in its place — is reported with its path and a `entire plugin install --force` remedy. It is neither executed (a `fork/exec` ENOENT names a path the user never chose) nor reinstalled over: replacing a developer's deliberate symlink with a released binary is their call. ### Managed install directory @@ -28,6 +33,14 @@ The CLI prepends this directory to `$PATH` at startup via `cli.PrependPluginBinD `entire plugin install/list/remove/upgrade` manage the contents of this directory. Authors who prefer the raw "drop a binary on `$PATH`" model don't need to use it. +### Install progress and confirmations + +`entire plugin install`, `entire plugin upgrade`, dependency installs, and the on-demand `entire graph` install report their stages — index lookup, release metadata, download and checksum verification, placement — on **stderr**, so stdout carries only the result and stays pipeable. A styled terminal gets a spinner; a non-terminal writer and accessibility mode get one plain line per stage as it starts. Progress stops before any confirmation or result is printed, including on failure. Asset-name probing shares one download status per release rather than one per candidate. + +Progress travels on the context (`withPluginProgress`), so a caller that has not opted in prints nothing — a library caller never writes to the process's terminal on its own. + +Confirmations read from the **controlling terminal**, never stdin, so a plugin's piped input survives being prompted about. They render to the writer the caller supplies, except when that writer is not itself a terminal (`entire graph 2>log`): then the prompt renders on the terminal the answer is read from, because a prompt nobody can see still blocks on a keypress — and with Yes as the default, an unwitting Enter would authorize the install. An input that reaches EOF never becomes a Yes, but the two modes get there differently: accessibility mode declines outright — huh's scanner would otherwise read EOF as the field's default, which is Yes here — while the default full-screen prompt keeps waiting until the context is cancelled. Both fail closed; only one of them answers. Cancellation stops the prompt either way, and still terminates by signal. + ### Remote install `entire plugin install` accepts three source forms: @@ -279,6 +292,7 @@ The resolver lives in `cmd/entire/cli/plugin.go`. The entry point is `MaybeRunPl Key files: - `cmd/entire/cli/plugin.go` — entry point, `resolvePlugin`, `runPlugin` +- `cmd/entire/cli/plugin_on_demand.go` — missing Graph installation prompt and managed-install handoff - `cmd/entire/cli/plugin_env.go` — `pluginEnv`, the allowlist, and `ENTIRE_PLUGIN_ENV` parsing - `cmd/entire/cli/plugin_official.go` — `officialPlugins` allowlist, `IsOfficialPlugin` - `cmd/entire/cli/plugin_store.go` — managed install directory, `PluginBinDir`, `PluginDataDir`, `InstallPluginFromPath`, `ListInstalledPlugins`, `RemoveInstalledPlugin`, `PrependPluginBinDirToPATH` diff --git a/docs/first-time-contributors.md b/docs/first-time-contributors.md index 2ee28b7541..540903d3ca 100644 --- a/docs/first-time-contributors.md +++ b/docs/first-time-contributors.md @@ -78,7 +78,7 @@ Entire is a Go project managed by `mise`. Three commands and you're set up: # Install mise (skip if you already have it) curl https://mise.run | sh -# Trust this repo's mise config and install Go 1.26 +# Trust this repo's mise config and install Go 1.27 mise trust mise install diff --git a/e2e/vogon/main.go b/e2e/vogon/main.go index f5c987a615..00018c2e36 100644 --- a/e2e/vogon/main.go +++ b/e2e/vogon/main.go @@ -705,8 +705,17 @@ func appendTranscriptEntry(path string, entry transcriptEntry) { if err != nil { return } - defer f.Close() - f.Write(data) + // The canary asserts on this transcript, so a partial one invalidates the + // run rather than degrading it: every downstream assertion fails with a + // message about the wrong thing. Report the real cause instead. Close is + // checked because it is where a failed flush surfaces. + if _, err := f.Write(data); err != nil { + _ = f.Close() + fatal("writing transcript %s: %v", path, err) + } + if err := f.Close(); err != nil { + fatal("closing transcript %s: %v", path, err) + } } func fatal(format string, args ...any) { diff --git a/go.mod b/go.mod index 31b5ed4856..c546e345c6 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/entireio/cli -go 1.26.6 +go 1.27.1 require ( charm.land/bubbles/v2 v2.2.1 @@ -13,7 +13,7 @@ require ( github.com/charmbracelet/x/ansi v0.11.8 github.com/creack/pty v1.1.24 github.com/denisbrodbeck/machineid v1.0.1 - github.com/entireio/auth-go v0.5.3-0.20260902072340-78f4dc59700e + github.com/entireio/auth-go v0.5.3-0.20260911082959-f3d099d20e7b github.com/go-faster/errors v0.8.0 github.com/go-faster/jx v1.2.0 github.com/go-git/go-billy/v6 v6.0.0-alpha.2 @@ -30,8 +30,10 @@ require ( github.com/go-git/x/plugin/objectsigner/program v0.0.0-20260624122410-382b2905c041 github.com/gofrs/flock v0.13.1 github.com/google/uuid v1.6.0 + github.com/lastpersonlabs/goredact v0.1.0 github.com/mattn/go-isatty v0.0.24 github.com/mattn/go-runewidth v0.0.29 + github.com/muesli/cancelreader v0.2.2 github.com/muesli/termenv v0.16.0 github.com/ogen-go/ogen v1.24.0 github.com/oklog/ulid/v2 v2.1.2 @@ -42,16 +44,14 @@ require ( github.com/stretchr/testify v1.12.1 github.com/zalando/go-keyring v0.2.8 golang.org/x/crypto v0.56.0 - golang.org/x/mod v0.40.0 + golang.org/x/mod v0.41.0 golang.org/x/net v0.58.0 - golang.org/x/sync v0.22.0 - golang.org/x/sys v0.47.0 + golang.org/x/sync v0.23.0 + golang.org/x/sys v0.48.0 golang.org/x/term v0.45.0 gopkg.in/yaml.v3 v3.0.1 ) -require github.com/lastpersonlabs/goredact v0.1.0 - require ( dario.cat/mergo v1.0.2 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect @@ -68,7 +68,7 @@ require ( github.com/catppuccin/go v0.3.0 // indirect github.com/charlievieth/fastwalk v1.0.14 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 // indirect github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -116,7 +116,6 @@ require ( github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/muesli/cancelreader v0.2.2 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.3.1 // indirect github.com/pierrec/lz4/v4 v4.1.26 // indirect diff --git a/go.sum b/go.sum index 29f725e194..e593e90bdd 100644 --- a/go.sum +++ b/go.sum @@ -54,8 +54,8 @@ github.com/charlievieth/fastwalk v1.0.14 h1:3Eh5uaFGwHZd8EGwTjJnSpBkfwfsak9h6ICg github.com/charlievieth/fastwalk v1.0.14/go.mod h1:diVcUreiU1aQ4/Wu3NbxxH4/KYdKpLDojrQ1Bb2KgNY= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886 h1:rdnVWKgJpTVXKuKuJyxDJ+NFJdUaUqGvyGy61OcvlbA= -github.com/charmbracelet/ultraviolet v0.0.0-20260811164956-006e29f97886/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9 h1:tYBWVoMfQHTwp88mWeWH7o0uJFZqRJeEGJmeHpIR8Ms= +github.com/charmbracelet/ultraviolet v0.0.0-20260906173415-0277a179edd9/go.mod h1:nAw0d9PhFp1qdzi2xhQU5YOu5sVpDIHWlaW2Uz/bCro= github.com/charmbracelet/x/ansi v0.11.8 h1:JMFwp0CgDC2+jcOB162HH5k7I3FVbgFSMMYg7dSPBQQ= github.com/charmbracelet/x/ansi v0.11.8/go.mod h1:ZNN+3mXny/516oTQPLMPIBeSINvNJJQ8uQXDgbeJxY0= github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs= @@ -103,8 +103,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= -github.com/entireio/auth-go v0.5.3-0.20260902072340-78f4dc59700e h1:DVQYuqzB0x7oUjz8nh5hzasRyklC3mLD1VkyVoFNw0I= -github.com/entireio/auth-go v0.5.3-0.20260902072340-78f4dc59700e/go.mod h1:Uc1inZN5I4R60jU1mFqxDnT/S6PaZy8GvdMqXJAEw20= +github.com/entireio/auth-go v0.5.3-0.20260911082959-f3d099d20e7b h1:5RiZn+I83yeu3b7U9wfRMWuw9RyGmE9P7E7gBEqZ6+k= +github.com/entireio/auth-go v0.5.3-0.20260911082959-f3d099d20e7b/go.mod h1:Uc1inZN5I4R60jU1mFqxDnT/S6PaZy8GvdMqXJAEw20= github.com/expr-lang/expr v1.17.8 h1:W1loDTT+0PQf5YteHSTpju2qfUfNoBt4yw9+wOEU9VM= github.com/expr-lang/expr v1.17.8/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= @@ -312,19 +312,19 @@ golang.org/x/crypto v0.56.0 h1:GUh5Ii4J5jtcseSMiRqr1jXCNHoxjeV9Fmekc2oLy6Y= golang.org/x/crypto v0.56.0/go.mod h1:OMW5y6CY9l38uPLmxU6l6pwcXp1obtLo3e6gT7gQR2I= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa h1:t2QcU6V556bFjYgu4L6C+6VrCPyJZ+eyRsABUPs1mz4= golang.org/x/exp v0.0.0-20250218142911-aa4b98e5adaa/go.mod h1:BHOTPb3L19zxehTsLoJXVaTktb06DFgmdW6Wb9s8jqk= -golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs= -golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0= golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w= golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= diff --git a/internal/coreapi/UPSTREAM.md b/internal/coreapi/UPSTREAM.md index bf2dd04f41..788698f456 100644 --- a/internal/coreapi/UPSTREAM.md +++ b/internal/coreapi/UPSTREAM.md @@ -51,6 +51,63 @@ loosened; request-body enums stay strict. Locked in by `TestListProjectRepos_UnknownEnumValuesPassThrough` in `client_test.go`. Retire the allowlist entries as upstream loosens the corresponding fields. +## 2b. New read-model fields ship as `required` + +**Symptom:** `capabilities` on `Org`, `Project` and `Repo`, `Repo.provider`, +`org`/`provider` on `RepoIndexEntry`, and `ListReposOutputBody.candidatesIncomplete` +were added as `required`. ogen's +decoder then fails the whole response when a field is absent, so a core that +predates the field, or a mixed-version roll, breaks every list, get and +repo-routing call in a client that does not depend on any of them. + +**Fix upstream:** add read-model fields as optional until every deployment +sends them, then tighten. + +**Workaround:** `spec/normalize.go` (`loosenReadModelRequired`, allowlist +`readModelOptionalFields`) drops the listed fields from `required`. The new +`provider` and `RepoIndexEntry.permission` enums are also in +`readModelEnumFields`, since the CLI displays them or tests for the values it +knows and treats everything else as unknown (`repo protection list` tests +`Repo.provider` for `"github"` and `"entire"` separately, with a third +rendering for anything else — an absent or unrecognized provider must not +fall into either known value's branch). Remove an entry when the CLI needs +the field to be *present* to be correct — reading it through its `Opt` +accessor with a safe default is not that. + +Locked in by `TestListRepos_UnsentRequiredReadFieldsDecode` and +`TestListOrgsAndProjects_UnsentCapabilitiesDecode` in `client_test.go`. +`RepoIndexEntry` gets the dedicated test because `ListRepos` is the widest +consumer: the consolidated index is what `resolveRepoCellTarget` routes with +and what `search`, `repo mirror` and the dispatch wizard page through, so a +decode failure there takes out cell routing and search together rather than +one command. + +## 3. Every operation advertises the interactive login schemes + +**Symptom:** the spec lists four security alternatives on every operation +(`oauth2`, `oidc`, `bearerAuth`, `sessionAuth`). `oauth2` and `oidc` +describe how a browser or device obtains a token; a client that already +holds a bearer never drives them. ogen has no generator for `openIdConnect` +and aborts on it, so the spec cannot be consumed as published. + +**Fix upstream:** advertise `oauth2`/`oidc` in `components.securitySchemes` +for documentation, but list only `bearerAuth` and `sessionAuth` as the +per-operation requirements, since those are what a request actually carries. + +**Workaround:** `spec/normalize.go` (`dropInteractiveSecurity`, +`interactiveSecuritySchemes`) removes the two schemes from the components +and from every security list, so the generated `SecuritySource` keeps the +`BearerAuth` and `SessionAuth` methods the client implements. + +The transform refuses to *empty* a security list that had entries. An empty +operation-level `security` means "no authentication required" in OpenAPI, so +filtering an oauth2-only operation down to nothing would silently generate a +client that stops sending the bearer to it. Nothing in today's spec has that +shape; a device-code or authorize endpoint added upstream would, and it is +the only failure mode here that would not announce itself (a requirement +mixing `oauth2` with `bearerAuth` survives whole and makes ogen abort at +generate time on the now-dangling scheme). +