From 753e00abb3725ae95a4ec68294fc297499630992 Mon Sep 17 00:00:00 2001 From: Gurney Buchanan Date: Wed, 21 Oct 2026 14:17:09 -0400 Subject: [PATCH] Add peek: a one-shot snapshot of a run or flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Checking on a background run meant either attach (a full-screen session) or logs (the entire transcript). peek is the quick glance: state, usage and the last dozen journal entries, printed and gone. peek resolves runs and flows alike โ€” a run peek shows its tail, a flow peek shows the stage chain and recurses into the current stage's run โ€” so one command answers "what is happening right now" at any level of the pipeline. The plain-event renderer was refactored into a string form (renderEventString) shared by the sink, logs, plain attach and peek, so every surface renders events identically. ๐Ÿ’˜ Generated with Crush Assisted-by: Crush:rits/zai-org/glm-5-3 --- CHANGELOG.md | 4 + README.md | 1 + cmd/flow.go | 20 +++-- cmd/peek.go | 191 +++++++++++++++++++++++++++++++++++++++++++++++ cmd/peek_test.go | 84 +++++++++++++++++++++ cmd/run.go | 29 +------ 6 files changed, 298 insertions(+), 31 deletions(-) create mode 100644 cmd/peek.go create mode 100644 cmd/peek_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c649ae..cac3637 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -101,3 +101,7 @@ Initial implementation. is an ordinary background run. `flow status` lists flows and stage detail; interactive flows follow stage-by-stage with Ctrl-C detaching without cancelling. +- `peek `: a one-shot snapshot of what is happening right now + โ€” state, usage and the last dozen journal entries, printed and gone. A + flow peek shows the stage chain and recurses into the current stage's + run. diff --git a/README.md b/README.md index 293b7cc..a53cefd 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,7 @@ need is an agent harness you are logged into. | `attach ` | Live monitor over a background run: full transcript replay + live tail with thinking, tool calls and tool results. **Ctrl-C detaches without cancelling** (the run keeps going); `x` twice cancels. Works on finished runs too (full replay + result). Non-TTY degrades to a plain tail. | | `runs` (`ls`/`ps`) | List runs โ€” running first, then newest โ€” with state (running/done/failed/needs-input/budget-stop/aborted/orphaned), age and tokens; `--json`. | | `stop ` | Graceful cancel: SIGINT (context-driven teardown: crush cancel endpoint, server shutdown) โ†’ SIGTERM โ†’ SIGKILL. | +| `peek ` | One-shot snapshot: state, usage and the last dozen journal entries โ€” for runs and flows alike (a flow peek shows the stage chain plus the current stage's snapshot). | | `logs ` | The run's rendered journal transcript. | | `result ` | The stored result: summary, artifacts, usage, error โ€” exits with the run's own code. | | `report ` | Validate (`reporting validate`), lint threat models (`reporting lint`), render (`reporting render`), and browse findings (severity-colored TUI). | diff --git a/cmd/flow.go b/cmd/flow.go index 6415226..f2c774e 100644 --- a/cmd/flow.go +++ b/cmd/flow.go @@ -248,16 +248,25 @@ func runFlowStatus(cmd *cobra.Command, args []string) { } func resolveFlow(dir, prefix string) *flowManifest { + m, err := resolveFlowE(dir, prefix) + if err != nil { + fatalf(1, "%v", err) + } + return m +} + +// resolveFlowE resolves a flow by id prefix without exiting. +func resolveFlowE(dir, prefix string) (*flowManifest, error) { entries, err := os.ReadDir(dir) if err != nil { - fatalf(1, "no flows: %v", err) + return nil, fmt.Errorf("no flows: %w", err) } var matches []*flowManifest for _, e := range entries { if !e.IsDir() { continue } - if e.Name() == prefix || (len(prefix) >= 1 && (e.Name() == prefix || hasSuffixID(e.Name(), prefix))) { + if e.Name() == prefix || hasSuffixID(e.Name(), prefix) { if m, err := loadFlowManifest(filepath.Join(dir, e.Name())); err == nil { matches = append(matches, m) } @@ -265,13 +274,12 @@ func resolveFlow(dir, prefix string) *flowManifest { } switch len(matches) { case 1: - return matches[0] + return matches[0], nil case 0: - fatalf(1, "no flow matches %q", prefix) + return nil, fmt.Errorf("no flow matches %q", prefix) default: - fatalf(1, "ambiguous flow prefix %q", prefix) + return nil, fmt.Errorf("ambiguous flow prefix %q", prefix) } - return nil } func hasSuffixID(name, prefix string) bool { diff --git a/cmd/peek.go b/cmd/peek.go new file mode 100644 index 0000000..d1bd304 --- /dev/null +++ b/cmd/peek.go @@ -0,0 +1,191 @@ +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/cobra" + + "github.com/traust-security/traust-cli/internal/harness" + "github.com/traust-security/traust-cli/internal/theme" + "github.com/traust-security/traust-cli/internal/tui" +) + +// peekTail is how many journal entries a peek shows. +const peekTail = 12 + +var peekCmd = &cobra.Command{ + Use: "peek ", + Short: "Snapshot a run's (or flow's) current state and recent output", + Long: `A one-shot look at what is happening right now: state, usage and the +last few journal entries โ€” printed and gone, no following. For live +viewing use attach; for the full transcript use logs.`, + Args: cobra.ExactArgs(1), + Run: runPeek, +} + +func init() { + rootCmd.AddCommand(peekCmd) +} + +func runPeek(cmd *cobra.Command, args []string) { + state, err := loadState() + if err != nil { + fatalf(1, "%v", err) + } + + // Runs first, then flows โ€” a peek is equally valid on either. + if info, err := resolveRun(state.RunsDir(), args[0]); err == nil { + fmt.Print(peekRun(info)) + return + } + if m, err := resolveFlowE(flowsDir(state.Workspace), args[0]); err == nil { + fmt.Print(peekFlow(m, state.RunsDir())) + return + } + fatalf(1, "no run or flow matches %q โ€” see `traust-cli runs` / `traust-cli flow status`", args[0]) +} + +// peekRun renders a run snapshot: header line, then the journal tail. +func peekRun(info *RunInfo) string { + var b strings.Builder + styles := theme.Default() + + stateStyle := styles.OK + switch info.State { + case stateFailed, stateOrphaned: + stateStyle = styles.Err + case stateRunning: + stateStyle = styles.Accent + case stateAborted, stateBudget, stateNeedsIn: + stateStyle = styles.Warn + } + head := fmt.Sprintf("%s %s ยท %s ยท %s", shortRunID(info.ID), info.Skill, stateStyle.Render(info.State), humanAgeSince(info)) + if info.Tokens.In+info.Tokens.Out > 0 { + head += fmt.Sprintf(" ยท in %dk out %dk", info.Tokens.In/1000, info.Tokens.Out/1000) + } + b.WriteString(styles.Header.Render(head) + "\n") + + for _, line := range journalTailLines(filepath.Join(info.Dir, "stream.jsonl"), peekTail) { + b.WriteString(line) + } + + switch info.State { + case stateRunning: + b.WriteString(styles.Muted.Render(fmt.Sprintf("โ€ฆ live: traust-cli attach %s", shortRunID(info.ID))) + "\n") + case stateOrphaned: + b.WriteString(styles.Warn.Render("orphaned: the worker died without writing a result") + "\n") + } + return b.String() +} + +// peekFlow renders a flow snapshot: the stage chain, then a peek of the +// current (or last) stage's run. +func peekFlow(m *flowManifest, runsDir string) string { + var b strings.Builder + styles := theme.Default() + + stateStyle := styles.OK + if m.State == stateFailed { + stateStyle = styles.Err + } else if m.State == stateRunning { + stateStyle = styles.Accent + } + b.WriteString(styles.Header.Render(fmt.Sprintf("flow %s ยท %s ยท %s ยท %s", shortFlowID(m.ID), m.Flow, stateStyle.Render(m.State), m.Target)) + "\n") + + var names []string + for _, s := range m.Stages { + mark := "โœ“" + switch s.State { + case stateFailed: + mark = "โœ—" + case stateRunning: + mark = "โ–ธ" + case "pending": + mark = "ยท" + case "skipped": + mark = "-" + } + names = append(names, fmt.Sprintf("%s %s", mark, s.Name)) + } + b.WriteString(styles.Muted.Render(strings.Join(names, " โ†’ ")) + "\n") + + if cur := m.currentStage(); cur != nil && cur.RunID != "" { + if info, err := loadRunInfo(filepath.Join(runsDir, cur.RunID)); err == nil && info != nil { + b.WriteString("\n") + b.WriteString(peekRun(info)) + } + } + return b.String() +} + +// journalTailLines renders the last n entries of a stream journal as +// display lines. +func journalTailLines(path string, n int) []string { + data, err := os.ReadFile(path) + if err != nil { + return []string{theme.Default().Muted.Render(" (no journal yet)") + "\n"} + } + lines := splitLines(string(data)) + start := len(lines) - n + if start < 0 { + start = 0 + } + var out []string + for _, line := range lines[start:] { + if it, ok := journalLineToItem(line); ok { + switch it.Kind { + case tui.ItemEvent: + if s := renderEventString(it.Event); s != "" { + out = append(out, s) + } + case tui.ItemRaw: + if it.Text != "" { + out = append(out, tui.Wrap(theme.Default().Muted.Render(" "+it.Text), tui.TerminalWidth())+"\n") + } + } + } + } + return out +} + +func humanAgeSince(info *RunInfo) string { + if info.StartedAt.IsZero() { + return "age ?" + } + return humanAge(time.Since(info.StartedAt)) +} + +// renderEventString renders one harness event as display lines (the string +// form of renderPlainEvent). +func renderEventString(ev harness.Event) string { + styles := theme.Default() + switch ev.Kind { + case harness.EventText: + return tui.Wrap(ev.Text, tui.TerminalWidth()) + "\n" + case harness.EventThinking: + return tui.Wrap(styles.Muted.Render("ยท "+ev.Text), tui.TerminalWidth()) + "\n" + case harness.EventToolCall: + line := "โ†’ " + ev.Tool + if ev.Text != "" { + line += " " + ev.Text + } + return tui.Wrap(styles.Accent.Render(line), tui.TerminalWidth()) + "\n" + case harness.EventToolResult: + style := styles.Muted + if ev.IsError { + style = styles.Warn + } + var b strings.Builder + for _, l := range strings.Split(ev.Text, "\n") { + b.WriteString(tui.Wrap(style.Render(" โ”‚ "+l), tui.TerminalWidth()) + "\n") + } + return b.String() + case harness.EventError: + return tui.Wrap(styles.Err.Render("โœ— "+ev.Text), tui.TerminalWidth()) + "\n" + } + return "" +} diff --git a/cmd/peek_test.go b/cmd/peek_test.go new file mode 100644 index 0000000..930ba3b --- /dev/null +++ b/cmd/peek_test.go @@ -0,0 +1,84 @@ +package cmd + +import ( + "os" + "path/filepath" + "strconv" + "strings" + "testing" +) + +// TestPeekRunTail verifies a run snapshot shows the state and the LAST few +// journal entries (not the head). +func TestPeekRunTail(t *testing.T) { + ws := t.TempDir() + state := setupWorkerWorkspace(t, ws) + + runID := "20261021-180000-test-peeks" + dir := filepath.Join(state.RunsDir(), runID) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + var journal strings.Builder + for i := 1; i <= 20; i++ { + journal.WriteString(`{"ts":"t","kind":"event","event":{"kind":"text","text":"line ` + itoa(i) + `"}}` + "\n") + } + if err := os.WriteFile(filepath.Join(dir, "stream.jsonl"), []byte(journal.String()), 0o644); err != nil { + t.Fatal(err) + } + // A result marks the run done. + if err := os.WriteFile(filepath.Join(dir, "result.json"), []byte(`{"run_id":"`+runID+`","skill":"secure-code-audit","exit_code":0,"usage":{"tokens_in":100,"tokens_out":50}}`), 0o644); err != nil { + t.Fatal(err) + } + + info, err := loadRunInfo(dir) + if err != nil || info == nil { + t.Fatal(err) + } + out := peekRun(info) + + if !strings.Contains(out, "done") { + t.Errorf("peek missing state: %q", out) + } + if !strings.Contains(out, "line 20") || !strings.Contains(out, "line 9") { + t.Errorf("peek should show the journal tail: %q", out) + } + if strings.Contains(out, "line 8") { + t.Errorf("peek shows more than the tail window: %q", out) + } +} + +// TestPeekFlowRecursesCurrentStage verifies a flow peek includes the +// current stage's snapshot. +func TestPeekFlowRecursesCurrentStage(t *testing.T) { + ws := t.TempDir() + state := setupWorkerWorkspace(t, ws) + + runID := "20261021-180100-test-peekf" + runDir := filepath.Join(state.RunsDir(), runID) + os.MkdirAll(runDir, 0o755) + os.WriteFile(filepath.Join(runDir, "stream.jsonl"), []byte(`{"ts":"t","kind":"event","event":{"kind":"text","text":"stage activity"}}`+"\n"), 0o644) + os.WriteFile(filepath.Join(runDir, "pid"), []byte(strconv.Itoa(os.Getpid())), 0o644) + os.WriteFile(filepath.Join(runDir, "options.json"), []byte(`{"skill":"triage"}`), 0o644) + + flowID := "20261021-180200-flow-peek" + flowDir := filepath.Join(flowsDir(ws), flowID) + os.MkdirAll(flowDir, 0o755) + os.WriteFile(filepath.Join(flowDir, "pipeline.json"), []byte(`{"id":"`+flowID+`","flow":"audit-triage","target":"https://github.com/example/repo","state":"running","stages":[`+ + `{"name":"audit","skill":"secure-code-audit","run_id":"x1","state":"done"},`+ + `{"name":"triage","skill":"triage","run_id":"`+runID+`","state":"running"}]}`), 0o644) + + m, err := loadFlowManifest(flowDir) + if err != nil { + t.Fatal(err) + } + out := peekFlow(m, state.RunsDir()) + + for _, want := range []string{"โœ“ audit", "โ–ธ triage", "stage activity"} { + if !strings.Contains(out, want) { + t.Errorf("flow peek missing %q: %q", want, out) + } + } +} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/cmd/run.go b/cmd/run.go index b31a279..2a0ae64 100644 --- a/cmd/run.go +++ b/cmd/run.go @@ -301,32 +301,11 @@ func (plainSink) Progress(msg string) { func (plainSink) Event(ev harness.Event) { renderPlainEvent(ev) } // renderPlainEvent prints one harness event as styled plain text โ€” shared -// by the synchronous sink, plain attach and `logs`. +// by the synchronous sink, plain attach and `logs`. The string form lives +// in renderEventString (peek). func renderPlainEvent(ev harness.Event) { - styles := theme.Default() - switch ev.Kind { - case harness.EventText: - fmt.Println(tui.Wrap(ev.Text, tui.TerminalWidth())) - case harness.EventThinking: - fmt.Println(tui.Wrap(styles.Muted.Render("ยท "+ev.Text), tui.TerminalWidth())) - case harness.EventToolCall: - line := "โ†’ " + ev.Tool - if ev.Text != "" { - line += " " + ev.Text - } - fmt.Println(tui.Wrap(styles.Accent.Render(line), tui.TerminalWidth())) - case harness.EventToolResult: - style := styles.Muted - if ev.IsError { - style = styles.Warn - } - for _, l := range strings.Split(ev.Text, "\n") { - fmt.Println(tui.Wrap(style.Render(" โ”‚ "+l), tui.TerminalWidth())) - } - case harness.EventResult: - // Result text is printed in the summary. - case harness.EventError: - fmt.Println(tui.Wrap(styles.Err.Render("โœ— "+ev.Text), tui.TerminalWidth())) + if s := renderEventString(ev); s != "" { + fmt.Print(s) } }