Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <run-or-flow>`: 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.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ need is an agent harness you are logged into.
| `attach <run>` | 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 <run>` | Graceful cancel: SIGINT (context-driven teardown: crush cancel endpoint, server shutdown) → SIGTERM → SIGKILL. |
| `peek <run-or-flow>` | 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 <run>` | The run's rendered journal transcript. |
| `result <run>` | The stored result: summary, artifacts, usage, error — exits with the run's own code. |
| `report <file-or-dir>` | Validate (`reporting validate`), lint threat models (`reporting lint`), render (`reporting render`), and browse findings (severity-colored TUI). |
Expand Down
20 changes: 14 additions & 6 deletions cmd/flow.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,30 +248,38 @@ 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)
}
}
}
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 {
Expand Down
191 changes: 191 additions & 0 deletions cmd/peek.go
Original file line number Diff line number Diff line change
@@ -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 <run-or-flow>",
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 ""
}
84 changes: 84 additions & 0 deletions cmd/peek_test.go
Original file line number Diff line number Diff line change
@@ -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) }
29 changes: 4 additions & 25 deletions cmd/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
Loading