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
53 changes: 37 additions & 16 deletions cmd/internal/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,14 +156,14 @@ func execFunc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, ar

startTime := time.Now()
prov := runProvenanceFromEnv()
recordRunStart(ctx, ref, startTime, prov, "", "")
recordRunStart(ctx, ref, startTime, prov, transientMeta{})

eng := engine.NewExecEngine()
runErr := runner.Exec(ctx, e, eng, envMap, execArgs)
dur := time.Since(startTime)

cleanupProcessStore(ctx)
recordExecution(ctx, ref, startTime, dur, runErr, prov, "", "")
recordExecution(ctx, ref, startTime, dur, runErr, prov, transientMeta{})

// Update background run record if this is a child process.
if bgRunID != "" {
Expand Down Expand Up @@ -270,7 +270,7 @@ func execAdHoc(ctx *context.Context, cmd *cobra.Command, verb executable.Verb, c
if maybeLaunchBackground(ctx, cmd, e.Ref()) {
return
}
runTransientExecutable(ctx, cmd, e, joined, label)
runTransientExecutable(ctx, cmd, e, transientMeta{command: joined, label: label, dir: dir})
}

// execTransientSpec runs a transient executable parsed from an inline definition (--spec). Unlike
Expand Down Expand Up @@ -316,7 +316,7 @@ func execTransientSpec(ctx *context.Context, cmd *cobra.Command, verb executable
if maybeLaunchBackground(ctx, cmd, e.Ref()) {
return
}
runTransientExecutable(ctx, cmd, e, "", label)
runTransientExecutable(ctx, cmd, e, transientMeta{spec: content, label: label, dir: runDir})
}

// transientSpecName derives a ref-safe name for a --spec run that omitted `name`, preferring the
Expand Down Expand Up @@ -423,9 +423,9 @@ func workspaceForPath(wsList workspace.WorkspaceList, dir string) *workspace.Wor
}

// runTransientExecutable runs an already-constructed, in-memory executable through the normal engine
// and records it in history with the given command/label provenance. Shared by --cmd and --spec.
// and records it in history along with what it ran. Shared by --cmd and --spec.
func runTransientExecutable(
ctx *context.Context, cmd *cobra.Command, e *executable.Executable, command, label string,
ctx *context.Context, cmd *cobra.Command, e *executable.Executable, meta transientMeta,
) {
ref := e.Ref()

Expand All @@ -440,14 +440,17 @@ func runTransientExecutable(

startTime := time.Now()
prov := runProvenanceFromEnv()
recordRunStart(ctx, ref, startTime, prov, command, label)
if meta.dir != "" {
prov.dir = meta.dir
}
recordRunStart(ctx, ref, startTime, prov, meta)

eng := engine.NewExecEngine()
runErr := runner.Exec(ctx, e, eng, envMap, nil)
dur := time.Since(startTime)

cleanupProcessStore(ctx)
recordExecution(ctx, ref, startTime, dur, runErr, prov, command, label)
recordExecution(ctx, ref, startTime, dur, runErr, prov, meta)

if runErr != nil {
errhandler.HandleFatal(ctx, cmd, runErr)
Expand Down Expand Up @@ -688,9 +691,12 @@ func cleanupProcessStore(ctx *context.Context) {
}
}

// provenance bundles who/what launched a run, recorded on its execution record.
// provenance bundles who/what launched a run, and from where, recorded on its execution record.
type provenance struct {
source, client, session string
// dir is where the run executed. Defaults to the process working directory, which is the
// caller's cwd; ad-hoc runs override it with their resolved --dir.
dir string
}

// runProvenanceFromEnv resolves run provenance from environment variables set by the caller
Expand All @@ -700,20 +706,31 @@ func runProvenanceFromEnv() provenance {
if source == "" {
source = store.RunSourceCLI
}
wd, _ := os.Getwd()
return provenance{
source: source,
client: os.Getenv(store.RunClientEnv),
session: os.Getenv(store.RunSessionEnv),
dir: wd,
}
}

// transientMeta describes what a transient run actually executed: the shell command for --cmd,
// the inline definition for --spec, and the caller's label. All fields are empty for a named
// executable, which can be looked up in its flowfile instead.
type transientMeta struct {
command, spec, label string
// dir is the run's resolved working directory, which for --cmd may differ from the
// process's own cwd.
dir string
}

// recordRunStart writes an in-progress ("running") execution record before the run begins, so that
// `flow logs` (from any process, via the shared store) can show the run as active. It is keyed by
// the run's log archive ID so recordExecution can upsert it into its terminal state on completion.
// No-op when there is no stable ID (legacy fallback: only the terminal record is written).
// command/label are set for ad-hoc runs and empty for named executables.
func recordRunStart(
ctx *context.Context, ref executable.Ref, startTime time.Time, prov provenance, command, label string,
ctx *context.Context, ref executable.Ref, startTime time.Time, prov provenance, meta transientMeta,
) {
if ctx.DataStore == nil || ctx.LogArchiveID == "" {
return
Expand All @@ -727,8 +744,10 @@ func recordRunStart(
Source: prov.source,
ClientName: prov.client,
SessionID: prov.session,
Command: command,
Label: label,
WorkingDir: prov.dir,
Command: meta.command,
Spec: meta.spec,
Label: meta.label,
}
if recErr := ctx.DataStore.RecordExecution(record); recErr != nil {
logger.Log().Debug("failed to record run start", "err", recErr)
Expand All @@ -737,7 +756,7 @@ func recordRunStart(

func recordExecution(
ctx *context.Context, ref executable.Ref, startTime time.Time, dur time.Duration, runErr error,
prov provenance, command, label string,
prov provenance, meta transientMeta,
) {
now := time.Now()
record := store.ExecutionRecord{
Expand All @@ -751,8 +770,10 @@ func recordExecution(
Source: prov.source,
ClientName: prov.client,
SessionID: prov.session,
Command: command,
Label: label,
WorkingDir: prov.dir,
Command: meta.command,
Spec: meta.spec,
Label: meta.label,
}
if runErr != nil {
record.ExitCode = 1
Expand Down
2 changes: 1 addition & 1 deletion cmd/internal/flags/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ var LogFilterStatusFlag = &Metadata{

var LogFilterSourceFlag = &Metadata{
Name: "source",
Usage: "Filter history by run origin: 'cli' or 'mcp'.",
Usage: "Filter history by run origin, e.g. 'cli', 'desktop' or 'mcp'.",
Default: "",
Required: false,
}
Expand Down
2 changes: 1 addition & 1 deletion docs/cli/flow_logs.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ flow logs [ref] [flags]
--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'.
--source string Filter history by run origin, e.g. 'cli', 'desktop' 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
102 changes: 67 additions & 35 deletions internal/io/logs/output.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"io"
"strings"
"time"

tuikitIO "github.com/flowexec/tuikit/io"
Expand All @@ -14,18 +15,23 @@ import (
)

type recordOutput struct {
Ref string `json:"ref" yaml:"ref"`
StartedAt string `json:"startedAt" yaml:"startedAt"`
Duration string `json:"duration" yaml:"duration"`
Status string `json:"status" yaml:"status"`
ExitCode int `json:"exitCode" yaml:"exitCode"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
LogFile string `json:"logFile,omitempty" yaml:"logFile,omitempty"`
Command string `json:"command,omitempty" yaml:"command,omitempty"`
Label string `json:"label,omitempty" yaml:"label,omitempty"`
Source string `json:"source,omitempty" yaml:"source,omitempty"`
ClientName string `json:"clientName,omitempty" yaml:"clientName,omitempty"`
SessionID string `json:"sessionId,omitempty" yaml:"sessionId,omitempty"`
// ID is the run's stable identifier. Empty for legacy records, which predate it.
ID string `json:"id,omitempty" yaml:"id,omitempty"`
Ref string `json:"ref" yaml:"ref"`
StartedAt string `json:"startedAt" yaml:"startedAt"`
CompletedAt string `json:"completedAt,omitempty" yaml:"completedAt,omitempty"`
Duration string `json:"duration" yaml:"duration"`
Status string `json:"status" yaml:"status"`
ExitCode int `json:"exitCode" yaml:"exitCode"`
Error string `json:"error,omitempty" yaml:"error,omitempty"`
LogFile string `json:"logFile,omitempty" yaml:"logFile,omitempty"`
Command string `json:"command,omitempty" yaml:"command,omitempty"`
Spec string `json:"spec,omitempty" yaml:"spec,omitempty"`
Label string `json:"label,omitempty" yaml:"label,omitempty"`
Source string `json:"source,omitempty" yaml:"source,omitempty"`
ClientName string `json:"clientName,omitempty" yaml:"clientName,omitempty"`
SessionID string `json:"sessionId,omitempty" yaml:"sessionId,omitempty"`
WorkingDir string `json:"workingDir,omitempty" yaml:"workingDir,omitempty"`

// Content and its companions are populated only when log content is requested.
Content string `json:"content,omitempty" yaml:"content,omitempty"`
Expand All @@ -40,17 +46,23 @@ type recordsResponse struct {

func toRecordOutput(r UnifiedRecord, content ContentOptions, includeContent bool) recordOutput {
out := recordOutput{
ID: r.ID,
Ref: r.Ref,
StartedAt: r.StartedAt.Format(time.RFC3339),
Duration: r.Duration.Round(time.Millisecond).String(),
Status: string(CanonicalStatus(r)),
ExitCode: r.ExitCode,
Error: r.Error,
Command: r.Command,
Spec: r.Spec,
Label: r.Label,
Source: r.Source,
ClientName: r.ClientName,
SessionID: r.SessionID,
WorkingDir: r.WorkingDir,
}
if r.CompletedAt != nil {
out.CompletedAt = r.CompletedAt.Format(time.RFC3339)
}
if r.LogEntry != nil {
out.LogFile = r.LogEntry.Path
Expand Down Expand Up @@ -143,29 +155,7 @@ func PrintLastRecord(
}
_, _ = fmt.Fprint(stdout, string(data))
default:
_, _ = fmt.Fprintf(stdout, "Executable: %s\n", record.Ref)
if record.Label != "" {
_, _ = fmt.Fprintf(stdout, "Label: %s\n", record.Label)
}
if record.Command != "" {
_, _ = fmt.Fprintf(stdout, "Command: %s\n", record.Command)
}
_, _ = fmt.Fprintf(stdout, "Time: %s\n", record.StartedAt.Format(time.RFC3339))
_, _ = fmt.Fprintf(stdout, "Duration: %s\n", record.Duration.Round(time.Millisecond))
_, _ = fmt.Fprintf(stdout, "Status: %s\n", StatusText(record))
if record.Source != "" {
_, _ = fmt.Fprintf(stdout, "Source: %s\n", record.Source)
}
if record.ClientName != "" {
_, _ = fmt.Fprintf(stdout, "Client: %s\n", record.ClientName)
}
if record.SessionID != "" {
_, _ = fmt.Fprintf(stdout, "Session: %s\n", record.SessionID)
}
if record.Error != "" {
_, _ = fmt.Fprintf(stdout, "Error: %s\n", record.Error)
}
_, _ = fmt.Fprintln(stdout)
printRecordMetadata(record, stdout)

if record.LogEntry != nil {
raw, err := record.LogEntry.Read()
Expand All @@ -190,3 +180,45 @@ func PrintLastRecord(
}
}
}

// printRecordMetadata writes a record's key/value header for text output. Optional fields are
// omitted rather than shown empty, so a named executable's header stays as short as it is.
func printRecordMetadata(record UnifiedRecord, stdout io.Writer) {
_, _ = fmt.Fprintf(stdout, "Executable: %s\n", record.Ref)
optional := []struct{ label, value string }{
{"Label", record.Label},
{"Command", record.Command},
{"Spec", indentBlock(record.Spec, metadataLabelWidth)},
}
for _, f := range optional {
if f.value != "" {
_, _ = fmt.Fprintf(stdout, "%-*s%s\n", metadataLabelWidth, f.label+":", f.value)
}
}
_, _ = fmt.Fprintf(stdout, "Time: %s\n", record.StartedAt.Format(time.RFC3339))
_, _ = fmt.Fprintf(stdout, "Duration: %s\n", record.Duration.Round(time.Millisecond))
_, _ = fmt.Fprintf(stdout, "Status: %s\n", StatusText(record))
for _, f := range []struct{ label, value string }{
{"Source", record.Source},
{"Client", record.ClientName},
{"Session", record.SessionID},
{"Directory", record.WorkingDir},
{"Error", record.Error},
} {
if f.value != "" {
_, _ = fmt.Fprintf(stdout, "%-*s%s\n", metadataLabelWidth, f.label+":", f.value)
}
}
_, _ = fmt.Fprintln(stdout)
}

// metadataLabelWidth is the column the text-mode metadata values line up at.
const metadataLabelWidth = 12

// indentBlock aligns a multi-line value under the label column of the key/value metadata
// block, so a spec's YAML keeps its shape instead of running back to column zero. Empty in,
// empty out — the caller uses that to decide whether the field is worth printing at all.
func indentBlock(s string, width int) string {
lines := strings.Split(strings.TrimRight(s, "\n"), "\n")
return strings.Join(lines, "\n"+strings.Repeat(" ", width))
}
83 changes: 83 additions & 0 deletions internal/io/logs/output_internal_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package logs

import (
"strings"
"testing"
"time"

"github.com/flowexec/flow/v2/pkg/store"
)

func TestToRecordOutput_RunIdentity(t *testing.T) {
started := time.Date(2026, 7, 27, 17, 20, 29, 0, time.UTC)
completed := started.Add(45 * time.Millisecond)

t.Run("carries the run ID, completion time and spec", func(t *testing.T) {
out := toRecordOutput(UnifiedRecord{ExecutionRecord: store.ExecutionRecord{
ID: "4d75c29b-36e5-4b40-b840-98951746254e",
Ref: "exec flow/spec-build",
StartedAt: started,
CompletedAt: &completed,
Duration: 45 * time.Millisecond,
Status: store.RunCompleted,
Spec: "verb: run\nexec:\n cmd: echo hi\n",
Label: "build",
}}, ContentOptions{}, false)

if out.ID != "4d75c29b-36e5-4b40-b840-98951746254e" {
t.Errorf("expected the run ID to survive; got %q", out.ID)
}
if out.CompletedAt != completed.Format(time.RFC3339) {
t.Errorf("expected RFC3339 completedAt, got %q", out.CompletedAt)
}
if !strings.Contains(out.Spec, "cmd: echo hi") {
t.Errorf("expected the spec to survive; got %q", out.Spec)
}
})

t.Run("carries the working directory", func(t *testing.T) {
// The ref names the workspace, but not which checkout of it — sibling worktrees
// produce identical refs, so the path is the only thing telling them apart.
out := toRecordOutput(UnifiedRecord{ExecutionRecord: store.ExecutionRecord{
Ref: "run mochi/build",
StartedAt: started,
WorkingDir: "/Users/x/worktrees/feature-a",
}}, ContentOptions{}, false)

if out.WorkingDir != "/Users/x/worktrees/feature-a" {
t.Errorf("expected the working directory to survive; got %q", out.WorkingDir)
}
})

t.Run("omits completedAt for a run still in progress", func(t *testing.T) {
out := toRecordOutput(UnifiedRecord{ExecutionRecord: store.ExecutionRecord{
Ref: "run flow/build",
StartedAt: started,
Status: store.RunRunning,
}}, ContentOptions{}, false)

if out.CompletedAt != "" {
t.Errorf("expected an empty completedAt while running, got %q", out.CompletedAt)
}
if out.Status != string(store.RunRunning) {
t.Errorf("expected status %q, got %q", store.RunRunning, out.Status)
}
})
}

func TestIndentBlock(t *testing.T) {
t.Run("aligns continuation lines under the label column", func(t *testing.T) {
got := indentBlock("verb: run\nexec:\n cmd: echo hi\n", 4)
want := "verb: run\n exec:\n cmd: echo hi"
if got != want {
t.Errorf("expected %q, got %q", want, got)
}
})

t.Run("empty in, empty out", func(t *testing.T) {
// printRecordMetadata keys "should I print this field at all?" off the result.
if got := indentBlock("", 12); got != "" {
t.Errorf("expected an empty string, got %q", got)
}
})
}
Loading
Loading