diff --git a/apps/pipeline_launcher/app/model.go b/apps/pipeline_launcher/app/model.go new file mode 100644 index 00000000..96cbb51d --- /dev/null +++ b/apps/pipeline_launcher/app/model.go @@ -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 +} diff --git a/apps/pipeline_launcher/config/config.go b/apps/pipeline_launcher/config/config.go new file mode 100644 index 00000000..4dcd3622 --- /dev/null +++ b/apps/pipeline_launcher/config/config.go @@ -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, + } +} diff --git a/apps/pipeline_launcher/go.mod b/apps/pipeline_launcher/go.mod new file mode 100644 index 00000000..0efb6cda --- /dev/null +++ b/apps/pipeline_launcher/go.mod @@ -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 +) diff --git a/apps/pipeline_launcher/go.sum b/apps/pipeline_launcher/go.sum new file mode 100644 index 00000000..7278f31d --- /dev/null +++ b/apps/pipeline_launcher/go.sum @@ -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= diff --git a/apps/pipeline_launcher/main.go b/apps/pipeline_launcher/main.go new file mode 100644 index 00000000..18cc1890 --- /dev/null +++ b/apps/pipeline_launcher/main.go @@ -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) + } +} diff --git a/apps/pipeline_launcher/views/history.go b/apps/pipeline_launcher/views/history.go new file mode 100644 index 00000000..df19bccd --- /dev/null +++ b/apps/pipeline_launcher/views/history.go @@ -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() +} diff --git a/apps/pipeline_launcher/views/keys.go b/apps/pipeline_launcher/views/keys.go new file mode 100644 index 00000000..2ed80d08 --- /dev/null +++ b/apps/pipeline_launcher/views/keys.go @@ -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 +} diff --git a/apps/pipeline_launcher/views/pipelines.go b/apps/pipeline_launcher/views/pipelines.go new file mode 100644 index 00000000..a75b6f74 --- /dev/null +++ b/apps/pipeline_launcher/views/pipelines.go @@ -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 +} diff --git a/apps/pipeline_launcher/views/runner.go b/apps/pipeline_launcher/views/runner.go new file mode 100644 index 00000000..3dfdae22 --- /dev/null +++ b/apps/pipeline_launcher/views/runner.go @@ -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, + } +} diff --git a/functions/pipelines/pipeline_launcher.go b/functions/pipelines/pipeline_launcher.go new file mode 100644 index 00000000..6ab02d5a --- /dev/null +++ b/functions/pipelines/pipeline_launcher.go @@ -0,0 +1,8 @@ +package pipelines + +// PipelineLauncher es una TUI fullscreen para listar, lanzar y monitorear pipelines. +// +// Tabs: Pipelines (lista filtrable + lanzamiento) y History (historial de ejecuciones). +// Registra cada ejecución en operations.db usando fn_operations. +// +// Implementation: apps/pipeline_launcher/main.go diff --git a/functions/pipelines/pipeline_launcher.md b/functions/pipelines/pipeline_launcher.md new file mode 100644 index 00000000..96365c69 --- /dev/null +++ b/functions/pipelines/pipeline_launcher.md @@ -0,0 +1,47 @@ +--- +name: pipeline_launcher +kind: pipeline +lang: go +domain: infra +version: "1.0.0" +purity: impure +signature: "func main() — TUI fullscreen para lanzar pipelines y registrar ejecuciones" +description: "TUI interactiva que lista pipelines del registry, permite lanzarlos como subprocesos y registra cada ejecución en operations.db. Dos tabs: Pipelines (filtro + launch) y History (historial)." +tags: [tui, pipeline, launcher, infra, operations, bubbletea, devfactory] +uses_functions: + - docker_tui_go_infra +uses_types: + - base_model_go_tui + - filtered_list_model_go_tui + - spinner_model_go_tui + - styles_go_tui +returns: [] +returns_optional: false +error_type: "error_go_core" +imports: + - github.com/charmbracelet/bubbletea + - github.com/lucasdataproyects/devfactory + - fn-registry/registry + - fn-registry/fn_operations +tested: false +tests: [] +test_file_path: "" +file_path: "apps/pipeline_launcher/main.go" +--- + +## Ejemplo + +```bash +FN_REGISTRY_ROOT=/home/lucas/fn_registry go run apps/pipeline_launcher/main.go +``` + +## Notas + +TUI con dos tabs: + +- **Pipelines**: lista filtrable de todos los pipelines del registry. Enter lanza el pipeline seleccionado como subproceso (`go run .`), muestra spinner durante la ejecución, y al terminar muestra stdout/stderr y registra la ejecución en operations.db. +- **History**: historial de ejecuciones pasadas con status, duración y timestamp. Enter muestra detalle completo. + +Teclas: Tab/Shift+Tab cambia vista, Enter ejecuta acción, r refresca, / filtra, j/k scroll, Esc vuelve. + +Requiere `FN_REGISTRY_ROOT` apuntando a la raíz del fn-registry para encontrar registry.db y los file_path de los pipelines. diff --git a/launcher.sh b/launcher.sh new file mode 100755 index 00000000..8d24ff4e --- /dev/null +++ b/launcher.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +export FN_REGISTRY_ROOT="$(cd "$(dirname "$0")" && pwd)" +cd "$FN_REGISTRY_ROOT/apps/pipeline_launcher" +CGO_ENABLED=1 exec go run -tags fts5 .