Skip to content

Commit 2da1948

Browse files
committed
refactor(investigate): split cmd.go into smaller files
1 parent 76e1a79 commit 2da1948

2 files changed

Lines changed: 360 additions & 344 deletions

File tree

cli/investigate/cmd.go

Lines changed: 0 additions & 344 deletions
Original file line numberDiff line numberDiff line change
@@ -4,22 +4,17 @@ import (
44
"context"
55
"errors"
66
"fmt"
7-
"io"
87
"log/slog"
98
"os"
10-
"path/filepath"
119
"strings"
1210
"time"
1311

1412
"github.com/spf13/cobra"
1513

1614
"github.com/GrayCodeAI/trace/cli/agent/spawn"
1715
"github.com/GrayCodeAI/trace/cli/agent/types"
18-
"github.com/GrayCodeAI/trace/cli/checkpoint/id"
19-
"github.com/GrayCodeAI/trace/cli/gitexec"
2016
"github.com/GrayCodeAI/trace/cli/interactive"
2117
"github.com/GrayCodeAI/trace/cli/logging"
22-
"github.com/GrayCodeAI/trace/cli/mdrender"
2318
"github.com/GrayCodeAI/trace/cli/paths"
2419
"github.com/GrayCodeAI/trace/cli/session"
2520
"github.com/GrayCodeAI/trace/cli/settings"
@@ -816,342 +811,3 @@ func resolveRunConfig(cfg *settings.InvestigateConfig, f runFlags) (agents []str
816811
}
817812
return agents, maxTurns, quorum, nil
818813
}
819-
820-
// parseAgentsCSV splits a comma-separated agent list, trimming whitespace
821-
// and dropping empty entries.
822-
func parseAgentsCSV(csv string) []string {
823-
parts := strings.Split(csv, ",")
824-
out := make([]string, 0, len(parts))
825-
for _, p := range parts {
826-
if v := strings.TrimSpace(p); v != "" {
827-
out = append(out, v)
828-
}
829-
}
830-
return out
831-
}
832-
833-
// verifyAgentsLaunchable confirms each agent has a non-nil Spawner AND has
834-
// hooks installed in the current repo.
835-
func verifyAgentsLaunchable(ctx context.Context, agents []string, deps Deps) error {
836-
if deps.SpawnerFor == nil {
837-
return errors.New("investigate: SpawnerFor not wired")
838-
}
839-
if deps.GetAgentsWithHooksInstalled == nil {
840-
return errors.New("investigate: GetAgentsWithHooksInstalled not wired")
841-
}
842-
installed := deps.GetAgentsWithHooksInstalled(ctx)
843-
installedSet := make(map[string]struct{}, len(installed))
844-
for _, n := range installed {
845-
installedSet[string(n)] = struct{}{}
846-
}
847-
for _, name := range agents {
848-
if deps.SpawnerFor(name) == nil {
849-
return fmt.Errorf("agent %q is not launchable (spawner missing)", name)
850-
}
851-
if _, ok := installedSet[name]; !ok {
852-
return fmt.Errorf("agent %q is not launchable (run `entire configure --agent %s` first)", name, name)
853-
}
854-
}
855-
return nil
856-
}
857-
858-
// resolveTopicAndSeed turns the user's input args into a topic + (seed
859-
// doc path | issue link seed bytes + topic). pickerPrompt is the
860-
// "Investigation prompt" collected from the spawn-time multipicker; it
861-
// becomes the topic only when no seed-doc / --issue-link was supplied.
862-
// Exactly one of seedDoc / issueSeed / topic-only is set on return.
863-
func resolveTopicAndSeed(ctx context.Context, args []string, f runFlags, pickerPrompt string) (topic, seedDoc string, issueSeed []byte, issueTopic string, err error) {
864-
switch {
865-
case len(args) == 1:
866-
seedDoc = args[0]
867-
body, readErr := os.ReadFile(seedDoc) //nolint:gosec // path is user-supplied positional arg
868-
if readErr != nil {
869-
return "", "", nil, "", fmt.Errorf("read seed doc %s: %w", seedDoc, readErr)
870-
}
871-
topic = DeriveTopicFromSeed(body, seedDoc)
872-
return topic, seedDoc, nil, "", nil
873-
case strings.TrimSpace(f.issueLink) != "":
874-
res, resErr := ResolveIssueLink(ctx, f.issueLink)
875-
if resErr != nil {
876-
return "", "", nil, "", resErr
877-
}
878-
return res.Topic, "", res.SeedDoc, res.Topic, nil
879-
case strings.TrimSpace(pickerPrompt) != "":
880-
topic = strings.TrimSpace(pickerPrompt)
881-
return topic, "", nil, "", nil
882-
default:
883-
return "", "", nil, "", errors.New("missing investigation input: pass [seed-doc] or --issue-link, or enter an investigation prompt in the picker")
884-
}
885-
}
886-
887-
// topicForBootstrap returns the topic value to embed in the bootstrap
888-
// scaffold. The seed-doc path takes precedence (Bootstrap re-derives from
889-
// the seed body), and the issue-link path uses IssueLinkTopic; only the
890-
// topic-only path puts the resolved topic into BootstrapInput.Topic.
891-
func topicForBootstrap(topic, seedDoc string, issueSeed []byte) string {
892-
if seedDoc != "" || len(issueSeed) > 0 {
893-
return ""
894-
}
895-
return topic
896-
}
897-
898-
// resolveDocPaths returns the absolute findings path for a run. The
899-
// findings doc lives alongside state.json in the per-run directory under
900-
// the git common dir:
901-
//
902-
// <commonDir>/trace-investigations/<run-id>/findings.md
903-
// <commonDir>/trace-investigations/<run-id>/state.json
904-
//
905-
// Putting the per-run artefacts under the git common dir (rather than the
906-
// worktree's .trace/investigations/) keeps the worktree's working tree
907-
// clean — investigation findings are session-scoped scratch space, not
908-
// part of the user's source tree.
909-
func resolveDocPaths(commonDir, runID string) string {
910-
return filepath.Join(commonDir, InvestigationsDirName, runID, "findings.md")
911-
}
912-
913-
// executeLoopAndCapture runs the loop and returns the LoopResult so the
914-
// caller can use it to compose a post-run manifest / footer.
915-
func executeLoopAndCapture(ctx context.Context, cmd *cobra.Command, in LoopInput, deps Deps) (LoopResult, error) {
916-
stateStore, err := NewStateStore(ctx)
917-
if err != nil {
918-
return LoopResult{}, fmt.Errorf("open run state store: %w", err)
919-
}
920-
921-
out := cmd.OutOrStdout()
922-
progress, tuiSink, runCtx, cancelTUI := buildProgressSink(ctx, in, out)
923-
// Defers run LIFO. Register Wait first so cancelTUI fires BEFORE Wait
924-
// — Wait blocks on the Bubble Tea program exiting, and the ctx-watcher
925-
// in Start() needs ctx cancelled to push tea.Quit when no RunFinished
926-
// arrives (early loop return, validation error, etc.).
927-
if tuiSink != nil {
928-
tuiSink.Start(runCtx)
929-
defer tuiSink.Wait()
930-
}
931-
if cancelTUI != nil {
932-
defer cancelTUI()
933-
}
934-
935-
ldeps := LoopDeps{
936-
SpawnerFor: deps.SpawnerFor,
937-
States: stateStore,
938-
Progress: progress,
939-
}
940-
941-
runner := deps.LoopRun
942-
if runner == nil {
943-
runner = RunInvestigateLoop
944-
}
945-
result, runErr := runner(runCtx, in, ldeps)
946-
if runErr != nil {
947-
return result, fmt.Errorf("investigate loop: %w", runErr)
948-
}
949-
return result, nil
950-
}
951-
952-
// buildProgressSink chooses between the Bubble Tea TUI and the plain-text
953-
// fallback based on terminal capability. In TTY mode ctx is wrapped in a
954-
// cancellable child so the in-TUI Ctrl+C handler can stop the run via the
955-
// same cancel function the cobra root would use on SIGINT. In non-TTY mode
956-
// the caller's ctx is returned unchanged and cancelTUI is nil.
957-
func buildProgressSink(ctx context.Context, in LoopInput, out io.Writer) (ProgressSink, *tuiProgressSink, context.Context, context.CancelFunc) { //nolint:ireturn // returns interface by design
958-
if !interactive.IsTerminalWriter(out) || !interactive.CanPromptInteractively() {
959-
return newTextProgressSink(out), nil, ctx, nil
960-
}
961-
runCtx, cancel := context.WithCancel(ctx)
962-
maxTurns := in.MaxTurns
963-
if maxTurns == 0 {
964-
maxTurns = defaultMaxTurns
965-
}
966-
quorum := in.Quorum
967-
if quorum == 0 {
968-
quorum = len(in.Agents)
969-
}
970-
sink := newTUIProgressSink(in.Topic, in.RunID, in.Agents, maxTurns, quorum, cancel, out)
971-
return sink, sink, runCtx, cancel
972-
}
973-
974-
// writeRunManifest builds a LocalManifest from the loop result and
975-
// persists it. Failures are logged but do not error — the docs themselves
976-
// are the deliverable.
977-
//
978-
// On terminal outcomes (Quorum/Stalled) the manifest captures the final
979-
// findings.md content into FindingsContent and the per-run directory is
980-
// removed — the manifest becomes the durable record of the run. On
981-
// Paused/Cancelled the per-run directory is left in place so `--continue`
982-
// can pick up where the run left off.
983-
func writeRunManifest(
984-
ctx context.Context,
985-
out io.Writer,
986-
runID, topic string,
987-
agents []string,
988-
startingSHA, worktreePath, findingsDoc string,
989-
startedAt, endedAt time.Time,
990-
result LoopResult,
991-
) {
992-
manifestStore, err := NewLocalManifestStore(ctx)
993-
if err != nil {
994-
logging.Debug(ctx, "investigate: open manifest store",
995-
slog.String("err", err.Error()), slog.String("run_id", runID))
996-
return
997-
}
998-
stancesByAgent := map[string]string{}
999-
if result.State != nil {
1000-
for _, s := range result.State.Stances {
1001-
stancesByAgent[s.Agent] = s.Stance
1002-
}
1003-
}
1004-
if startedAt.IsZero() && result.State != nil {
1005-
startedAt = result.State.StartedAt
1006-
}
1007-
if endedAt.IsZero() {
1008-
endedAt = time.Now().UTC()
1009-
}
1010-
1011-
// Capture findings into the manifest on terminal outcomes so the
1012-
// content survives even after the per-run dir is deleted. Failure to
1013-
// read is logged but non-fatal — the manifest still records that
1014-
// the run happened, just without the findings body. The per-run dir
1015-
// is NOT cleaned up if the read fails: leaving the file behind gives
1016-
// the user a chance to recover it manually.
1017-
terminal := result.Outcome == OutcomeQuorum || result.Outcome == OutcomeStalled
1018-
findingsContent := ""
1019-
captured := false
1020-
if terminal && findingsDoc != "" {
1021-
data, readErr := os.ReadFile(findingsDoc) //nolint:gosec // path computed from runID + git common dir
1022-
if readErr != nil {
1023-
logging.Debug(ctx, "investigate: read findings for manifest capture",
1024-
slog.String("err", readErr.Error()), slog.String("run_id", runID))
1025-
} else {
1026-
findingsContent = string(data)
1027-
captured = true
1028-
}
1029-
}
1030-
1031-
m := LocalManifest{
1032-
RunID: runID,
1033-
Topic: topic,
1034-
Slug: SlugifyTopic(topic),
1035-
StartingSHA: startingSHA,
1036-
WorktreePath: worktreePath,
1037-
FindingsDoc: findingsDoc,
1038-
FindingsContent: findingsContent,
1039-
Agents: append([]string(nil), agents...),
1040-
Outcome: string(result.Outcome),
1041-
StancesByAgent: stancesByAgent,
1042-
StartedAt: startedAt,
1043-
EndedAt: endedAt,
1044-
}
1045-
if writeErr := manifestStore.Write(ctx, m); writeErr != nil {
1046-
logging.Debug(ctx, "investigate: manifest write failed",
1047-
slog.String("err", writeErr.Error()), slog.String("run_id", runID))
1048-
return
1049-
}
1050-
1051-
// Clean up the per-run dir only AFTER the manifest write succeeds
1052-
// and only when the findings body was captured. This keeps failure
1053-
// modes safe: a manifest write failure leaves the per-run dir intact
1054-
// (for retry/inspection); a read failure leaves the file on disk so
1055-
// the user can recover it.
1056-
if terminal && captured && findingsDoc != "" {
1057-
runDir := filepath.Dir(findingsDoc)
1058-
if rmErr := os.RemoveAll(runDir); rmErr != nil {
1059-
logging.Debug(ctx, "investigate: cleanup per-run dir",
1060-
slog.String("err", rmErr.Error()), slog.String("run_id", runID))
1061-
}
1062-
}
1063-
1064-
writeInvestigateFooter(out, m)
1065-
}
1066-
1067-
// writeInvestigateFooter prints the post-run summary, the findings
1068-
// content, and how to run `trace investigate fix`. The findings
1069-
// content comes from the manifest's embedded FindingsContent on
1070-
// terminal outcomes (Quorum/Stalled — the per-run dir is gone); on
1071-
// paused/cancelled outcomes findings.md is read from the per-run dir.
1072-
func writeInvestigateFooter(w io.Writer, m LocalManifest) {
1073-
fmt.Fprintln(w)
1074-
if m.Outcome != "" {
1075-
fmt.Fprintf(w, "Outcome: %s\n", m.Outcome)
1076-
}
1077-
// Quorum/Stalled are terminal (per-run dir cleaned, findings captured);
1078-
// Paused/Cancelled are resumable. "complete" would mislead users into
1079-
// thinking a paused run can't be picked up.
1080-
switch m.Outcome {
1081-
case string(OutcomePaused), string(OutcomeCancelled):
1082-
fmt.Fprintln(w, "Investigation ended (resumable with `trace investigate --continue "+m.RunID+"`).")
1083-
default:
1084-
fmt.Fprintln(w, "Investigation complete.")
1085-
}
1086-
fmt.Fprintln(w)
1087-
1088-
body := findingsContentFor(m)
1089-
if body != "" {
1090-
rendered, renderErr := mdrender.RenderForWriter(w, body)
1091-
if renderErr != nil {
1092-
// Fall back to raw markdown when glamour fails (malformed
1093-
// style config, unexpected runtime).
1094-
rendered = body
1095-
}
1096-
fmt.Fprint(w, rendered)
1097-
if !strings.HasSuffix(rendered, "\n") {
1098-
fmt.Fprintln(w)
1099-
}
1100-
fmt.Fprintln(w)
1101-
}
1102-
1103-
// For terminal outcomes, suggest `fix` (which feeds findings into a
1104-
// coding agent). For paused/cancelled, `fix` would launch off stale
1105-
// partial findings; the resume hint above is the right next step
1106-
// instead.
1107-
switch m.Outcome {
1108-
case string(OutcomePaused), string(OutcomeCancelled):
1109-
// Resume hint already emitted above.
1110-
default:
1111-
fmt.Fprintln(w, "To apply these findings:")
1112-
fmt.Fprintf(w, " trace investigate fix %s\n", m.RunID)
1113-
}
1114-
}
1115-
1116-
// findingsContentFor returns the findings body to render in the footer.
1117-
// Prefers the manifest's embedded content (set on terminal outcomes
1118-
// when the per-run dir has been cleaned); falls back to reading the
1119-
// on-disk findings.md for paused/cancelled outcomes. Errors and
1120-
// missing files both yield "" — the caller prints a shorter footer.
1121-
func findingsContentFor(m LocalManifest) string {
1122-
if m.FindingsContent != "" {
1123-
return m.FindingsContent
1124-
}
1125-
if m.FindingsDoc == "" {
1126-
return ""
1127-
}
1128-
data, err := os.ReadFile(m.FindingsDoc)
1129-
if err != nil {
1130-
return ""
1131-
}
1132-
return string(data)
1133-
}
1134-
1135-
// newRunID returns a fresh 12-hex-char run identifier, sharing the
1136-
// checkpoint-id format used by the strategy package.
1137-
func newRunID() (string, error) {
1138-
cid, err := id.Generate()
1139-
if err != nil {
1140-
return "", fmt.Errorf("generate run ID: %w", err)
1141-
}
1142-
return cid.String(), nil
1143-
}
1144-
1145-
// currentHeadSHA returns the current HEAD commit hash as a 40-char hex
1146-
// string.
1147-
func currentHeadSHA(ctx context.Context, repoRoot string) (string, error) {
1148-
return gitexec.HeadSHA(ctx, repoRoot) //nolint:wrapcheck // gitexec already wraps
1149-
}
1150-
1151-
// wrapSilent applies the silent-error wrapper if it is non-nil.
1152-
func wrapSilent(fn func(error) error, err error) error {
1153-
if fn == nil {
1154-
return err
1155-
}
1156-
return fn(err)
1157-
}

0 commit comments

Comments
 (0)