diff --git a/cmd/internal/exec.go b/cmd/internal/exec.go index 945e715f..3e4d7c87 100644 --- a/cmd/internal/exec.go +++ b/cmd/internal/exec.go @@ -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 != "" { @@ -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 @@ -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 @@ -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() @@ -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) @@ -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 @@ -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 @@ -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) @@ -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{ @@ -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 diff --git a/cmd/internal/flags/types.go b/cmd/internal/flags/types.go index 95b2b805..707b8c78 100644 --- a/cmd/internal/flags/types.go +++ b/cmd/internal/flags/types.go @@ -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, } diff --git a/docs/cli/flow_logs.md b/docs/cli/flow_logs.md index 303c4c45..222d2cdf 100644 --- a/docs/cli/flow_logs.md +++ b/docs/cli/flow_logs.md @@ -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. diff --git a/internal/io/logs/output.go b/internal/io/logs/output.go index 02865f8a..98fcac82 100644 --- a/internal/io/logs/output.go +++ b/internal/io/logs/output.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "io" + "strings" "time" tuikitIO "github.com/flowexec/tuikit/io" @@ -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"` @@ -40,6 +46,7 @@ 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(), @@ -47,10 +54,15 @@ func toRecordOutput(r UnifiedRecord, content ContentOptions, includeContent bool 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 @@ -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() @@ -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)) +} diff --git a/internal/io/logs/output_internal_test.go b/internal/io/logs/output_internal_test.go new file mode 100644 index 00000000..8326f138 --- /dev/null +++ b/internal/io/logs/output_internal_test.go @@ -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) + } + }) +} diff --git a/internal/io/logs/records.go b/internal/io/logs/records.go index 9b24ecd4..3644cba5 100644 --- a/internal/io/logs/records.go +++ b/internal/io/logs/records.go @@ -16,7 +16,7 @@ import ( type RecordFilter struct { Workspace string Status string // lifecycle status: running/completed/failed (success/failure accepted as aliases) - Source string // provenance origin: "cli" or "mcp" + Source string // provenance origin, e.g. "cli", "desktop", "mcp" Session string // provenance session ID Client string // provenance client name (e.g. "claude", "cursor") Since time.Time diff --git a/internal/mcp/command_executor.go b/internal/mcp/command_executor.go index cf86f947..a4962874 100644 --- a/internal/mcp/command_executor.go +++ b/internal/mcp/command_executor.go @@ -5,7 +5,9 @@ import ( "fmt" "os" "os/exec" + "sync" + "github.com/google/uuid" "github.com/mark3labs/mcp-go/server" "github.com/pkg/errors" @@ -34,6 +36,17 @@ func provenanceFromContext(ctx context.Context) (runProvenance, bool) { return p, ok } +// stdioSessionID is what mcp-go reports as the session ID for every stdio connection — a +// package constant, not a per-connection value (see mcp-go server/stdio.go). Taken at face +// value it collapses every run from every client into one "session". +const stdioSessionID = "stdio" + +// processSessionID identifies this server process as a single client session. flow's MCP server +// is stdio-only and each client spawns its own `flow mcp` process, so one process is exactly one +// client connection — the session boundary mcp-go's constant fails to draw. It is resolved once +// and never changes, which is what makes it a usable grouping key in execution history. +var processSessionID = sync.OnceValue(uuid.NewString) + // mcpProvenance builds run provenance for an MCP-originated command, capturing the calling client's // name and session ID from the MCP session (when the transport exposes them). func mcpProvenance(ctx context.Context) runProvenance { @@ -44,6 +57,11 @@ func mcpProvenance(ctx context.Context) runProvenance { prov.Client = withInfo.GetClientInfo().Name } } + // Only substitute when the transport gave us nothing usable, so a transport that does issue + // genuine per-connection IDs keeps them. + if prov.Session == "" || prov.Session == stdioSessionID { + prov.Session = processSessionID() + } return prov } diff --git a/internal/mcp/output_types.go b/internal/mcp/output_types.go index e8f30ecb..5cfe9a51 100644 --- a/internal/mcp/output_types.go +++ b/internal/mcp/output_types.go @@ -37,6 +37,10 @@ type CurrentContext struct { Vault string `json:"vault"` WorkspaceMode string `json:"workspaceMode"` WorkspacePath string `json:"workspacePath"` + // SessionID tags every run this connection launches, and is what `flow logs --session` + // filters on. A client reads it once here rather than inferring which records are its own: + // the value is knowable only to the server, since the stdio transport carries no identity. + SessionID string `json:"sessionId"` } // WorkspaceOutput is the output of the get_workspace tool. diff --git a/internal/mcp/provenance_internal_test.go b/internal/mcp/provenance_internal_test.go new file mode 100644 index 00000000..fc91f616 --- /dev/null +++ b/internal/mcp/provenance_internal_test.go @@ -0,0 +1,65 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/mark3labs/mcp-go/mcp" + "github.com/mark3labs/mcp-go/server" + + "github.com/flowexec/flow/v2/pkg/store" +) + +// fakeSession is a minimal ClientSession reporting a fixed ID, standing in for a transport. +type fakeSession struct{ id string } + +func (f fakeSession) Initialize() {} +func (f fakeSession) Initialized() bool { return true } +func (f fakeSession) NotificationChannel() chan<- mcp.JSONRPCNotification { + return nil +} +func (f fakeSession) SessionID() string { return f.id } + +func TestMCPProvenance(t *testing.T) { + srv := server.NewMCPServer("test", "1.0.0") + + t.Run("replaces mcp-go's stdio constant with the process session ID", func(t *testing.T) { + ctx := srv.WithContext(context.Background(), fakeSession{id: stdioSessionID}) + prov := mcpProvenance(ctx) + if prov.Session == stdioSessionID { + t.Fatalf("expected %q to be replaced; every client would share one session", stdioSessionID) + } + if prov.Session != processSessionID() { + t.Errorf("expected the process session ID, got %q", prov.Session) + } + }) + + t.Run("replaces an absent session ID", func(t *testing.T) { + if prov := mcpProvenance(context.Background()); prov.Session != processSessionID() { + t.Errorf("expected the process session ID, got %q", prov.Session) + } + }) + + t.Run("keeps a genuine per-connection session ID", func(t *testing.T) { + ctx := srv.WithContext(context.Background(), fakeSession{id: "real-session-7"}) + if prov := mcpProvenance(ctx); prov.Session != "real-session-7" { + t.Errorf("expected the transport's session ID, got %q", prov.Session) + } + }) + + t.Run("always marks the run as MCP-sourced", func(t *testing.T) { + if prov := mcpProvenance(context.Background()); prov.Source != store.RunSourceMCP { + t.Errorf("expected source %q, got %q", store.RunSourceMCP, prov.Source) + } + }) +} + +func TestProcessSessionID_StableAndNonEmpty(t *testing.T) { + first := processSessionID() + if first == "" { + t.Fatal("expected a non-empty process session ID") + } + if second := processSessionID(); second != first { + t.Errorf("expected a stable ID across calls, got %q then %q", first, second) + } +} diff --git a/internal/mcp/tools_system.go b/internal/mcp/tools_system.go index 441605cc..80c88e01 100644 --- a/internal/mcp/tools_system.go +++ b/internal/mcp/tools_system.go @@ -29,10 +29,12 @@ const ( func addSystemTools(srv *server.MCPServer, executor CommandExecutor) { getFlowInfo := mcp.NewTool("get_info", mcp.WithDescription( - "Bootstrap context about the flow environment. Returns the current workspace, "+ - "schema URLs for authoring .flow files, and the docs index (llms.txt). "+ - "Call this at the start of a session to understand the project's automation setup, "+ - "or whenever you need schema URLs to author or validate flow configuration."), + "Bootstrap context about the flow environment. Returns the current workspace, this "+ + "connection's session ID, schema URLs for authoring .flow files, and the docs index "+ + "(llms.txt). Call this at the start of a session to understand the project's automation "+ + "setup, or whenever you need schema URLs to author or validate flow configuration. "+ + "The session ID tags every run this connection launches — keep it to group them later "+ + "via `flow logs --session`."), ) getFlowInfo.Annotations = mcp.ToolAnnotation{ Title: "Get flow information and current context", @@ -84,7 +86,7 @@ func addSystemTools(srv *server.MCPServer, executor CommandExecutor) { srv.AddTool(sync, syncStateHandler(srv, executor)) } -func getInfoHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { +func getInfoHandler(ctx context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResult, error) { cfg, err := filesystem.LoadConfig() if err != nil { return toolError(ErrCodeInternal, fmt.Sprintf("failed to load user config: %s", err)), nil @@ -107,6 +109,7 @@ func getInfoHandler(_ context.Context, _ mcp.CallToolRequest) (*mcp.CallToolResu Vault: cfg.CurrentVaultName(), WorkspaceMode: string(cfg.WorkspaceMode), WorkspacePath: wsPath, + SessionID: mcpProvenance(ctx).Session, }, Summary: flowInfoSummary, DocsURL: docsBaseURL, diff --git a/pkg/store/store.go b/pkg/store/store.go index cb6c29f2..e9d8f287 100644 --- a/pkg/store/store.go +++ b/pkg/store/store.go @@ -35,9 +35,13 @@ const ( RunClientEnv = "FLOW_RUN_CLIENT" RunSessionEnv = "FLOW_RUN_SESSION" - // RunSourceCLI and RunSourceMCP are the recognized values for RunSourceEnv / ExecutionRecord.Source. - RunSourceCLI = "cli" - RunSourceMCP = "mcp" + // Known values for RunSourceEnv / ExecutionRecord.Source, naming who drove the run: a human + // at a terminal, a human in a GUI, or an agent over MCP. The set is open — Source is compared + // as a plain string everywhere, so an embedder may record its own origin without a change + // here. These are the ones flow itself produces. + RunSourceCLI = "cli" + RunSourceDesktop = "desktop" + RunSourceMCP = "mcp" openTimeout = 3 * time.Second ) @@ -115,13 +119,21 @@ type ExecutionRecord struct { // Command is the shell command for an ad-hoc (transient) run; empty for named executables. Command string `json:"command,omitempty"` + // Spec is the inline executable definition for a transient `--spec` run. A spec run has no + // flowfile to look up and no single Command to show, so without this the record names an + // executable that never existed on disk and nothing can say what it did. + Spec string `json:"spec,omitempty"` // Label is a human-readable, self-documenting name for an ad-hoc run. Label string `json:"label,omitempty"` // Provenance: who/what launched the run. - Source string `json:"source,omitempty"` // "cli" | "mcp" + Source string `json:"source,omitempty"` // "cli" | "desktop" | "mcp" ClientName string `json:"clientName,omitempty"` // e.g. "claude", "cursor" SessionID string `json:"sessionId,omitempty"` + // WorkingDir is where the run actually executed. The workspace is already recoverable from + // Ref, but the path is not, and it is the only thing separating two checkouts of the same + // repo — sibling git worktrees produce identical refs from different directories. + WorkingDir string `json:"workingDir,omitempty"` } // BackgroundRunStatus represents the state of a background run.