feat: pipeline_launcher TUI para lanzar pipelines y registrar ejecuciones

TUI fullscreen con dos tabs: Pipelines (lista filtrable del registry,
lanzamiento como subproceso, registro automático en operations.db) y
History (historial de ejecuciones con status, duración y detalles).
Incluye launcher.sh en la raíz para ejecución rápida.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-28 17:14:11 +01:00
parent a8f5b3c828
commit edfff680b3
12 changed files with 949 additions and 0 deletions
+181
View File
@@ -0,0 +1,181 @@
package app
import (
"fmt"
ops "fn-registry/fn_operations"
"fn-registry/registry"
"pipeline-launcher/config"
"pipeline-launcher/views"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
// View identifies which tab is active.
type View int
const (
ViewPipelines View = iota
ViewHistory
)
var tabNames = []string{"Pipelines", "History"}
// Model is the top-level TUI model with two tabs.
type Model struct {
tui.BaseModel
activeTab int
pipelines views.PipelinesModel
history views.HistoryModel
ready bool
registryDB *registry.DB
opsDB *ops.DB
}
// New creates the Model, opening both databases.
func New(cfg config.Config) (Model, error) {
regDB, err := registry.Open(cfg.RegistryDB)
if err != nil {
return Model{}, fmt.Errorf("opening registry: %w", err)
}
opsDB, err := ops.Open(cfg.OperationsDB)
if err != nil {
regDB.Close()
return Model{}, fmt.Errorf("opening operations: %w", err)
}
// Build pipeline name map for history view
fns, _ := regDB.SearchFunctions("", registry.KindPipeline, "", "", "")
names := make(map[string]string, len(fns))
for _, f := range fns {
names[f.ID] = f.Name
}
styles := tui.DarkStyles()
return Model{
BaseModel: tui.NewBaseModel().WithStyles(styles),
pipelines: views.NewPipelinesModel(styles, regDB, opsDB, cfg.RegistryRoot),
history: views.NewHistoryModel(styles, opsDB, names),
registryDB: regDB,
opsDB: opsDB,
}, nil
}
// Close closes both database connections.
func (m Model) Close() {
if m.registryDB != nil {
m.registryDB.Close()
}
if m.opsDB != nil {
m.opsDB.Close()
}
}
func (m Model) Init() tea.Cmd {
return m.pipelines.Init()
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case views.KeyQuit:
return m, tea.Quit
case "q":
updated, atBase := m.handleBack()
if atBase {
return updated, tea.Quit
}
return updated, nil
case views.KeyEsc, views.KeyBack:
updated, atBase := m.handleBack()
if atBase {
return updated, nil
}
return updated, nil
case views.KeyTab:
m.activeTab = (m.activeTab + 1) % len(tabNames)
return m, m.initActiveView()
case "shift+tab":
m.activeTab = (m.activeTab - 1 + len(tabNames)) % len(tabNames)
return m, m.initActiveView()
}
case tea.WindowSizeMsg:
m.HandleWindowSize(msg)
m.ready = true
}
var cmd tea.Cmd
switch View(m.activeTab) {
case ViewPipelines:
m.pipelines, cmd = m.pipelines.Update(msg)
case ViewHistory:
m.history, cmd = m.history.Update(msg)
}
return m, cmd
}
func (m Model) View() string {
if !m.ready {
return "Loading..."
}
tabs := m.renderTabs()
var content string
switch View(m.activeTab) {
case ViewPipelines:
content = m.pipelines.View()
case ViewHistory:
content = m.history.View()
}
status := m.Styles.StatusBar.Render(" Tab: switch view │ Ctrl+C: quit │ Enter: action │ r: refresh")
return lipgloss.JoinVertical(lipgloss.Left,
tabs,
"",
content,
"",
status,
)
}
func (m Model) renderTabs() string {
var tabs []string
for i, name := range tabNames {
if i == m.activeTab {
tabs = append(tabs, m.Styles.Selected.Render(" "+name+" "))
} else {
tabs = append(tabs, m.Styles.Muted.Render(" "+name+" "))
}
}
row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
return m.Styles.Header.Render("Pipeline Launcher") + " " + row
}
func (m Model) handleBack() (Model, bool) {
switch View(m.activeTab) {
case ViewPipelines:
atBase := m.pipelines.HandleBack()
return m, atBase
case ViewHistory:
atBase := m.history.HandleBack()
return m, atBase
}
return m, true
}
func (m Model) initActiveView() tea.Cmd {
switch View(m.activeTab) {
case ViewPipelines:
return m.pipelines.Init()
case ViewHistory:
return m.history.Init()
}
return nil
}
+27
View File
@@ -0,0 +1,27 @@
package config
import (
"os"
"path/filepath"
)
// Config holds paths to databases.
type Config struct {
RegistryDB string // Path to registry.db
OperationsDB string // Path to operations.db
RegistryRoot string // Root directory of the registry (for resolving file paths)
}
// Default returns a Config resolved from environment or sensible defaults.
func Default() Config {
root := os.Getenv("FN_REGISTRY_ROOT")
if root == "" {
root = "."
}
return Config{
RegistryDB: filepath.Join(root, "registry.db"),
OperationsDB: filepath.Join(root, "operations.db"),
RegistryRoot: root,
}
}
+37
View File
@@ -0,0 +1,37 @@
module pipeline-launcher
go 1.22.2
require (
fn-registry v0.0.0
github.com/charmbracelet/bubbletea v0.25.0
github.com/charmbracelet/lipgloss v0.9.1
github.com/lucasdataproyects/devfactory v0.0.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/bubbles v0.18.0 // indirect
github.com/charmbracelet/harmonica v0.2.0 // indirect
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.15 // indirect
github.com/mattn/go-sqlite3 v1.14.37 // indirect
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b // indirect
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.2 // indirect
github.com/rivo/uniseg v0.4.6 // indirect
golang.org/x/sync v0.4.0 // indirect
golang.org/x/sys v0.13.0 // indirect
golang.org/x/term v0.6.0 // indirect
golang.org/x/text v0.13.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
fn-registry => /home/lucas/fn_registry
github.com/lucasdataproyects/devfactory => /home/lucas/.local_agentes/backend
)
+49
View File
@@ -0,0 +1,49 @@
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0=
github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw=
github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM=
github.com/charmbracelet/bubbletea v0.25.0/go.mod h1:EN3QDR1T5ZdWmdfDzYcqOCAps45+QIJbLOBxmVNWNNg=
github.com/charmbracelet/harmonica v0.2.0 h1:8NxJWRWg/bzKqqEaaeFNipOu77YR5t8aSwG4pgaUBiQ=
github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao=
github.com/charmbracelet/lipgloss v0.9.1 h1:PNyd3jvaJbg4jRHKWXnCj1akQm4rh8dbEzN1p/u1KWg=
github.com/charmbracelet/lipgloss v0.9.1/go.mod h1:1mPmG4cxScwUQALAAnacHaigiiHB9Pmr+v1VEawJl6I=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81 h1:q2hJAaP1k2wIvVRd/hEHD7lacgqrCPS+k8g1MndzfWY=
github.com/containerd/console v1.0.4-0.20230313162750-1ae8d489ac81/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.19 h1:JITubQf0MOLdlGRuRq+jtsDlekdYPia9ZFsB8h/APPA=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.37 h1:3DOZp4cXis1cUIpCfXLtmlGolNLp2VEqhiB/PARNBIg=
github.com/mattn/go-sqlite3 v1.14.37/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b h1:1XF24mVaiu7u+CFywTdcDo2ie1pzzhwjt6RHqzpMU34=
github.com/muesli/ansi v0.0.0-20211018074035-2e021307bc4b/go.mod h1:fQuZ0gauxyBcmsdE3ZT4NasjaRdxmbCS0jRHsrWu3Ho=
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.6 h1:Sovz9sDSwbOz9tgUy8JpT+KgCkPYJEN/oYzlJiYTNLg=
github.com/rivo/uniseg v0.4.6/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
golang.org/x/sync v0.4.0 h1:zxkM55ReGkDlKSM+Fu41A+zmbZuaPVbGMzvvdUPznYQ=
golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+29
View File
@@ -0,0 +1,29 @@
package main
import (
"fmt"
"os"
"pipeline-launcher/app"
"pipeline-launcher/config"
"github.com/lucasdataproyects/devfactory/tui"
)
func main() {
cfg := config.Default()
model, err := app.New(cfg)
if err != nil {
fmt.Fprintf(os.Stderr, "error: %v\n", err)
os.Exit(1)
}
defer model.Close()
result := tui.RunFullscreen(model)
if result.IsErr() {
fmt.Fprintf(os.Stderr, "error: %v\n", result.Error())
os.Exit(1)
}
}
+216
View File
@@ -0,0 +1,216 @@
package views
import (
"encoding/json"
"fmt"
"strings"
ops "fn-registry/fn_operations"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type historyState int
const (
historyLoading historyState = iota
historyList
historyDetail
)
type historyLoadedMsg []ops.Execution
// HistoryModel shows execution history.
type HistoryModel struct {
state historyState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
executions []ops.Execution
detail string
scrollOff int
opsDB *ops.DB
pipelineNames map[string]string
}
// NewHistoryModel creates a new history view.
func NewHistoryModel(styles tui.Styles, opsDB *ops.DB, names map[string]string) HistoryModel {
return HistoryModel{
state: historyLoading,
list: tui.NewFilteredList(nil, "Filter executions..."),
spinner: tui.NewSpinner("Loading history..."),
styles: styles,
opsDB: opsDB,
pipelineNames: names,
}
}
func (m HistoryModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadHistory(),
)
}
func (m HistoryModel) loadHistory() tea.Cmd {
return func() tea.Msg {
execs, err := m.opsDB.ListExecutions("", "", "")
if err != nil {
return historyLoadedMsg(nil)
}
return historyLoadedMsg(execs)
}
}
func (m HistoryModel) Update(msg tea.Msg) (HistoryModel, tea.Cmd) {
switch msg := msg.(type) {
case historyLoadedMsg:
m.executions = []ops.Execution(msg)
items := make([]tui.ListItem, len(m.executions))
for i, e := range m.executions {
icon := "●"
switch e.Status {
case ops.ExecSuccess:
icon = "✓"
case ops.ExecFailure:
icon = "✗"
case ops.ExecPartial:
icon = "~"
}
name := e.PipelineID
if n, ok := m.pipelineNames[e.PipelineID]; ok {
name = n
}
dur := ""
if e.DurationMs != nil {
dur = fmt.Sprintf("%dms", *e.DurationMs)
}
items[i] = tui.ListItem{
Title: fmt.Sprintf("%s %s", icon, name),
Description: fmt.Sprintf("%s — %s — %s", string(e.Status), dur, e.StartedAt.Format("2006-01-02 15:04:05")),
Value: e,
}
}
m.list.SetItems(items)
m.state = historyList
return m, nil
case tea.KeyMsg:
switch m.state {
case historyList:
switch msg.String() {
case "r":
m.state = historyLoading
m.spinner = tui.NewSpinner("Loading history...")
return m, tea.Batch(m.spinner.Init(), m.loadHistory())
case "enter":
if item := m.list.SelectedItem(); item != nil {
e := item.Value.(ops.Execution)
m.detail = formatExecution(e)
m.state = historyDetail
m.scrollOff = 0
}
return m, nil
}
case historyDetail:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case historyLoading:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case historyList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *HistoryModel) HandleBack() bool {
switch m.state {
case historyDetail:
m.state = historyList
return false
default:
return true
}
}
func (m HistoryModel) View() string {
switch m.state {
case historyLoading:
return m.spinner.View()
case historyList:
if len(m.executions) == 0 {
return m.styles.Muted.Render("No executions found. Launch a pipeline first.")
}
help := m.styles.Muted.Render(" Enter: details │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case historyDetail:
return m.renderDetail()
}
return ""
}
func (m HistoryModel) renderDetail() string {
lines := splitLines(m.detail)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Execution Detail")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func formatExecution(e ops.Execution) string {
var sb strings.Builder
sb.WriteString(fmt.Sprintf("ID: %s\n", e.ID))
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", e.PipelineID))
sb.WriteString(fmt.Sprintf("Status: %s\n", e.Status))
sb.WriteString(fmt.Sprintf("Started: %s\n", e.StartedAt.Format("2006-01-02 15:04:05")))
if e.EndedAt != nil {
sb.WriteString(fmt.Sprintf("Ended: %s\n", e.EndedAt.Format("2006-01-02 15:04:05")))
}
if e.DurationMs != nil {
sb.WriteString(fmt.Sprintf("Duration: %dms\n", *e.DurationMs))
}
if e.RecordsIn != nil {
sb.WriteString(fmt.Sprintf("Records In: %d\n", *e.RecordsIn))
}
if e.RecordsOut != nil {
sb.WriteString(fmt.Sprintf("Records Out: %d\n", *e.RecordsOut))
}
if e.Error != "" {
sb.WriteString(fmt.Sprintf("\n--- Error ---\n%s\n", e.Error))
}
if len(e.Metrics) > 0 {
sb.WriteString("\n--- Metrics ---\n")
b, _ := json.MarshalIndent(e.Metrics, "", " ")
sb.WriteString(string(b))
sb.WriteString("\n")
}
return sb.String()
}
+14
View File
@@ -0,0 +1,14 @@
package views
// Navigation key constants.
const (
KeyQuit = "ctrl+c"
KeyEsc = "esc"
KeyBack = "0"
KeyTab = "tab"
)
// IsBack returns true if the key should trigger back navigation.
func IsBack(key string) bool {
return key == KeyEsc || key == KeyBack
}
+249
View File
@@ -0,0 +1,249 @@
package views
import (
"fmt"
"strings"
ops "fn-registry/fn_operations"
"fn-registry/registry"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/lucasdataproyects/devfactory/tui"
)
type pipelinesState int
const (
pipelinesLoading pipelinesState = iota
pipelinesList
pipelinesRunning
pipelinesOutput
)
type pipelinesLoadedMsg []registry.Function
type pipelineFinishedMsg RunResult
// PipelinesModel lists and launches pipelines.
type PipelinesModel struct {
state pipelinesState
list tui.FilteredListModel
spinner tui.SpinnerModel
styles tui.Styles
pipelines []registry.Function
output string
lastResult *RunResult
scrollOff int
err error
registryDB *registry.DB
opsDB *ops.DB
registryRoot string
}
// NewPipelinesModel creates a new pipelines view.
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
return PipelinesModel{
state: pipelinesLoading,
list: tui.NewFilteredList(nil, "Filter pipelines..."),
spinner: tui.NewSpinner("Loading pipelines..."),
styles: styles,
registryDB: regDB,
opsDB: opsDB,
registryRoot: root,
}
}
func (m PipelinesModel) Init() tea.Cmd {
return tea.Batch(
m.spinner.Init(),
m.loadPipelines(),
)
}
func (m PipelinesModel) loadPipelines() tea.Cmd {
return func() tea.Msg {
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
if err != nil {
return pipelinesLoadedMsg(nil)
}
return pipelinesLoadedMsg(fns)
}
}
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
switch msg := msg.(type) {
case pipelinesLoadedMsg:
m.pipelines = []registry.Function(msg)
items := make([]tui.ListItem, len(m.pipelines))
for i, p := range m.pipelines {
items[i] = tui.ListItem{
Title: p.Name,
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
Value: p,
}
}
m.list.SetItems(items)
m.state = pipelinesList
return m, nil
case pipelineFinishedMsg:
result := RunResult(msg)
m.lastResult = &result
// Build output
var sb strings.Builder
if result.Status == ops.ExecSuccess {
sb.WriteString("[OK] ")
} else {
sb.WriteString("[FAIL] ")
}
sb.WriteString(fmt.Sprintf("Pipeline: %s\n", result.PipelineID))
sb.WriteString(fmt.Sprintf("Execution: %s\n", result.ExecID))
sb.WriteString(fmt.Sprintf("Duration: %dms\n", result.DurationMs))
sb.WriteString("\n--- stdout ---\n")
if result.Stdout != "" {
sb.WriteString(result.Stdout)
} else {
sb.WriteString("(empty)")
}
if result.Stderr != "" {
sb.WriteString("\n--- stderr ---\n")
sb.WriteString(result.Stderr)
}
if result.Err != nil {
sb.WriteString(fmt.Sprintf("\n--- error ---\n%v", result.Err))
}
m.output = sb.String()
m.state = pipelinesOutput
m.scrollOff = 0
return m, nil
case tea.KeyMsg:
switch m.state {
case pipelinesList:
switch msg.String() {
case "r":
m.state = pipelinesLoading
m.spinner = tui.NewSpinner("Loading pipelines...")
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
case "enter":
if item := m.list.SelectedItem(); item != nil {
fn := item.Value.(registry.Function)
m.state = pipelinesRunning
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", fn.Name))
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(&fn))
}
}
case pipelinesOutput:
switch msg.String() {
case "j", "down":
m.scrollOff++
case "k", "up":
if m.scrollOff > 0 {
m.scrollOff--
}
}
return m, nil
}
}
// Delegate to sub-components
var cmd tea.Cmd
switch m.state {
case pipelinesLoading, pipelinesRunning:
var spinnerModel tea.Model
spinnerModel, cmd = m.spinner.Update(msg)
m.spinner = spinnerModel.(tui.SpinnerModel)
case pipelinesList:
var listModel tea.Model
listModel, cmd = m.list.Update(msg)
m.list = listModel.(tui.FilteredListModel)
}
return m, cmd
}
func (m PipelinesModel) runPipelineCmd(fn *registry.Function) tea.Cmd {
regRoot := m.registryRoot
opsDB := m.opsDB
fnCopy := *fn
return func() tea.Msg {
result := RunPipeline(&fnCopy, regRoot, opsDB)
return pipelineFinishedMsg(result)
}
}
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
func (m *PipelinesModel) HandleBack() bool {
switch m.state {
case pipelinesOutput:
m.state = pipelinesList
return false
default:
return true
}
}
func (m PipelinesModel) View() string {
switch m.state {
case pipelinesLoading:
return m.spinner.View()
case pipelinesList:
if len(m.pipelines) == 0 {
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
}
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
return m.list.View() + "\n" + help
case pipelinesRunning:
return m.spinner.View()
case pipelinesOutput:
return m.renderOutput()
}
return ""
}
func (m PipelinesModel) renderOutput() string {
lines := splitLines(m.output)
maxLines := 20
if m.scrollOff >= len(lines) {
m.scrollOff = max(0, len(lines)-1)
}
end := min(m.scrollOff+maxLines, len(lines))
visible := lines[m.scrollOff:end]
header := m.styles.Header.Render("Pipeline Output")
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
return header + "\n" + content + "\n" + help
}
func splitLines(s string) []string {
if s == "" {
return []string{"(empty)"}
}
lines := strings.Split(s, "\n")
if len(lines) == 0 {
return []string{"(empty)"}
}
return lines
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n-3] + "..."
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
+88
View File
@@ -0,0 +1,88 @@
package views
import (
"bytes"
"fmt"
"os/exec"
"path/filepath"
"time"
ops "fn-registry/fn_operations"
"fn-registry/registry"
)
// RunResult holds the outcome of a pipeline execution.
type RunResult struct {
Stdout string
Stderr string
ExecID string
PipelineID string
Status ops.ExecutionStatus
DurationMs int64
Err error
}
// RunPipeline executes a pipeline as a subprocess and records the execution.
func RunPipeline(fn *registry.Function, registryRoot string, opsDB *ops.DB) RunResult {
absPath := filepath.Join(registryRoot, fn.FilePath)
dir := filepath.Dir(absPath)
startedAt := time.Now().UTC()
cmd := exec.Command("go", "run", ".")
cmd.Dir = dir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
endedAt := time.Now().UTC()
status := ops.ExecSuccess
var execErr string
if err != nil {
status = ops.ExecFailure
execErr = err.Error()
if stderr.Len() > 0 {
execErr = stderr.String()
}
}
execID := fmt.Sprintf("exec_%d", time.Now().UnixNano())
durationMs := endedAt.Sub(startedAt).Milliseconds()
execution := &ops.Execution{
ID: execID,
PipelineID: fn.ID,
Status: status,
StartedAt: startedAt,
EndedAt: &endedAt,
DurationMs: &durationMs,
Error: execErr,
CreatedAt: time.Now().UTC(),
}
insertErr := ops.InsertExecutionSafe(opsDB, execution)
if insertErr != nil {
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: fmt.Errorf("pipeline ran but failed to record: %w", insertErr),
}
}
return RunResult{
Stdout: stdout.String(),
Stderr: stderr.String(),
ExecID: execID,
PipelineID: fn.ID,
Status: status,
DurationMs: durationMs,
Err: err,
}
}