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
89 changes: 77 additions & 12 deletions cmd/internal/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -129,9 +129,7 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar
e, ref := resolveExecutableForRun(ctx, cmd, verb, args)

// Handle --background: spawn a detached child process and return immediately.
background := flags.ValueFor[bool](cmd, *flags.BackgroundFlag, false)
if background {
launchBackground(ctx, ref, verb, args)
if maybeLaunchBackground(ctx, cmd, ref) {
return
}

Expand Down Expand Up @@ -269,13 +267,22 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c
}
}
setTransientContext(ctx, cmd, e, dir)
if maybeLaunchBackground(ctx, cmd, e.Ref()) {
return
}
runTransientExecutable(ctx, cmd, e, joined, label)
}

// execTransientSpec runs a transient executable parsed from an inline definition (--spec). Unlike
// --cmd, the spec can be any executable type (exec, serial, parallel, request, render, launch); it
// is never written to disk but runs through the normal engine and is recorded in history.
func execTransientSpec(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, spec string) {
// A detached background child inherits no stdin, so a spec piped via stdin can't be re-read.
if spec == "-" && flags.ValueFor[bool](cmd, *flags.BackgroundFlag, false) {
errhandler.HandleUsage(ctx, cmd, "--background cannot be combined with a --spec read from stdin ('-')")
return
}

content, err := resolveSpecContent(spec)
if err != nil {
errhandler.HandleFatal(ctx, cmd, err)
Expand All @@ -298,12 +305,29 @@ func execTransientSpec(ctx *context.Context, cmd *cobra.Command, verb executable
}

label := flags.ValueFor[string](cmd, *flags.LabelFlag, false)
// A spec may omit `name`; fall back to a ref-safe name derived from --label so history and
// the background-run record show a meaningful ref rather than an empty "verb ws/".
if e.Name == "" {
e.Name = transientSpecName(label)
}
if label == "" {
label = e.Name
}
if maybeLaunchBackground(ctx, cmd, e.Ref()) {
return
}
runTransientExecutable(ctx, cmd, e, "", label)
}

// transientSpecName derives a ref-safe name for a --spec run that omitted `name`, preferring the
// --label and falling back to a generic default.
func transientSpecName(label string) string {
if slug := slugify(label); slug != "" {
return "spec-" + slug
}
return "spec"
}

// resolveSpecContent resolves a --spec value into raw definition content: '-' reads stdin,
// a leading '@' reads the named file, and anything else is treated as inline content.
func resolveSpecContent(spec string) (string, error) {
Expand Down Expand Up @@ -472,17 +496,32 @@ func slugify(s string) string {
return out
}

// launchBackground spawns a detached flow process for the given executable and returns immediately.
func launchBackground(ctx *context.Context, ref executable.Ref, verb executable.Verb, args []string) {
// maybeLaunchBackground spawns a detached child process for the run and returns true when it did,
// signalling the caller to return without executing inline. It is a no-op (returns false) unless
// --background is set and this process is not itself a background child — the latter guard prevents
// the detached child, which re-runs the same command, from forking again forever.
func maybeLaunchBackground(ctx *context.Context, cmd *cobra.Command, ref executable.Ref) bool {
if !flags.ValueFor[bool](cmd, *flags.BackgroundFlag, false) {
return false
}
if os.Getenv(backgroundRunIDEnv) != "" {
return false
}
launchBackground(ctx, ref)
return true
}

// launchBackground spawns a detached flow process for the current invocation and returns
// immediately. The child re-runs this exact command (verb, args, and every flag) so ad-hoc
// (--cmd), transient (--spec), and named executables all background identically.
func launchBackground(ctx *context.Context, ref executable.Ref) {
runID := uuid.New().String()[:8]

// Build the child command: same verb + args. Stdout/stderr are set to nil so
// Go redirects them to /dev/null — terminal output is suppressed but the tuikit
// archive handler still writes to the log file normally.
childArgs := []string{string(verb)}
if len(args) > 0 {
childArgs = append(childArgs, args...)
}
// Reconstruct the child command from this process's own args, dropping only the background
// flag so the child runs inline. Stdout/stderr/stdin are nil so Go redirects them to
// /dev/null — terminal output is suppressed but the tuikit archive handler still writes to
// the log file normally.
childArgs := backgroundChildArgs(os.Args[1:])

flowBin, err := os.Executable()
if err != nil {
Expand Down Expand Up @@ -519,6 +558,32 @@ func launchBackground(ctx *context.Context, ref executable.Ref, verb executable.
logger.Log().Println(fmt.Sprintf("Started background run %s (PID %d) for %s", runID, run.PID, ref))
}

// backgroundChildArgs returns the CLI args for a detached child: the current process's args with
// the --background/-b flag removed so the child executes inline instead of forking again. Both the
// long and short forms are stripped, including the `--background=true` value form. Anything after a
// `--` separator is passed through untouched so a passthrough arg to the target isn't mistaken for
// the flag.
func backgroundChildArgs(args []string) []string {
out := make([]string, 0, len(args))
passthrough := false
for _, a := range args {
if passthrough {
out = append(out, a)
continue
}
if a == "--" {
passthrough = true
out = append(out, a)
continue
}
if a == "--background" || a == "-b" || strings.HasPrefix(a, "--background=") {
continue
}
out = append(out, a)
}
return out
}

// linkBackgroundArchive eagerly writes the log archive path into the background run
// record so that `logs attach` can stream output while the child is still executing.
// Unlike findArchiveByID, this scans the log directory directly without skipping empty
Expand Down
65 changes: 65 additions & 0 deletions cmd/internal/exec_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package internal

import (
"slices"
"testing"
)

func TestBackgroundChildArgs_StripsBackgroundFlag(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{
name: "long form among ad-hoc flags",
in: []string{"exec", "--cmd", "echo hi", "--background", "--label", "x"},
want: []string{"exec", "--cmd", "echo hi", "--label", "x"},
},
{
name: "short form with a named executable",
in: []string{"run", "build", "-b"},
want: []string{"run", "build"},
},
{
name: "explicit value form",
in: []string{"exec", "--cmd", "echo hi", "--background=true"},
want: []string{"exec", "--cmd", "echo hi"},
},
{
name: "no background flag is a passthrough",
in: []string{"run", "build", "--", "--arg"},
want: []string{"run", "build", "--", "--arg"},
},
{
name: "preserves args after a -- separator",
in: []string{"run", "test", "--background", "--", "-v", "--run", "TestX"},
want: []string{"run", "test", "--", "-v", "--run", "TestX"},
},
}

for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := backgroundChildArgs(tc.in)
if !slices.Equal(got, tc.want) {
t.Fatalf("backgroundChildArgs(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}

func TestTransientSpecName(t *testing.T) {
cases := map[string]string{
"": "spec",
" ": "spec",
"Deploy API": "spec-deploy-api",
"my-run": "spec-my-run",
"!!!": "spec",
"nightly_build": "spec-nightly-build",
}
for label, want := range cases {
if got := transientSpecName(label); got != want {
t.Fatalf("transientSpecName(%q) = %q, want %q", label, got, want)
}
}
}
28 changes: 28 additions & 0 deletions cmd/internal/flags/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,34 @@ var LogFilterLimitFlag = &Metadata{
Required: false,
}

var LogContentFlag = &Metadata{
Name: "content",
Usage: "Include each record's log output (json/yaml only; already shown for --last text output).",
Default: false,
Required: false,
}

var LogTailFlag = &Metadata{
Name: "tail",
Usage: "Include only the last N lines of log output (implies --content).",
Default: 0,
Required: false,
}

var LogGrepFlag = &Metadata{
Name: "grep",
Usage: "Include only log lines matching this regular expression (implies --content).",
Default: "",
Required: false,
}

var LogMaxBytesFlag = &Metadata{
Name: "max-bytes",
Usage: "Cap included log output to the last N bytes, keeping the tail (implies --content).",
Default: 0,
Required: false,
}

var TemplateWorkspaceFlag = &Metadata{
Name: "workspace",
Shorthand: "w",
Expand Down
26 changes: 24 additions & 2 deletions cmd/internal/logs.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,10 @@ func RegisterLogsCmd(ctx *context.Context, rootCmd *cobra.Command) {
RegisterFlag(ctx, subCmd, *flags.LogFilterClientFlag)
RegisterFlag(ctx, subCmd, *flags.LogFilterSinceFlag)
RegisterFlag(ctx, subCmd, *flags.LogFilterLimitFlag)
RegisterFlag(ctx, subCmd, *flags.LogContentFlag)
RegisterFlag(ctx, subCmd, *flags.LogTailFlag)
RegisterFlag(ctx, subCmd, *flags.LogGrepFlag)
RegisterFlag(ctx, subCmd, *flags.LogMaxBytesFlag)

clearCmd := &cobra.Command{
Use: "clear [ref]",
Expand Down Expand Up @@ -120,16 +124,32 @@ func logFunc(ctx *context.Context, cmd *cobra.Command, args []string) {
return
}

contentOpts, includeContent := buildContentOptions(cmd)

if lastEntry {
if len(records) == 0 {
logger.Log().Fatalf("No execution history found")
}
logs.PrintLastRecord(outputFormat, records[0], ctx.StdOut())
logs.PrintLastRecord(outputFormat, records[0], ctx.StdOut(), contentOpts, includeContent)
} else {
logs.PrintRecords(outputFormat, records)
logs.PrintRecords(outputFormat, records, contentOpts, includeContent)
}
}

// buildContentOptions reads the log-content flags into a ContentOptions and reports whether
// log output should be included in json/yaml output. Any of --tail/--grep/--max-bytes implies
// content inclusion, matching their "implies --content" flag documentation.
func buildContentOptions(cmd *cobra.Command) (logs.ContentOptions, bool) {
opts := logs.ContentOptions{
Tail: flags.ValueFor[int](cmd, *flags.LogTailFlag, false),
Grep: flags.ValueFor[string](cmd, *flags.LogGrepFlag, false),
MaxBytes: flags.ValueFor[int](cmd, *flags.LogMaxBytesFlag, false),
}
includeContent := flags.ValueFor[bool](cmd, *flags.LogContentFlag, false) ||
opts.Tail > 0 || opts.Grep != "" || opts.MaxBytes > 0
return opts, includeContent
}

// durationWithDays extends time.ParseDuration to support a "d" (day) suffix.
var durationWithDaysRe = regexp.MustCompile(`^(\d+)d(.*)$`)

Expand Down Expand Up @@ -381,4 +401,6 @@ const logsExamples = `
flow logs --session <id> # everything one agent session ran
flow logs run build # history for 'run build' executable
flow logs --running # list active background processes
flow logs -o json --tail 50 # include the last 50 lines of each run's output
flow logs --last --grep ERROR # last run, only lines matching /ERROR/
`
6 changes: 6 additions & 0 deletions docs/cli/flow_logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,22 +22,28 @@ flow logs [ref] [flags]
flow logs --session <id> # everything one agent session ran
flow logs run build # history for 'run build' executable
flow logs --running # list active background processes
flow logs -o json --tail 50 # include the last 50 lines of each run's output
flow logs --last --grep ERROR # last run, only lines matching /ERROR/

```

### Options

```
--client string Filter history by the client that launched the run (e.g. 'claude', 'cursor').
--content Include each record's log output (json/yaml only; already shown for --last text output).
--grep string Include only log lines matching this regular expression (implies --content).
-h, --help help for logs
--last Print the last execution's logs
--limit int Maximum number of records to display.
--max-bytes int Cap included log output to the last N bytes, keeping the tail (implies --content).
-o, --output string Output format. One of: yaml, json, or tui.
--running Show only active background processes.
--session string Filter history to a single provenance session ID (e.g. an AI agent session).
--since string Filter history to entries after a duration (e.g. 1h, 30m, 7d).
--source string Filter history by run origin: 'cli' or 'mcp'.
--status string Filter history by status (running, completed, or failed; success/failure accepted as aliases).
--tail int Include only the last N lines of log output (implies --content).
-w, --workspace string Filter history by workspace name.
```

Expand Down
Loading
Loading