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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,14 @@ Initial implementation.
the command list (still invocable), over-long command summaries were
shortened, and the init --forge default URL moved out of the flag line
(an unbreakable token that could not fit pflag's description column).
- Runs execute in the background by default: `traust-cli run` spawns a
detached worker process (setsid, survives the terminal) and attaches to
it; `--detach` fires and forgets. The run journal is the event bus:
`attach` replays it and follows it live (Ctrl-C detaches without
cancelling — x twice cancels), `runs` lists run states, `stop` cancels
through a graceful SIGINT/SIGTERM/SIGKILL ladder, `logs` renders the
transcript and `result` prints the stored outcome with the run's own
exit code. Attaching to a finished run replays the full transcript.
- The monitor now shows tool results (crush tool_result parts, truncated),
which are the bulk of a run's output; journal raw lines no longer
duplicate parsed events, so attach replays render once.
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,12 @@ need is an agent harness you are logged into.
| `doctor` | CLI checks + `install_traust --doctor`; `--json` for CI. |
| `update` | Move the pin to a newer tag and re-sync. |
| `skills` | Catalog parsed from the same SKILL.md frontmatter the harness reads; TUI picker on a TTY, table otherwise; `--json`. |
| `run <skill> [args…]` | Compose the SKILL.md into a prompt, pre-flight the model floor, drive the harness headlessly, journal everything, discover and validate artifacts, declare spend. Live TUI monitor on a TTY. |
| `run <skill> [args…]` | Compose the SKILL.md into a prompt, pre-flight the model floor, and launch the run **in the background** (a detached worker process that survives the terminal), then attach. `-d/--detach` starts without attaching; `--json`/`--no-monitor` keep synchronous CI semantics. The journal records everything; artifacts are validated and spend declared. |
| `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. |
| `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). |
| `ledger whoami\|findings\|layers\|verify` | Read-only views over a ledger service (`--url` or `TRAUST_LEDGER_URL`; the token comes from the env var named by `--token-env`). No submissions, no countersign. |

Expand Down
250 changes: 250 additions & 0 deletions cmd/attach.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
package cmd

import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"

"github.com/spf13/cobra"

"github.com/traust-security/traust-cli/internal/harness"
"github.com/traust-security/traust-cli/internal/runner"
"github.com/traust-security/traust-cli/internal/theme"
"github.com/traust-security/traust-cli/internal/tui"
)

var attachCmd = &cobra.Command{
Use: "attach <run>",
Short: "Watch a run live (Ctrl-C detaches without cancelling)",
Long: `Attach to a background run: replay its journal from the start and follow
it live. Ctrl-C, q or d detaches — the run keeps going in its own process.
x twice cancels the run. Attaching to a finished run replays the whole
transcript and shows the result. Without a terminal, attach degrades to a
plain live tail.`,
Args: cobra.ExactArgs(1),
Run: runAttach,
}

func init() {
rootCmd.AddCommand(attachCmd)
}

func runAttach(cmd *cobra.Command, args []string) {
state, err := loadState()
if err != nil {
fatalf(1, "%v", err)
}
info, err := resolveRun(state.RunsDir(), args[0])
if err != nil {
fatalf(1, "%v", err)
}
attachByID(info)
}

// attachByID runs the attach flow over a resolved run.
func attachByID(info *RunInfo) {
// Header from the run's spec.
header := tui.MonitorHeader{Skill: info.Skill, RunID: info.ID}
if data, err := os.ReadFile(filepath.Join(info.Dir, "spec.json")); err == nil {
var spec runner.Spec
if json.Unmarshal(data, &spec) == nil {
header.Harness = spec.Harness
header.Model = spec.Model
header.BudgetCap = spec.BudgetCap
if t, err := time.Parse(time.RFC3339, spec.StartedAt); err == nil {
header.StartedAt = t
}
}
}

if !isTTY() {
plainAttach(info)
return
}

follow := make(chan tui.MonitorItem, 128)
go func() {
tailJournal(info.Dir, follow)
}()

_, err := tui.Attach(header, follow, func() { stopRun(info) })
if err != nil {
fatalf(1, "attach: %v", err)
}
printAttachExit(info)
}

// plainAttach is the non-TTY tail: replay + follow until the run finishes.
func plainAttach(info *RunInfo) {
styles := theme.Default()
fmt.Println(styles.Accent.Render(fmt.Sprintf("▸ attaching to %s (%s) — Ctrl-C detaches", info.ID, info.State)))
items := make(chan tui.MonitorItem, 128)
go func() {
tailJournal(info.Dir, items)
}()
for it := range items {
switch it.Kind {
case tui.ItemEvent:
renderPlainEvent(it.Event)
case tui.ItemRaw:
if it.Text != "" {
fmt.Println(tui.Wrap(styles.Muted.Render(" "+it.Text), tui.TerminalWidth()))
}
case tui.ItemProgress:
fmt.Println(tui.Wrap(styles.Accent.Render("› "+it.Text), tui.TerminalWidth()))
case tui.ItemDone:
fmt.Println(styles.Muted.Render("— run complete —"))
}
}
printResultFromDir(info.Dir)
}

// tailJournal replays the run's stream.jsonl from the start and follows it
// until result.json appears (or the run is already finished). Header usage
// is refreshed in passing (budget cap arrives with spec.json).
func tailJournal(dir string, out chan<- tui.MonitorItem) {
defer close(out)
stream := filepath.Join(dir, "stream.jsonl")

// Wait briefly for the journal to exist (the worker may still be in
// pre-flight when attach races the start).
deadline := time.Now().Add(20 * time.Second)
for {
if _, err := os.Stat(stream); err == nil {
break
}
if resultExists(dir) || time.Now().After(deadline) {
emitResultLines(dir, out)
return
}
time.Sleep(150 * time.Millisecond)
}

tailer, err := runner.NewTailer(stream, 0)
if err != nil {
out <- tui.MonitorItem{Kind: tui.ItemProgress, Text: "warning: cannot open journal: " + err.Error()}
emitResultLines(dir, out)
return
}
defer tailer.Close()

for {
lines, err := tailer.Read()
if err != nil {
out <- tui.MonitorItem{Kind: tui.ItemProgress, Text: "warning: journal read error: " + err.Error()}
break
}
for _, line := range lines {
if it, ok := journalLineToItem(line); ok {
out <- it
}
}
if resultExists(dir) {
// Drain any final lines, then finish.
for {
lines, err := tailer.Read()
if err != nil || len(lines) == 0 {
break
}
for _, line := range lines {
if it, ok := journalLineToItem(line); ok {
out <- it
}
}
}
break
}
time.Sleep(200 * time.Millisecond)
}
emitResultLines(dir, out)
}

func resultExists(dir string) bool {
_, err := os.Stat(filepath.Join(dir, "result.json"))
return err == nil
}

// journalLineToItem converts one stream.jsonl line into a monitor item.
func journalLineToItem(line string) (tui.MonitorItem, bool) {
entry, err := runner.ParseEntry([]byte(line))
if err != nil {
return tui.MonitorItem{}, false
}
switch entry.Kind {
case "event":
if entry.Event == nil {
return tui.MonitorItem{}, false
}
return tui.MonitorItem{Kind: tui.ItemEvent, Event: harnessEvent(*entry.Event)}, true
case "raw":
return tui.MonitorItem{Kind: tui.ItemRaw, Text: entry.Raw}, true
}
return tui.MonitorItem{}, false
}

// harnessEvent widens a journal event record into the harness event shape
// (identical fields, different types for the kind).
func harnessEvent(e runner.Event) harness.Event {
return harness.Event{
Kind: harness.EventKind(e.Kind),
Text: e.Text,
Tool: e.Tool,
TokensIn: e.TokensIn,
TokensOut: e.TokensOut,
CostUSD: e.CostUSD,
IsError: e.IsError,
}
}

// emitResultLines appends terminal progress lines from result.json.
func emitResultLines(dir string, out chan<- tui.MonitorItem) {
data, err := os.ReadFile(filepath.Join(dir, "result.json"))
if err != nil {
out <- tui.MonitorItem{Kind: tui.ItemProgress, Text: "warning: run ended without a result (orphaned worker?)"}
return
}
var res runner.RunResult
if json.Unmarshal(data, &res) != nil {
return
}
for _, a := range res.Artifacts {
mark := "artifact"
if !a.Valid {
mark = "artifact failed schema validation"
}
out <- tui.MonitorItem{Kind: tui.ItemProgress, Text: fmt.Sprintf("%s: %s", mark, a.Path)}
}
if res.HarnessError != "" {
out <- tui.MonitorItem{Kind: tui.ItemProgress, Text: "harness error: " + res.HarnessError}
}
}

func printAttachExit(info *RunInfo) {
styles := theme.Default()
short := shortRunID(info.ID)
refreshed, err := loadRunInfo(info.Dir)
if err == nil && refreshed != nil && refreshed.State != stateRunning {
// The run finished while attached: show the summary.
printResultFromDir(info.Dir)
return
}
fmt.Println()
fmt.Println(styles.OK.Render("▸ detached — the run continues in the background"))
kv("re-attach", "traust-cli attach "+short)
kv("result", "traust-cli result "+short)
}

// printResultFromDir renders the stored result.json as the run summary.
func printResultFromDir(dir string) {
data, err := os.ReadFile(filepath.Join(dir, "result.json"))
if err != nil {
fatalf(1, "no result for this run (orphaned?) — journal: %s", dir)
}
var res runner.RunResult
if err := json.Unmarshal(data, &res); err != nil {
fatalf(1, "parse result.json: %v", err)
}
printRunSummary(&res)
}
Loading
Loading